diff --git a/agents.md b/agents.md index 59de8bb1c22a89c8ae0cbc473ca0bfe7a80d3e48..64413377b8faecf1a58d266796ee7acfb0ce4812 100644 --- a/agents.md +++ b/agents.md @@ -54,7 +54,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **Retired question pages:** `/q/:collection`, `/q/:collection/:aspect`, and the room-scoped twins are gone entirely — no routes serve them, so they 404 (no question index on `/` either). Pairwise judging lives at `/vote?pool=` / `/vote?left=&right=`; aspect groups still exist in the DSL/CLI and render on garden scope pages. -- **`/vote` landing + aspects:** bare `GET /vote` (no pair) is the judgment entry point — it deals the most-compared open question first (`pick_landing_question` in `server/src/html/garden/vote.rs`: highest voted-pair count across canonical scopes and voted aspect groups, ties prefer more members then canonical), with a 3-step intro naming the scope (and `:aspect`), or an empty state when everything is judged. Popularity compounds deliberately: dealing a busy scope's stragglers grows it further. `/vote?pool=&aspect=` votes into that aspect group (hidden `aspect` input over the existing `$form` hole; invalid slugs 400). Garden aspect sections link their vote pool the same way. +- **`/vote` landing + aspects:** bare `GET /vote` (no pair) is the judgment entry point — it deals the most-compared open question first (`pick_landing_question` in `server/src/html/garden/vote.rs`: highest voted-pair count across canonical scopes and voted aspect groups, ties prefer more members then canonical), with a 3-step intro naming the scope (and `:aspect`), or an empty state when everything is judged. Popularity compounds deliberately: dealing a busy scope's stragglers grows it further. Below the dealt pair, a heat index lists more open questions hottest first (`rank_open_questions`: newest vote wins, then newest thread post, so judging heat beats talking heat; the dealt question is excluded; capped at 10) — each row deals straight into its pool, with judged counts and activity instead of scores so it never reads as rank order. `/vote?pool=&aspect=` votes into that aspect group (hidden `aspect` input over the existing `$form` hole; invalid slugs 400). Garden aspect sections link their vote pool the same way. - **`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/html/garden/tests.rs b/server/src/html/garden/tests.rs index ff9875222f8954e2f80d4ce62100176dcdf08d60..c1c8c5e6517bb20eaae31760b7d111d13966b726 100644 --- a/server/src/html/garden/tests.rs +++ b/server/src/html/garden/tests.rs @@ -10,8 +10,9 @@ use super::{ render::aspect_ranking_sections_markup, vote::{ canonical_edge_items, edge_vote_count_for_pair, edge_vote_entries_for_pair, - pick_landing_question, ratios_for_compare_page, sort_votes_for_compare_display, - suggest_next_vote_pair, vote_compare_item_card, vote_pool_href, + pick_landing_question, rank_open_questions, ratios_for_compare_page, + sort_votes_for_compare_display, suggest_next_vote_pair, vote_compare_item_card, + vote_pool_href, }, }; use crate::{ @@ -907,6 +908,57 @@ fn pick_landing_question_returns_none_when_all_judged() { assert!(pick_landing_question(content).is_none()); } +/// Heat index: judging heat beats talking heat — a scope with an old vote +/// outranks a scope with a fresh thread post but no votes, and newer votes +/// outrank older ones. +#[test] +fn rank_open_questions_prefers_votes_over_thread_chatter() { + use crate::events::Ingest as TestIngest; + let mut reduced = ReducerState::default(); + let post = |ts: i64, id: &str, raw: &str, thread_tag: &str| Event::Ingest(TestIngest { + ts, + id: id.to_string(), + raw: raw.to_string(), + principal: "testuser".to_string(), + delegate: None, + room_id: "public".to_string(), + thread_tag: thread_tag.to_string(), + }); + // Voted long ago (still open: 1 of 3 pairs), never discussed since. + reduced.apply_event(post( + 1, + "v1", + "~/voted/a { a }\n~/voted/b { b }\n~/voted/c { c }\n", + "voted", + )); + reduced.apply_event(post( + 2, + "v2", + "{ old }\n~/voted/a 2:1 ~/voted/b\n", + "voted", + )); + // Never voted, talked about yesterday. + reduced.apply_event(post(10, "t1", "~/talked/x { ex }\n~/talked/y { why }\n", "talked")); + reduced.apply_event(post(50, "t2", "lively debate", "talked")); + let content = content_for_garden_view(&reduced, &ScopeId::Public); + let rows = rank_open_questions(content, &reduced.forum_threads, &ScopeId::Public); + assert_eq!(rows.len(), 2); + assert!( + rows[0].question.scope.as_str().ends_with("voted"), + "judged scope first, got {}", + rows[0].question.scope.as_str() + ); + assert!(rows[0].thread_ts > 0); + assert_eq!(rows[0].question.last_vote_ts, 2); + assert!( + rows[1].question.scope.as_str().ends_with("talked"), + "talked-about scope second, got {}", + rows[1].question.scope.as_str() + ); + assert_eq!(rows[1].question.last_vote_ts, 0); + assert_eq!(rows[1].thread_ts, 50); +} + /// Popularity beats neediness: a busy scope with stragglers outranks a fresh /// scope nobody has touched. Winners keep winning. #[test] diff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs index 116a4cc51bcea769bb5458ce166951483dc41758..f0ccb080ff2d941e4ce07f57ac520bab84c12003 100644 --- a/server/src/html/garden/vote.rs +++ b/server/src/html/garden/vote.rs @@ -14,13 +14,14 @@ use crate::{ canonical_path::canonicalize_tag, form_template::template_json_compact, html::{ - format_ratio, forum::ThreadNav, layout_full_bleed_chromeless, ratio_pct, + format_ratio, forum::ThreadNav, layout_full_bleed_chromeless, now_ms, ratio_pct, render_item_body_in_scope, theme_from_jar, theme_next_from_uri, ui_action::UI_RPC_FIELD, user_can_post_room, JsBuilder, }, middleware::canonical_view_url, path_types::ItemId, - reducer::{ContentState, GroupState, ScopeId}, + reducer::{ContentState, ForumThreadState, ScopeId}, + timeago, scope_rank::{comparable_items, suggest_next_pair_in_pool}, state::AppState, }; @@ -579,6 +580,7 @@ pub(super) struct LandingQuestion { pub voted: usize, pub possible: usize, pub members: usize, + pub last_vote_ts: i64, } /// Density of voted pairs among `members` inside one vote graph. @@ -604,76 +606,127 @@ fn voted_density( (voted, possible) } -pub(super) fn pick_landing_question(content: &ContentState) -> Option { - // Most voted pairs wins; ties prefer more members, then canonical over an - // aspect, then lex order (iteration is lex-sorted and only strictly-better - // candidates replace the incumbent). - let mut best: Option = None; +/// Every open question (canonical + aspect groups with ≥1 unvoted pair), +/// unsorted. Powers both the landing deal and the heat index below it. +fn open_questions(content: &ContentState) -> Vec { + let mut out = Vec::new(); let mut scopes: Vec<&ItemId> = content .members_by_scope .keys() .filter(|id| matches!(id.tilde_tail(), Some(t) if !t.is_empty() && !t.contains('/'))) .collect(); scopes.sort_by(|a, b| a.as_str().cmp(b.as_str())); - // (scope, aspect-group-or-canonical, aspect-slug-or-None), canonical first - // per scope so ties keep the canonical question. - let mut candidates: Vec<(&ItemId, Option<(&GroupState, String)>)> = Vec::new(); - for scope in &scopes { - candidates.push((scope, None)); - } let mut aspect_keys: Vec<&(ItemId, String)> = content.aspect_groups.keys().collect(); aspect_keys.sort(); - for (scope, slug) in aspect_keys { - if scopes.iter().any(|s| s.as_str() == scope.as_str()) { - candidates.push(( - scopes - .iter() - .find(|s| s.as_str() == scope.as_str()) - .expect("scope present"), - Some(( - content - .aspect_groups - .get(&(scope.clone(), slug.clone())) - .expect("aspect group present"), - slug.clone(), - )), - )); - } - } - for (scope, group_opt) in candidates { + for scope in &scopes { let members = comparable_items(content, content.members_of(scope)); if members.len() < 2 { continue; } - let (group_idx, group_pairs) = match &group_opt { - None => ( - &content.ranking_group.item_to_idx, - &content.ranking_group.voted_pairs, - ), - Some((group, _)) => (&group.item_to_idx, &group.voted_pairs), - }; - let (voted, possible) = voted_density(group_idx, group_pairs, &members); - if voted >= possible { - continue; + let (voted, possible) = voted_density( + &content.ranking_group.item_to_idx, + &content.ranking_group.voted_pairs, + &members, + ); + if voted < possible { + out.push(LandingQuestion { + scope: (*scope).clone(), + aspect: None, + voted, + possible, + members: members.len(), + last_vote_ts: last_canonical_vote_ts(content, &members), + }); + } + for (ascope, slug) in aspect_keys + .iter() + .filter(|(s, _)| s.as_str() == scope.as_str()) + { + let Some(group) = content.aspect_groups.get(&(ascope.clone(), slug.clone())) else { + continue; + }; + let (voted, possible) = + voted_density(&group.item_to_idx, &group.voted_pairs, &members); + if voted < possible { + out.push(LandingQuestion { + scope: (*scope).clone(), + aspect: Some(slug.clone()), + voted, + possible, + members: members.len(), + last_vote_ts: group.recent_votes.front().map(|v| v.ts).unwrap_or(0), + }); + } } - // Highest voted-pair count first; ties prefer more members (then the - // lex-first candidate, since iteration is sorted and only - // strictly-better replaces the incumbent). + } + out +} + +/// Newest canonical vote touching two members (0 when none). The global +/// recent-votes deque is newest-first, so the first in-electorate hit wins. +fn last_canonical_vote_ts(content: &ContentState, members: &[ItemId]) -> i64 { + let set: std::collections::HashSet<&ItemId> = members.iter().collect(); + content + .ranking_group + .recent_votes + .iter() + .find(|v| set.contains(&v.a) && set.contains(&v.b)) + .map(|v| v.ts) + .unwrap_or(0) +} + +/// One row of the heat index: an open question plus its thread heartbeat. +pub(super) struct OpenRow { + pub question: LandingQuestion, + pub thread_ts: i64, +} + +const OPEN_INDEX_CAP: usize = 10; + +/// Open questions hottest first: newest vote wins, then newest thread post. +/// Votes always outrank mere discussion (a voted question has +/// `last_vote_ts > 0`; a talked-about one has 0), so judging heat beats +/// talking heat by construction. +pub(super) fn rank_open_questions( + content: &ContentState, + threads: &HashMap<(ScopeId, String), ForumThreadState>, + scope: &ScopeId, +) -> Vec { + let mut rows: Vec = open_questions(content) + .into_iter() + .map(|question| { + let leaf = canonicalize_tag(question.scope.last_segment()); + let thread_ts = threads + .get(&(scope.clone(), leaf)) + .map(|t| t.last_activity_ts) + .unwrap_or(0); + OpenRow { + question, + thread_ts, + } + }) + .collect(); + rows.sort_by(|a, b| { + (b.question.last_vote_ts, b.thread_ts).cmp(&(a.question.last_vote_ts, a.thread_ts)) + }); + rows +} + +pub(super) fn pick_landing_question(content: &ContentState) -> Option { + // Most voted pairs wins; ties prefer more members, then canonical over an + // aspect, then lex order (`open_questions` is lex-sorted with canonical + // first per scope, and only strictly-better replaces the incumbent). + let mut best: Option = None; + for q in open_questions(content) { let take = match &best { None => true, Some(incumbent) => { - voted.cmp(&incumbent.voted) == std::cmp::Ordering::Greater - || (voted == incumbent.voted && members.len() > incumbent.members) + q.voted.cmp(&incumbent.voted) == std::cmp::Ordering::Greater + || (q.voted == incumbent.voted && q.members > incumbent.members) } }; if take { - best = Some(LandingQuestion { - scope: (*scope).clone(), - aspect: group_opt.map(|(_, slug)| slug), - voted, - possible, - members: members.len(), - }); + best = Some(q); } } best @@ -808,6 +861,7 @@ pub(super) async fn vote_compare_inner( aspect_slug, q.thread.clone(), None, + None, ) .await } @@ -826,6 +880,7 @@ async fn render_compare_page( aspect_slug: Option, query_thread: Option, intro: Option, + below: Option, ) -> axum::response::Response { let reduced = state.reduced.read().await; let content = content_for_garden_view(&reduced, &nav.scope()); @@ -894,6 +949,9 @@ async fn render_compare_page( (intro) } (panel) + @if let Some(below) = below { + (below) + } }; let url_key = canonical_view_url(&uri); @@ -920,14 +978,25 @@ async fn vote_landing( jar: CookieJar, uri: Uri, ) -> axum::response::Response { - let needy = { + let scope_id = nav.scope(); + let (pick, index) = { let reduced = state.reduced.read().await; let content = content_for_garden_view(&reduced, &nav.scope()); - pick_landing_question(content).map(|n| (n.scope, n.aspect, n.voted, n.possible)) + let pick = pick_landing_question(content); + let mut index = rank_open_questions(content, &reduced.forum_threads, &scope_id); + if let Some(dealt) = &pick { + index.retain(|row| { + !(row.question.scope == dealt.scope && row.question.aspect == dealt.aspect) + }); + } + index.truncate(OPEN_INDEX_CAP); + (pick, index) }; - let Some((scope, aspect, voted, possible)) = needy else { + let Some(dealt) = pick else { return landing_empty_page(&state, &jar, &uri).await; }; + let (scope, aspect, voted, possible) = + (dealt.scope, dealt.aspect, dealt.voted, dealt.possible); let pair = { let reduced = state.reduced.read().await; let content = content_for_garden_view(&reduced, &nav.scope()); @@ -980,6 +1049,11 @@ async fn vote_landing( } } }; + let below = if index.is_empty() { + None + } else { + Some(open_index_markup(&nav, &index, now_ms())) + }; render_compare_page( state, nav, @@ -992,10 +1066,64 @@ async fn vote_landing( aspect, None, Some(intro), + below, ) .await } +/// Heat index below the dealt pair: more open questions, hottest first. Each +/// row deals straight into its pool — a menu, not a ranking, so it carries +/// judged counts and activity instead of scores. +fn open_index_markup(nav: &ThreadNav, rows: &[OpenRow], now: i64) -> maud::Markup { + html! { + section class="vote-open-index ont-tab-panel" { + h3 { "more open questions" } + ul class="vote-open-list" { + @for row in rows { + @let q = &row.question; + @let judge_href = match &q.aspect { + None => vote_pool_href(nav, q.scope.as_str()), + Some(slug) => format!( + "{}&aspect={}", + vote_pool_href(nav, q.scope.as_str()), + urlencoding::encode(slug) + ), + }; + @let garden_href = match &q.aspect { + None => item_href(q.scope.as_str(), nav), + Some(slug) => { + format!("{}#aspect-{slug}", item_href(q.scope.as_str(), nav)) + } + }; + @let leaf = canonicalize_tag(q.scope.last_segment()); + @let active_ts = row.question.last_vote_ts.max(row.thread_ts); + li class="vote-open-row" { + a class="vote-open-judge" href=(judge_href) { "judge" } + span class="vote-open-name" { + @if let Some(slug) = &q.aspect { + span class="vote-open-aspect" { ":" (slug) " in " } + } + a href=(garden_href) { (item_display_path(q.scope.as_str())) } + } + span class="muted vote-open-meta" { + (format!("{} of {} judged", q.voted, q.possible)) + @if active_ts > 0 { + @let hover = timeago::rfc3339_utc(active_ts); + @let ago = timeago::timeago(now, active_ts); + " · " + span title=(hover) { (ago) } + } + } + span class="vote-open-links" { + a href=(nav.thread_url(&leaf)) { "thread" } + } + } + } + } + } + } +} + /// Nothing left to judge: every scope with two comparable members is fully compared. async fn landing_empty_page( state: &AppState, diff --git a/server/static/theme_default.css b/server/static/theme_default.css index f2e54588842802e848427a0597a5f372de3d2e52..a52e3dcd0621b87a22b49ccfe5201b31e454b5b6 100644 --- a/server/static/theme_default.css +++ b/server/static/theme_default.css @@ -2342,3 +2342,60 @@ ol.vote-landing-steps { ol.vote-landing-steps li { margin: 4px 0; } + +/* Heat index below the dealt pair: a menu, not a ranking — judged counts + and activity instead of scores, so it never reads as rank order. */ +.vote-open-index { + margin: 8px auto 32px; + max-width: 560px; + padding: 0 16px; +} +.vote-open-index > h3 { + border-top: 2px solid var(--lo); + color: var(--ui); + font-size: 11px; + margin-top: 8px; + padding-top: 10px; +} +ul.vote-open-list { + display: flex; + flex-direction: column; + gap: 6px; + list-style: none; + margin: 0; + padding: 0; + width: 100%; + max-width: 100%; +} +ul.vote-open-list li.vote-open-row { + align-items: baseline; + background: var(--g3); + border: 1px solid var(--lo); + border-radius: 2px; + display: flex; + flex-wrap: wrap; + gap: 2px 8px; + padding: 6px 10px; + width: auto; + max-width: none; +} +a.vote-open-judge { + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} +.vote-open-name { + font-size: 13px; + font-weight: 600; +} +.vote-open-name a { + color: var(--signal); +} +.vote-open-meta { + font-size: 11px; +} +.vote-open-links { + font-size: 11px; + margin-left: auto; + white-space: nowrap; +} diff --git a/server/static/theme_retro.css b/server/static/theme_retro.css index 5748cdb5c588bb389ca9ff413377ce9b6b7cb9ab..7e2be497fcd35105c40ab334a754859671eca168 100644 --- a/server/static/theme_retro.css +++ b/server/static/theme_retro.css @@ -472,6 +472,59 @@ body.view-ontology ol.vote-landing-steps { body.view-ontology ol.vote-landing-steps li { margin: 0.3rem 0; } +body.view-ontology .vote-open-index { + margin: 0.5rem auto 2rem; + max-width: 560px; +} +body.view-ontology .vote-open-index > h3 { + border-top: 2px solid #ccc; + color: #333; + font-size: 0.75rem; + letter-spacing: 0.14em; + margin-top: 0.5rem; + padding-top: 0.6rem; + text-transform: uppercase; +} +body.view-ontology ul.vote-open-list { + display: flex; + flex-direction: column; + gap: 0.4rem; + list-style: none; + margin: 0; + padding: 0; + width: 100%; + max-width: 100%; +} +body.view-ontology ul.vote-open-list li.vote-open-row { + align-items: baseline; + background: #faf8f3; + border: 1px solid #ccc; + display: flex; + flex-wrap: wrap; + gap: 0.15rem 0.5rem; + padding: 0.4rem 0.65rem; + width: auto; + max-width: none; +} +body.view-ontology a.vote-open-judge { + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; +} +body.view-ontology .vote-open-name { + font-size: 0.85rem; + font-weight: 600; + color: #111; +} +body.view-ontology .vote-open-meta { + font-size: 0.72rem; + color: #666; +} +body.view-ontology .vote-open-links { + font-size: 0.72rem; + margin-left: auto; + white-space: nowrap; +} /* ================================================================ GARDEN HIERARCHY — parents ↑ / self / children ↓ (paper context). diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css index 867f8fec9c6240e231b539828a13e659a85b2438..f2f1faa018f924a00172782c88a1588bc8c22597 100644 --- a/server/static/theme_retro_craft.css +++ b/server/static/theme_retro_craft.css @@ -1316,6 +1316,67 @@ body.view-ontology ol.vote-landing-steps { body.view-ontology ol.vote-landing-steps li { margin: 0.3rem 0; } +body.view-ontology .vote-open-index { + margin: 0.5rem auto 2rem; + max-width: 38rem; + padding: 0 1.25rem; +} +body.view-ontology .vote-open-index > h3 { + border-top: 2px solid #c8c4bc; + color: #3d3a34; + font-family: var(--font-ui); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.14em; + margin-top: 0.5rem; + padding-top: 0.6rem; + text-transform: uppercase; +} +body.view-ontology ul.vote-open-list { + display: flex; + flex-direction: column; + gap: 0.4rem; + list-style: none; + margin: 0; + padding: 0; + width: 100%; + max-width: 100%; +} +body.view-ontology ul.vote-open-list li.vote-open-row { + align-items: baseline; + background: #f7f3eb; + border: 1px solid #c8c4bc; + border-radius: 2px; + display: flex; + flex-wrap: wrap; + gap: 0.15rem 0.5rem; + padding: 0.4rem 0.65rem; + width: auto; + max-width: none; +} +body.view-ontology ul.vote-open-list li::before { + content: none; +} +body.view-ontology a.vote-open-judge { + font-family: var(--font-ui); + font-size: 0.72rem; + font-weight: 600; + white-space: nowrap; +} +body.view-ontology .vote-open-name { + font-size: 0.85rem; + font-weight: 600; + color: #1a1814; +} +body.view-ontology .vote-open-meta { + font-size: 0.72rem; + color: #8a857a; +} +body.view-ontology .vote-open-links { + font-size: 0.72rem; + margin-left: auto; + white-space: nowrap; +} .home-intro { margin: 1rem 0; } details.question-row { diff --git a/server/tests/integration_html.rs b/server/tests/integration_html.rs index 2c7f52bbb36b69135268d600f404f4a34e1b42c2..fc3e2f46c2c755aefed2b1bebd6de7ed4075a60e 100644 --- a/server/tests/integration_html.rs +++ b/server/tests/integration_html.rs @@ -444,6 +444,96 @@ async fn test_vote_landing_serves_neediest_pair() { assert!(body.contains("value=\"duel\""), "thread_tag should seed to pool leaf"); } +#[tokio::test] +async fn test_vote_landing_lists_more_open_questions() { + let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await; + { + let mut w = state.reduced.write().await; + // NOTE: leaves are global (`~/duel/a` IS `~a`), so the straggler scope + // needs its own leaves or the busy scope's votes would judge its pair. + w.apply_event(Event::Ingest(Ingest { + ts: 20, + id: "ing-duel2".into(), + raw: "~/duel/p { Pee }\n~/duel/q { Kew }\n".to_string(), + principal: "testuser".into(), + delegate: None, + room_id: "public".into(), + thread_tag: "duel".into(), + })); + w.apply_event(Event::Ingest(Ingest { + ts: 25, + id: "ing-old".into(), + raw: "~/old/a { Alpha }\n~/old/b { Beta }\n~/old/c { Gamma }\n\ + { judged }\n~/old/a 2:1 ~/old/b\n" + .to_string(), + principal: "testuser".into(), + delegate: None, + room_id: "public".into(), + thread_tag: "old".into(), + })); + } + let client = reqwest::Client::new(); + let body = client + .get(format!("http://{addr}/vote")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + // Dealt pair is ~/old (1 judged pair beats duel's 0); the index lists + // only the straggler — never a second row for the dealt question. + assert!(body.contains("more open questions"), "heat index missing"); + assert_eq!( + body.matches("vote-open-row").count(), + 1, + "index should hold exactly the straggler row" + ); + assert!( + body.contains("~%2Fduel"), + "straggler judge link missing: {}", + body.chars().take(3000).collect::() + ); + assert!(body.contains("0 of 1 judged")); +} + +#[tokio::test] +async fn test_vote_landing_deals_hungrier_aspect_group() { + let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await; + { + let mut w = state.reduced.write().await; + w.apply_event(Event::Ingest(Ingest { + ts: 24, + id: "ing-tri-full".into(), + raw: "~/tri/a { alpha }\n~/tri/b { beta }\n~/tri/c { gamma }\n\ + { ab }\n~/tri/a 2:1 ~/tri/b\n\ + { ac }\n~/tri/a 2:1 ~/tri/c\n\ + { bc }\n~/tri/b 2:1 ~/tri/c\n\ + :beauty {more beautiful}\n{ pretty }\n~/tri/a 2:1 ~/tri/b\n" + .to_string(), + principal: "testuser".into(), + delegate: None, + room_id: "public".into(), + thread_tag: "tri".into(), + })); + } + let client = reqwest::Client::new(); + let resp = client + .get(format!("http://{addr}/vote")) + .header("Authorization", format!("Bearer {}", test_bearer())) + .send() + .await + .unwrap(); + assert!(resp.status().is_success(), "{}", resp.status()); + let body = resp.text().await.unwrap(); + assert!(body.contains("judge one pair"), "landing intro missing"); + assert!(body.contains(":beauty"), "aspect question missing"); + assert!(body.contains("1 of 3"), "aspect need count missing"); + assert!(body.contains("name=\"aspect\""), "aspect input missing"); + assert!(body.contains("value=\"beauty\""), "aspect value missing"); + assert!(body.contains("value=\"tri\""), "thread_tag should seed to pool leaf"); +} + #[tokio::test] async fn test_vote_aspect_query_param() { let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;