Extract pure logic out of RSX components into unit-testable handlers modules #9
8 changed files with 136 additions and 17 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
use super::handlers::score_percent;
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::api::ArticleView;
|
use crate::api::ArticleView;
|
||||||
use crate::components::badge::{Badge, BadgeVariant};
|
use crate::components::badge::{Badge, BadgeVariant};
|
||||||
|
|
@ -6,7 +7,7 @@ use dioxus::prelude::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ArticleRow(article: ArticleView) -> Element {
|
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! {
|
rsx! {
|
||||||
li { class: "article-row",
|
li { class: "article-row",
|
||||||
div { class: "article-row-header",
|
div { class: "article-row-header",
|
||||||
29
crates/web/src/app/article_row/handlers.rs
Normal file
29
crates/web/src/app/article_row/handlers.rs
Normal file
|
|
@ -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<f32>) -> Option<i32> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/web/src/app/article_row/mod.rs
Normal file
4
crates/web/src/app/article_row/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
mod component;
|
||||||
|
mod handlers;
|
||||||
|
|
||||||
|
pub use component::*;
|
||||||
48
crates/web/src/app/handlers.rs
Normal file
48
crates/web/src/app/handlers.rs
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod article_row;
|
mod article_row;
|
||||||
|
mod handlers;
|
||||||
mod subscribe_form;
|
mod subscribe_form;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
|
|
@ -89,16 +90,10 @@ pub fn App() -> Element {
|
||||||
SidebarTrigger {}
|
SidebarTrigger {}
|
||||||
h2 {
|
h2 {
|
||||||
{
|
{
|
||||||
let heading = match selected_feed.read().as_ref() {
|
let heading = handlers::feed_heading(
|
||||||
None => "All articles".to_string(),
|
selected_feed.read().as_deref(),
|
||||||
Some(id) => feeds
|
feeds.read().as_ref().and_then(|r| r.as_ref().ok()).map(|v| v.as_slice()),
|
||||||
.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()),
|
|
||||||
};
|
|
||||||
rsx! { "{heading}" }
|
rsx! { "{heading}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use super::handlers::{normalize_feed_url, subscribed_message};
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::components::button::{Button, ButtonVariant};
|
use crate::components::button::{Button, ButtonVariant};
|
||||||
use crate::components::input::Input;
|
use crate::components::input::Input;
|
||||||
|
|
@ -13,18 +14,15 @@ pub fn SubscribeForm(on_subscribed: EventHandler<()>) -> Element {
|
||||||
let mut submitting = use_signal(|| false);
|
let mut submitting = use_signal(|| false);
|
||||||
|
|
||||||
let submit = move |_| {
|
let submit = move |_| {
|
||||||
let feed_url = url.read().clone();
|
let Some(feed_url) = normalize_feed_url(&url.read()) else {
|
||||||
if feed_url.trim().is_empty() {
|
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
submitting.set(true);
|
submitting.set(true);
|
||||||
status.set(None);
|
status.set(None);
|
||||||
match api::feeds::subscribe_feed(feed_url).await {
|
match api::feeds::subscribe_feed(feed_url).await {
|
||||||
Ok(count) => {
|
Ok(count) => {
|
||||||
status.set(Some(Ok(format!(
|
status.set(Some(Ok(subscribed_message(count))));
|
||||||
"Subscribed — pulled in {count} article(s)."
|
|
||||||
))));
|
|
||||||
url.set(String::new());
|
url.set(String::new());
|
||||||
on_subscribed.call(());
|
on_subscribed.call(());
|
||||||
}
|
}
|
||||||
40
crates/web/src/app/subscribe_form/handlers.rs
Normal file
40
crates/web/src/app/subscribe_form/handlers.rs
Normal file
|
|
@ -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<String> {
|
||||||
|
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).");
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/web/src/app/subscribe_form/mod.rs
Normal file
4
crates/web/src/app/subscribe_form/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
mod component;
|
||||||
|
mod handlers;
|
||||||
|
|
||||||
|
pub use component::*;
|
||||||
Loading…
Reference in a new issue