test: add unit tests for all reviewed presentation modules

Cover parse_created_at in CLI, StatsView::is_empty, format_datetime,
Paginated helpers, encode_uri_component, page_size_from_text,
build_map_data, build_coffee_info, used_percent edge cases,
brew_again_url construction, and parse_and_categorize splitting.
This commit is contained in:
Jon Seager 2026-02-13 15:18:43 +00:00
parent 472e851b80
commit ab4bcbaa0a
No known key found for this signature in database
5 changed files with 474 additions and 0 deletions

View file

@ -158,3 +158,45 @@ where
println!("{rendered}"); println!("{rendered}");
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_created_at_rfc3339() {
let result = parse_created_at("2025-08-05T10:30:00Z").unwrap();
assert_eq!(
result,
DateTime::parse_from_rfc3339("2025-08-05T10:30:00Z").unwrap()
);
}
#[test]
fn parse_created_at_yyyy_mm_dd() {
let result = parse_created_at("2025-08-05").unwrap();
let expected = NaiveDate::from_ymd_opt(2025, 8, 5)
.unwrap()
.and_time(chrono::NaiveTime::MIN)
.and_utc();
assert_eq!(result, expected);
}
#[test]
fn parse_created_at_invalid() {
let result = parse_created_at("not-a-date");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("invalid date format"));
}
#[test]
fn parse_created_at_leap_day() {
let result = parse_created_at("2024-02-29").unwrap();
let expected = NaiveDate::from_ymd_opt(2024, 2, 29)
.unwrap()
.and_time(chrono::NaiveTime::MIN)
.and_utc();
assert_eq!(result, expected);
}
}

View file

@ -162,3 +162,28 @@ impl From<BagWithRoast> for BagOptionView {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn used_percent_normal() {
assert_eq!(used_percent(100.0, 25.0), 75);
}
#[test]
fn used_percent_zero_amount() {
assert_eq!(used_percent(0.0, 0.0), 0);
}
#[test]
fn used_percent_fully_used() {
assert_eq!(used_percent(100.0, 0.0), 100);
}
#[test]
fn used_percent_over_amount() {
assert_eq!(used_percent(100.0, 150.0), 0);
}
}

View file

@ -311,3 +311,77 @@ impl From<BrewWithDetails> for BrewDefaultsView {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn dummy_brew_view(
filter_paper_id: Option<i64>,
brew_time_raw: Option<i32>,
quick_notes_raw: &str,
) -> BrewView {
BrewView {
id: "1".to_string(),
bag_id: 10,
roast_name: "Test Roast".to_string(),
roaster_name: "Test Roaster".to_string(),
roast_slug: "test-roast".to_string(),
roaster_slug: "test-roaster".to_string(),
coffee_weight: "15.0g".to_string(),
grinder_id: 2,
grinder_name: "Grinder".to_string(),
grinder_model: "Model".to_string(),
grind_setting: "6.0".to_string(),
brewer_id: 3,
brewer_name: "Brewer".to_string(),
filter_paper_id,
filter_paper_name: filter_paper_id.map(|_| "Filter".to_string()),
water_volume: "250ml".to_string(),
water_temp: "91.0\u{00B0}C".to_string(),
ratio: "1:16.7".to_string(),
brew_time: brew_time_raw.map(|_| "2:00".to_string()),
quick_notes: vec![],
quick_notes_label: String::new(),
quick_notes_raw: quick_notes_raw.to_string(),
created_date: "2025-01-01".to_string(),
created_time: "12:00".to_string(),
relative_date_label: "today".to_string(),
coffee_weight_raw: 15.0,
grind_setting_raw: 6.0,
water_volume_raw: 250,
water_temp_raw: 91.0,
brew_time_raw,
}
}
#[test]
fn brew_again_url_all_params() {
let view = dummy_brew_view(Some(5), Some(120), "good,too-fast");
let url = view.brew_again_url();
assert!(url.starts_with("/add?type=brew&"));
assert!(url.contains("bag_id=10"));
assert!(url.contains("coffee_weight=15"));
assert!(url.contains("grinder_id=2"));
assert!(url.contains("grind_setting=6"));
assert!(url.contains("brewer_id=3"));
assert!(url.contains("water_volume=250"));
assert!(url.contains("water_temp=91"));
assert!(url.contains("filter_paper_id=5"));
assert!(url.contains("brew_time=120"));
assert!(url.contains("quick_notes=good,too-fast"));
}
#[test]
fn brew_again_url_optional_omitted() {
let view = dummy_brew_view(None, None, "");
let url = view.brew_again_url();
assert!(url.starts_with("/add?type=brew&"));
assert!(url.contains("bag_id=10"));
assert!(!url.contains("filter_paper_id"));
assert!(!url.contains("brew_time"));
assert!(!url.contains("quick_notes"));
}
}

View file

@ -510,3 +510,300 @@ pub(crate) fn build_map_data(entries: &[(&str, u32)]) -> (String, u32) {
(parts.join(","), max) (parts.join(","), max)
} }
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::listing::{DEFAULT_PAGE_SIZE, PageSize};
use crate::domain::roasts::Roast;
// ── StatsView::is_empty ─────────────────────────────────────────
#[test]
fn stats_view_default_is_empty() {
assert!(StatsView::default().is_empty());
}
#[test]
fn stats_view_any_nonzero_field_is_not_empty() {
let with_brews = StatsView {
brews: 1,
..Default::default()
};
assert!(!with_brews.is_empty());
let with_roasts = StatsView {
roasts: 1,
..Default::default()
};
assert!(!with_roasts.is_empty());
let with_roasters = StatsView {
roasters: 1,
..Default::default()
};
assert!(!with_roasters.is_empty());
let with_cups = StatsView {
cups: 1,
..Default::default()
};
assert!(!with_cups.is_empty());
let with_cafes = StatsView {
cafes: 1,
..Default::default()
};
assert!(!with_cafes.is_empty());
let with_bags = StatsView {
bags: 1,
..Default::default()
};
assert!(!with_bags.is_empty());
}
// ── format_datetime ─────────────────────────────────────────────
#[test]
fn format_datetime_known_value() {
let dt = Utc.with_ymd_and_hms(2024, 3, 15, 14, 30, 0).unwrap();
let (date, time) = format_datetime(dt);
assert_eq!(date, "2024-03-15");
assert_eq!(time, "14:30");
}
// ── Paginated ───────────────────────────────────────────────────
#[test]
fn total_pages_rounds_up() {
let p: Paginated<()> = Paginated::new(vec![], 1, 10, 25, false);
assert_eq!(p.total_pages(), 3);
}
#[test]
fn total_pages_zero_total_is_1() {
let p: Paginated<()> = Paginated::new(vec![], 1, 10, 0, false);
assert_eq!(p.total_pages(), 1);
}
#[test]
fn total_pages_showing_all_is_1() {
let p: Paginated<()> = Paginated::new(vec![], 1, 10, 100, true);
assert_eq!(p.total_pages(), 1);
}
#[test]
fn has_previous_false_on_page_1() {
let p: Paginated<()> = Paginated::new(vec![], 1, 10, 30, false);
assert!(!p.has_previous());
}
#[test]
fn has_previous_true_on_page_2() {
let p: Paginated<()> = Paginated::new(vec![], 2, 10, 30, false);
assert!(p.has_previous());
}
#[test]
fn has_next_true_when_not_last_page() {
let p: Paginated<()> = Paginated::new(vec![], 1, 10, 30, false);
assert!(p.has_next());
}
#[test]
fn has_next_false_on_last_page() {
let p: Paginated<()> = Paginated::new(vec![], 3, 10, 30, false);
assert!(!p.has_next());
}
#[test]
fn start_end_index_page_1() {
let items: Vec<i32> = (1..=10).collect();
let p = Paginated::new(items, 1, 10, 25, false);
assert_eq!(p.start_index(), 1);
assert_eq!(p.end_index(), 10);
}
#[test]
fn start_end_index_page_2() {
let items: Vec<i32> = (1..=10).collect();
let p = Paginated::new(items, 2, 10, 25, false);
assert_eq!(p.start_index(), 11);
assert_eq!(p.end_index(), 20);
}
#[test]
fn start_end_index_empty() {
let p: Paginated<()> = Paginated::new(vec![], 1, 10, 0, false);
assert_eq!(p.start_index(), 0);
assert_eq!(p.end_index(), 0);
}
#[test]
fn page_size_query_value_showing_all() {
let p: Paginated<()> = Paginated::new(vec![], 1, 10, 50, true);
assert_eq!(p.page_size_query_value(), "all");
}
#[test]
fn page_size_query_value_limited() {
let p: Paginated<()> = Paginated::new(vec![], 1, 25, 50, false);
assert_eq!(p.page_size_query_value(), "25");
}
// ── encode_uri_component ────────────────────────────────────────
#[test]
fn encode_ascii_alphanumeric_passes_through() {
assert_eq!(encode_uri_component("abc123XYZ"), "abc123XYZ");
}
#[test]
fn encode_spaces_to_percent_20() {
assert_eq!(encode_uri_component("hello world"), "hello%20world");
}
#[test]
fn encode_special_chars() {
assert_eq!(encode_uri_component("&"), "%26");
assert_eq!(encode_uri_component("="), "%3D");
assert_eq!(encode_uri_component("?"), "%3F");
assert_eq!(encode_uri_component("a&b=c?d"), "a%26b%3Dc%3Fd");
}
#[test]
fn encode_preserves_unreserved_chars() {
assert_eq!(encode_uri_component("-_.~"), "-_.~");
}
// ── page_size_from_text ─────────────────────────────────────────
#[test]
fn page_size_from_text_all() {
assert_eq!(page_size_from_text("all"), PageSize::All);
assert_eq!(page_size_from_text("ALL"), PageSize::All);
}
#[test]
fn page_size_from_text_number() {
assert_eq!(page_size_from_text("25"), PageSize::limited(25));
}
#[test]
fn page_size_from_text_garbage_returns_default() {
assert_eq!(
page_size_from_text("garbage"),
PageSize::limited(DEFAULT_PAGE_SIZE)
);
}
// ── build_map_data ──────────────────────────────────────────────
#[test]
fn build_map_data_single_valid_country() {
let entries = vec![("Ethiopia", 1u32)];
let (data, max) = build_map_data(&entries);
assert_eq!(data, "ET:1");
assert_eq!(max, 1);
}
#[test]
fn build_map_data_duplicate_keeps_highest_weight() {
let entries = vec![("Ethiopia", 1), ("Ethiopia", 3)];
let (data, max) = build_map_data(&entries);
assert_eq!(data, "ET:3");
assert_eq!(max, 3);
}
#[test]
fn build_map_data_empty_country_skipped() {
let entries = vec![("", 1)];
let (data, max) = build_map_data(&entries);
assert_eq!(data, "");
assert_eq!(max, 0);
}
#[test]
fn build_map_data_unknown_country_skipped() {
let entries = vec![("Narnia", 1)];
let (data, max) = build_map_data(&entries);
assert_eq!(data, "");
assert_eq!(max, 0);
}
// ── build_coffee_info ───────────────────────────────────────────
fn make_roast(
origin: Option<&str>,
region: Option<&str>,
producer: Option<&str>,
process: Option<&str>,
tasting_notes: Vec<&str>,
) -> Roast {
Roast {
id: RoastId::new(1),
roaster_id: RoasterId::new(1),
name: "Test Roast".to_string(),
slug: "test-roast".to_string(),
origin: origin.map(String::from),
region: region.map(String::from),
producer: producer.map(String::from),
tasting_notes: tasting_notes.into_iter().map(String::from).collect(),
process: process.map(String::from),
created_at: Utc::now(),
}
}
#[test]
fn build_coffee_info_all_none_shows_em_dashes() {
let roast = make_roast(None, None, None, None, vec![]);
let info = build_coffee_info(&roast);
let em_dash = "\u{2014}";
assert_eq!(info.origin, em_dash);
assert_eq!(info.region, em_dash);
assert_eq!(info.producer, em_dash);
assert_eq!(info.process, em_dash);
assert!(info.tasting_notes.is_empty());
}
#[test]
fn build_coffee_info_all_populated_no_em_dashes() {
let roast = make_roast(
Some("Ethiopia"),
Some("Yirgacheffe"),
Some("Konga"),
Some("Washed"),
vec!["Blueberry", "Jasmine"],
);
let info = build_coffee_info(&roast);
let em_dash = "\u{2014}";
assert_ne!(info.origin, em_dash);
assert_ne!(info.region, em_dash);
assert_ne!(info.producer, em_dash);
assert_ne!(info.process, em_dash);
assert_eq!(info.origin, "Ethiopia");
assert_eq!(info.region, "Yirgacheffe");
assert_eq!(info.producer, "Konga");
assert_eq!(info.process, "Washed");
assert!(!info.origin_flag.is_empty());
assert_eq!(info.tasting_notes.len(), 2);
}
#[test]
fn build_coffee_info_comma_separated_tasting_notes_split() {
let roast = make_roast(None, None, None, None, vec!["Blueberry, Jasmine, Caramel"]);
let info = build_coffee_info(&roast);
assert_eq!(info.tasting_notes.len(), 3);
assert_eq!(info.tasting_notes[0].label, "Blueberry");
assert_eq!(info.tasting_notes[1].label, "Jasmine");
assert_eq!(info.tasting_notes[2].label, "Caramel");
}
}

View file

@ -546,4 +546,40 @@ mod tests {
assert_eq!(levenshtein("same", "same"), 0); assert_eq!(levenshtein("same", "same"), 0);
assert_eq!(levenshtein("smokey", "smoky"), 1); assert_eq!(levenshtein("smokey", "smoky"), 1);
} }
// ── parse_and_categorize ───────────────────────────────────────────
#[test]
fn parse_and_categorize_splits_commas() {
let notes = vec!["chocolate, caramel".to_string()];
let result = parse_and_categorize(&notes);
assert_eq!(result.len(), 2);
assert_eq!(result[0].label, "chocolate");
assert_eq!(result[1].label, "caramel");
}
#[test]
fn parse_and_categorize_splits_newlines() {
let notes = vec!["chocolate\ncaramel".to_string()];
let result = parse_and_categorize(&notes);
assert_eq!(result.len(), 2);
assert_eq!(result[0].label, "chocolate");
assert_eq!(result[1].label, "caramel");
}
#[test]
fn parse_and_categorize_filters_empty() {
let notes = vec!["chocolate, , caramel".to_string()];
let result = parse_and_categorize(&notes);
assert_eq!(result.len(), 2);
assert_eq!(result[0].label, "chocolate");
assert_eq!(result[1].label, "caramel");
}
#[test]
fn parse_and_categorize_empty_input() {
let notes: Vec<String> = vec![];
let result = parse_and_categorize(&notes);
assert!(result.is_empty());
}
} }