From de6256a8124a456c26fc2d6538dff4eb8bf3190d Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Tue, 15 Sep 2026 09:26:00 +0200 Subject: [PATCH 1/4] Extract pure logic out of RSX components into unit-testable handlers modules Splits article_row.rs and subscribe_form.rs into mod.rs + component.rs + handlers.rs (mirroring the components/*/ convention), and adds a sibling app/handlers.rs for App's heading computation. RSX now calls into plain functions (score_percent, normalize_feed_url, subscribed_message, feed_heading) that are covered by unit tests, instead of computing the same logic inline where it can't be tested without a Dioxus runtime. Co-Authored-By: Claude Sonnet 5 --- .../component.rs} | 3 +- crates/web/src/app/article_row/handlers.rs | 29 +++++++++++ crates/web/src/app/article_row/mod.rs | 4 ++ crates/web/src/app/handlers.rs | 48 +++++++++++++++++++ crates/web/src/app/mod.rs | 15 ++---- .../component.rs} | 10 ++-- crates/web/src/app/subscribe_form/handlers.rs | 40 ++++++++++++++++ crates/web/src/app/subscribe_form/mod.rs | 4 ++ 8 files changed, 136 insertions(+), 17 deletions(-) rename crates/web/src/app/{article_row.rs => article_row/component.rs} (93%) create mode 100644 crates/web/src/app/article_row/handlers.rs create mode 100644 crates/web/src/app/article_row/mod.rs create mode 100644 crates/web/src/app/handlers.rs rename crates/web/src/app/{subscribe_form.rs => subscribe_form/component.rs} (90%) create mode 100644 crates/web/src/app/subscribe_form/handlers.rs create mode 100644 crates/web/src/app/subscribe_form/mod.rs diff --git a/crates/web/src/app/article_row.rs b/crates/web/src/app/article_row/component.rs similarity index 93% rename from crates/web/src/app/article_row.rs rename to crates/web/src/app/article_row/component.rs index 123d735..df9fa34 100644 --- a/crates/web/src/app/article_row.rs +++ b/crates/web/src/app/article_row/component.rs @@ -1,3 +1,4 @@ +use super::handlers::score_percent; use crate::api; use crate::api::ArticleView; use crate::components::badge::{Badge, BadgeVariant}; @@ -6,7 +7,7 @@ use dioxus::prelude::*; #[component] pub fn ArticleRow(article: ArticleView) -> Element { - let score_pct = article.final_score.map(|s| (s * 100.0).round() as i32); + let score_pct = score_percent(article.final_score); rsx! { li { class: "article-row", div { class: "article-row-header", diff --git a/crates/web/src/app/article_row/handlers.rs b/crates/web/src/app/article_row/handlers.rs new file mode 100644 index 0000000..0a3a7f9 --- /dev/null +++ b/crates/web/src/app/article_row/handlers.rs @@ -0,0 +1,29 @@ +/// Converts a fractional relevance score into a whole-number percentage for +/// the row's badge, rounding rather than truncating so e.g. 0.995 shows 100% +/// instead of 99%. +pub fn score_percent(final_score: Option) -> Option { + final_score.map(|s| (s * 100.0).round() as i32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_score_yields_no_percent() { + assert_eq!(score_percent(None), None); + } + + #[test] + fn score_is_rounded_not_truncated() { + assert_eq!(score_percent(Some(0.995)), Some(100)); + assert_eq!(score_percent(Some(0.554)), Some(55)); + } + + #[test] + fn score_is_scaled_to_a_percentage() { + assert_eq!(score_percent(Some(0.5)), Some(50)); + assert_eq!(score_percent(Some(0.0)), Some(0)); + assert_eq!(score_percent(Some(1.0)), Some(100)); + } +} diff --git a/crates/web/src/app/article_row/mod.rs b/crates/web/src/app/article_row/mod.rs new file mode 100644 index 0000000..d71520f --- /dev/null +++ b/crates/web/src/app/article_row/mod.rs @@ -0,0 +1,4 @@ +mod component; +mod handlers; + +pub use component::*; diff --git a/crates/web/src/app/handlers.rs b/crates/web/src/app/handlers.rs new file mode 100644 index 0000000..202a460 --- /dev/null +++ b/crates/web/src/app/handlers.rs @@ -0,0 +1,48 @@ +use crate::api::FeedView; + +/// Heading text for the article list: the selected feed's title, "Feed" as a +/// placeholder while that feed's title hasn't loaded yet, or "All articles" +/// when no feed is selected. +pub fn feed_heading(selected_feed_id: Option<&str>, feeds: Option<&[FeedView]>) -> String { + match selected_feed_id { + None => "All articles".to_string(), + Some(id) => feeds + .and_then(|feeds| feeds.iter().find(|f| f.id == id)) + .map(|f| f.title.clone()) + .unwrap_or_else(|| "Feed".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn feed(id: &str, title: &str) -> FeedView { + FeedView { + id: id.to_string(), + title: title.to_string(), + url: format!("https://example.com/{id}.xml"), + } + } + + #[test] + fn no_selection_shows_all_articles() { + assert_eq!(feed_heading(None, None), "All articles"); + } + + #[test] + fn selected_feed_shows_its_title() { + let feeds = vec![feed("1", "Rust Blog"), feed("2", "Hacker News")]; + assert_eq!(feed_heading(Some("2"), Some(&feeds)), "Hacker News"); + } + + /// The feed list may still be loading (or the id may be stale) when a + /// selection is made, so fall back to a placeholder rather than panic + /// or show a blank heading. + #[test] + fn unresolved_selection_falls_back_to_placeholder() { + assert_eq!(feed_heading(Some("missing"), None), "Feed"); + let feeds = vec![feed("1", "Rust Blog")]; + assert_eq!(feed_heading(Some("missing"), Some(&feeds)), "Feed"); + } +} diff --git a/crates/web/src/app/mod.rs b/crates/web/src/app/mod.rs index 2467f81..c65b138 100644 --- a/crates/web/src/app/mod.rs +++ b/crates/web/src/app/mod.rs @@ -1,4 +1,5 @@ mod article_row; +mod handlers; mod subscribe_form; use crate::api; @@ -89,16 +90,10 @@ pub fn App() -> Element { SidebarTrigger {} h2 { { - let heading = match selected_feed.read().as_ref() { - None => "All articles".to_string(), - Some(id) => feeds - .read() - .as_ref() - .and_then(|r| r.as_ref().ok()) - .and_then(|feeds| feeds.iter().find(|f| &f.id == id)) - .map(|f| f.title.clone()) - .unwrap_or_else(|| "Feed".to_string()), - }; + let heading = handlers::feed_heading( + selected_feed.read().as_deref(), + feeds.read().as_ref().and_then(|r| r.as_ref().ok()).map(|v| v.as_slice()), + ); rsx! { "{heading}" } } } diff --git a/crates/web/src/app/subscribe_form.rs b/crates/web/src/app/subscribe_form/component.rs similarity index 90% rename from crates/web/src/app/subscribe_form.rs rename to crates/web/src/app/subscribe_form/component.rs index ff98379..9003e0a 100644 --- a/crates/web/src/app/subscribe_form.rs +++ b/crates/web/src/app/subscribe_form/component.rs @@ -1,3 +1,4 @@ +use super::handlers::{normalize_feed_url, subscribed_message}; use crate::api; use crate::components::button::{Button, ButtonVariant}; use crate::components::input::Input; @@ -13,18 +14,15 @@ pub fn SubscribeForm(on_subscribed: EventHandler<()>) -> Element { let mut submitting = use_signal(|| false); let submit = move |_| { - let feed_url = url.read().clone(); - if feed_url.trim().is_empty() { + let Some(feed_url) = normalize_feed_url(&url.read()) else { return; - } + }; spawn(async move { submitting.set(true); status.set(None); match api::feeds::subscribe_feed(feed_url).await { Ok(count) => { - status.set(Some(Ok(format!( - "Subscribed — pulled in {count} article(s)." - )))); + status.set(Some(Ok(subscribed_message(count)))); url.set(String::new()); on_subscribed.call(()); } diff --git a/crates/web/src/app/subscribe_form/handlers.rs b/crates/web/src/app/subscribe_form/handlers.rs new file mode 100644 index 0000000..60dbceb --- /dev/null +++ b/crates/web/src/app/subscribe_form/handlers.rs @@ -0,0 +1,40 @@ +/// Trims a feed URL entered by the user, returning `None` when it's blank +/// so the submit handler can bail out before making a request. +pub fn normalize_feed_url(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +/// Success message shown after a feed subscription pulls in new articles. +pub fn subscribed_message(article_count: usize) -> String { + format!("Subscribed — pulled in {article_count} article(s).") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blank_input_normalizes_to_none() { + assert_eq!(normalize_feed_url(""), None); + assert_eq!(normalize_feed_url(" "), None); + } + + #[test] + fn surrounding_whitespace_is_trimmed() { + assert_eq!( + normalize_feed_url(" https://example.com/feed.xml "), + Some("https://example.com/feed.xml".to_string()) + ); + } + + #[test] + fn message_reports_the_article_count() { + assert_eq!(subscribed_message(0), "Subscribed — pulled in 0 article(s)."); + assert_eq!(subscribed_message(3), "Subscribed — pulled in 3 article(s)."); + } +} diff --git a/crates/web/src/app/subscribe_form/mod.rs b/crates/web/src/app/subscribe_form/mod.rs new file mode 100644 index 0000000..d71520f --- /dev/null +++ b/crates/web/src/app/subscribe_form/mod.rs @@ -0,0 +1,4 @@ +mod component; +mod handlers; + +pub use component::*; -- 2.45.2 From 2ae3293c9d4d095c69916956da7656aaf1c601d6 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Tue, 15 Sep 2026 09:42:08 +0200 Subject: [PATCH 2/4] Fix cargo fmt formatting in subscribe_form handlers tests Co-Authored-By: Claude Sonnet 5 --- crates/web/src/app/subscribe_form/handlers.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/web/src/app/subscribe_form/handlers.rs b/crates/web/src/app/subscribe_form/handlers.rs index 60dbceb..7ed1901 100644 --- a/crates/web/src/app/subscribe_form/handlers.rs +++ b/crates/web/src/app/subscribe_form/handlers.rs @@ -34,7 +34,13 @@ mod tests { #[test] fn message_reports_the_article_count() { - assert_eq!(subscribed_message(0), "Subscribed — pulled in 0 article(s)."); - assert_eq!(subscribed_message(3), "Subscribed — pulled in 3 article(s)."); + assert_eq!( + subscribed_message(0), + "Subscribed — pulled in 0 article(s)." + ); + assert_eq!( + subscribed_message(3), + "Subscribed — pulled in 3 article(s)." + ); } } -- 2.45.2 From bd086b0288db321fe1d5932a84a65c59eff8523b Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Tue, 15 Sep 2026 09:50:06 +0200 Subject: [PATCH 3/4] Extract subscribe_form's submit-result mapping into a testable outcome The submit closure mixed pure decision logic (what should the form show after subscribe_feed resolves) with Dioxus signal orchestration. Splits out resolve_submit_outcome(Result) -> SubmitOutcome, so the success/failure mapping is unit-tested without a Dioxus runtime; the component now just applies the resulting outcome to its signals. Co-Authored-By: Claude Sonnet 5 --- .../web/src/app/subscribe_form/component.rs | 11 ++--- crates/web/src/app/subscribe_form/handlers.rs | 43 ++++++++++++++++--- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/crates/web/src/app/subscribe_form/component.rs b/crates/web/src/app/subscribe_form/component.rs index 9003e0a..c589038 100644 --- a/crates/web/src/app/subscribe_form/component.rs +++ b/crates/web/src/app/subscribe_form/component.rs @@ -1,4 +1,4 @@ -use super::handlers::{normalize_feed_url, subscribed_message}; +use super::handlers::{normalize_feed_url, resolve_submit_outcome, SubmitOutcome}; use crate::api; use crate::components::button::{Button, ButtonVariant}; use crate::components::input::Input; @@ -20,13 +20,14 @@ pub fn SubscribeForm(on_subscribed: EventHandler<()>) -> Element { spawn(async move { submitting.set(true); status.set(None); - match api::feeds::subscribe_feed(feed_url).await { - Ok(count) => { - status.set(Some(Ok(subscribed_message(count)))); + let result = api::feeds::subscribe_feed(feed_url).await; + match resolve_submit_outcome(result.map_err(|err| err.to_string())) { + SubmitOutcome::Success { message } => { + status.set(Some(Ok(message))); url.set(String::new()); on_subscribed.call(()); } - Err(err) => status.set(Some(Err(err.to_string()))), + SubmitOutcome::Failure { message } => status.set(Some(Err(message))), } submitting.set(false); }); diff --git a/crates/web/src/app/subscribe_form/handlers.rs b/crates/web/src/app/subscribe_form/handlers.rs index 7ed1901..08b9c2a 100644 --- a/crates/web/src/app/subscribe_form/handlers.rs +++ b/crates/web/src/app/subscribe_form/handlers.rs @@ -10,10 +10,31 @@ pub fn normalize_feed_url(input: &str) -> Option { } /// Success message shown after a feed subscription pulls in new articles. -pub fn subscribed_message(article_count: usize) -> String { +fn subscribed_message(article_count: usize) -> String { format!("Subscribed — pulled in {article_count} article(s).") } +/// What the form should show, and whether the URL input should be cleared, +/// after a subscription attempt resolves. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubmitOutcome { + /// The subscription succeeded: show `message` and clear the input. + Success { message: String }, + /// The subscription failed: show `message` and leave the input as-is, + /// so the reader can fix it up and retry. + Failure { message: String }, +} + +/// Maps a `subscribe_feed` result to what the form should do next. +pub fn resolve_submit_outcome(result: Result) -> SubmitOutcome { + match result { + Ok(count) => SubmitOutcome::Success { + message: subscribed_message(count), + }, + Err(message) => SubmitOutcome::Failure { message }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -33,14 +54,24 @@ mod tests { } #[test] - fn message_reports_the_article_count() { + fn success_reports_the_article_count_and_clears_the_input() { assert_eq!( - subscribed_message(0), - "Subscribed — pulled in 0 article(s)." + resolve_submit_outcome(Ok(3)), + SubmitOutcome::Success { + message: "Subscribed — pulled in 3 article(s).".to_string() + } ); + } + + /// A failed attempt should surface the error but leave the input alone + /// so the reader doesn't have to retype the URL to fix it. + #[test] + fn failure_surfaces_the_error_message() { assert_eq!( - subscribed_message(3), - "Subscribed — pulled in 3 article(s)." + resolve_submit_outcome(Err("feed unreachable".to_string())), + SubmitOutcome::Failure { + message: "feed unreachable".to_string() + } ); } } -- 2.45.2 From 49ce3eaad85f57e29dcdc25cb8cdcdd4427bc8f0 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Tue, 15 Sep 2026 10:11:00 +0200 Subject: [PATCH 4/4] Bump rustls to 0.23.45 to clear RUSTSEC-2026-0285 cargo audit was failing on main independent of this branch's changes: a new advisory (published 2026-09-14) flags rustls 0.23.43, pulled in transitively via dioxus-server/reqwest/rig-core, for accepting TLS 1.3 handshake messages across encryption level boundaries. The fix is upstream in 0.23.45; no code changes needed. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5691c2..ae17750 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3400,9 +3400,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", -- 2.45.2