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:
parent
472e851b80
commit
ab4bcbaa0a
5 changed files with 474 additions and 0 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(¬es);
|
||||||
|
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(¬es);
|
||||||
|
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(¬es);
|
||||||
|
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(¬es);
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue