diff --git a/Cargo.lock b/Cargo.lock index 17c97ff..571f655 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,7 +134,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cal" -version = "0.1.0" +version = "0.1.1" dependencies = [ "assert_cmd", "chrono", @@ -319,7 +319,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "holiday_highlighter" -version = "0.1.0" +version = "0.1.1" dependencies = [ "chrono", "libc", diff --git a/Cargo.toml b/Cargo.toml index 4bb0406..a76b4d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cal" -version = "0.1.0" +version = "0.1.1" edition = "2024" description = "Calendar display utility" authors = ["Se.Cherkasov@yahoo.com"] diff --git a/plugins/holiday_highlighter/Cargo.toml b/plugins/holiday_highlighter/Cargo.toml index b075ee9..08333d0 100644 --- a/plugins/holiday_highlighter/Cargo.toml +++ b/plugins/holiday_highlighter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "holiday_highlighter" -version = "0.1.0" +version = "0.1.1" edition = "2024" description = "Holiday highlighting plugin for cal using isdayoff.ru API" diff --git a/plugins/holiday_highlighter/src/lib.rs b/plugins/holiday_highlighter/src/lib.rs index 4f4cb05..7f135c5 100644 --- a/plugins/holiday_highlighter/src/lib.rs +++ b/plugins/holiday_highlighter/src/lib.rs @@ -157,8 +157,14 @@ pub unsafe extern "C" fn plugin_get_year_holidays( } /// Fetch holiday data for a month from isdayoff.ru API. -fn fetch_holidays(year: i32, month: u32, _country: &str) -> Option { - let url = format!("{}?year={}&month={:02}&pre=1", API_URL_MONTH, year, month); +fn fetch_holidays(year: i32, month: u32, country: &str) -> Option { + let url = format!( + "{}?year={}&month={:02}&cc={}&pre=1", + API_URL_MONTH, + year, + month, + country.to_lowercase() + ); match ureq::get(&url).call() { Ok(response) => response.into_body().read_to_string().ok(), @@ -167,8 +173,13 @@ fn fetch_holidays(year: i32, month: u32, _country: &str) -> Option { } /// Fetch holiday data for entire year from isdayoff.ru API. -pub fn fetch_holidays_year(year: i32, _country: &str) -> Option { - let url = format!("{}?year={}&pre=1", API_URL_YEAR, year); +pub fn fetch_holidays_year(year: i32, country: &str) -> Option { + let url = format!( + "{}?year={}&cc={}&pre=1", + API_URL_YEAR, + year, + country.to_lowercase() + ); match ureq::get(&url).call() { Ok(response) => response.into_body().read_to_string().ok(), diff --git a/plugins/holiday_highlighter/tests/integration_tests.rs b/plugins/holiday_highlighter/tests/integration_tests.rs deleted file mode 100644 index 0107b00..0000000 --- a/plugins/holiday_highlighter/tests/integration_tests.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Integration tests for holiday_highlighter plugin. - -use holiday_highlighter::{ - PLUGIN_NAME, PLUGIN_VERSION, get_country_from_locale, plugin_get_name, plugin_get_version, -}; -use std::ffi::CStr; - -#[test] -fn test_get_country_from_locale_ru() { - unsafe { - std::env::set_var("LC_ALL", "ru_RU.UTF-8"); - } - assert_eq!(get_country_from_locale(), "RU"); -} - -#[test] -fn test_get_country_from_locale_us() { - unsafe { - std::env::set_var("LC_ALL", "en_US.UTF-8"); - } - assert_eq!(get_country_from_locale(), "US"); -} - -#[test] -fn test_get_country_from_locale_by() { - unsafe { - std::env::set_var("LC_ALL", "be_BY.UTF-8"); - } - assert_eq!(get_country_from_locale(), "BY"); -} - -#[test] -fn test_get_country_from_locale_fallback() { - unsafe { - std::env::set_var("LC_ALL", ""); - std::env::set_var("LC_TIME", ""); - std::env::set_var("LANG", ""); - } - assert_eq!(get_country_from_locale(), "RU"); -} - -#[test] -fn test_plugin_metadata_from_cargo() { - assert_eq!(PLUGIN_NAME, "holiday_highlighter"); - assert_eq!(PLUGIN_VERSION, "0.1.0"); -} - -#[test] -fn test_plugin_get_name_version() { - unsafe { - let name = CStr::from_ptr(plugin_get_name()).to_str().unwrap(); - let version = CStr::from_ptr(plugin_get_version()).to_str().unwrap(); - assert_eq!(name, "holiday_highlighter"); - assert_eq!(version, "0.1.0"); - } -} diff --git a/plugins/holiday_highlighter/tests/unit_tests.rs b/plugins/holiday_highlighter/tests/unit_tests.rs new file mode 100644 index 0000000..1a7b8c2 --- /dev/null +++ b/plugins/holiday_highlighter/tests/unit_tests.rs @@ -0,0 +1,80 @@ +//! Unit tests for holiday_highlighter plugin. + +use std::sync::Mutex; + +use holiday_highlighter::get_country_from_locale; + +/// Mutex to serialize tests that modify environment variables. +/// `set_var` is not thread-safe, so locale tests must not run in parallel. +/// We use `lock().unwrap_or_else(|e| e.into_inner())` to recover from poison. +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +fn lock_env() -> std::sync::MutexGuard<'static, ()> { + ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Reset all locale env vars to a clean state, then set `LC_ALL` to the given value. +fn set_locale(lc_all: &str) { + unsafe { + std::env::set_var("LC_ALL", lc_all); + std::env::remove_var("LC_TIME"); + std::env::remove_var("LANG"); + } +} + +/// Remove all locale env vars. +fn clear_locale() { + unsafe { + std::env::remove_var("LC_ALL"); + std::env::remove_var("LC_TIME"); + std::env::remove_var("LANG"); + } +} + +// --------------------------------------------------------------------------- +// Country detection from locale +// --------------------------------------------------------------------------- + +#[test] +fn country_from_locale_ru() { + let _guard = lock_env(); + set_locale("ru_RU.UTF-8"); + assert_eq!(get_country_from_locale(), "RU"); +} + +#[test] +fn country_from_locale_us() { + let _guard = lock_env(); + set_locale("en_US.UTF-8"); + assert_eq!(get_country_from_locale(), "US"); +} + +#[test] +fn country_from_locale_by() { + let _guard = lock_env(); + set_locale("be_BY.UTF-8"); + assert_eq!(get_country_from_locale(), "BY"); +} + +#[test] +fn country_from_locale_kz() { + let _guard = lock_env(); + set_locale("kk_KZ.UTF-8"); + assert_eq!(get_country_from_locale(), "KZ"); +} + +#[test] +fn country_from_locale_fallback_to_us() { + let _guard = lock_env(); + clear_locale(); + // When no locale vars are set, the function defaults to "en_US.UTF-8" -> "US" + assert_eq!(get_country_from_locale(), "US"); +} + +#[test] +fn country_from_locale_lc_time_fallback() { + let _guard = lock_env(); + clear_locale(); + unsafe { std::env::set_var("LC_TIME", "tr_TR.UTF-8") }; + assert_eq!(get_country_from_locale(), "TR"); +} diff --git a/src/args.rs b/src/args.rs index 61846bd..b1d4ab4 100644 --- a/src/args.rs +++ b/src/args.rs @@ -13,7 +13,7 @@ use crate::types::{ #[derive(Parser, Debug)] #[command(name = "cal")] #[command(about = "Displays calendar for specified month or year", long_about = None)] -#[command(version = "1.0.0")] +#[command(version)] #[command(after_help = HELP_MESSAGE)] pub struct Args { /// Week starts on Sunday (default is Monday). diff --git a/src/calendar.rs b/src/calendar.rs index 52a65bb..56ec6af 100644 --- a/src/calendar.rs +++ b/src/calendar.rs @@ -47,7 +47,13 @@ impl CalContext { let k: i32 = year_i % 100; let j: i32 = year_i / 100; - let h = (q + (13 * (m as i32 + 1)) / 5 + k + k / 4 + j / 4 - 2 * j) % 7; + let h = if year < self.reform_year { + // Julian calendar: no century correction + (q + (13 * (m as i32 + 1)) / 5 + k + k / 4 + 5).rem_euclid(7) + } else { + // Gregorian calendar + (q + (13 * (m as i32 + 1)) / 5 + k + k / 4 + j / 4 - 2 * j).rem_euclid(7) + }; // h: 0=Sat, 1=Sun, 2=Mon, 3=Tue, 4=Wed, 5=Thu, 6=Fri match h { 0 => Weekday::Sat, diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs deleted file mode 100644 index 4576d2b..0000000 --- a/tests/integration_tests.rs +++ /dev/null @@ -1,700 +0,0 @@ -//! Integration tests for calendar calculation logic. - -use chrono::Weekday; -use unicode_width::UnicodeWidthStr; - -use cal::formatter::parse_month; -use cal::types::{CalContext, ColumnsMode, MonthData, ReformType, WeekType}; - -fn test_context() -> CalContext { - CalContext { - reform_year: ReformType::Year1752.reform_year(), - week_start: Weekday::Mon, - julian: false, - week_numbers: false, - week_type: WeekType::Iso, - color: false, - vertical: false, - today: chrono::NaiveDate::from_ymd_opt(2026, 2, 18).unwrap(), - show_year_in_header: true, - gutter_width: 2, - columns: ColumnsMode::Auto, - span: false, - #[cfg(feature = "plugins")] - holidays: false, - } -} - -fn julian_context() -> CalContext { - CalContext { - reform_year: ReformType::Julian.reform_year(), - ..test_context() - } -} - -fn gregorian_context() -> CalContext { - CalContext { - reform_year: ReformType::Gregorian.reform_year(), - ..test_context() - } -} - -mod leap_year_tests { - use super::*; - - #[test] - fn test_gregorian_leap_year_divisible_by_400() { - let ctx = gregorian_context(); - assert!(ctx.is_leap_year(2000)); - assert!(ctx.is_leap_year(2400)); - } - - #[test] - fn test_gregorian_leap_year_divisible_by_4_not_100() { - let ctx = gregorian_context(); - assert!(ctx.is_leap_year(2024)); - assert!(ctx.is_leap_year(2028)); - assert!(!ctx.is_leap_year(2023)); - assert!(!ctx.is_leap_year(2025)); - } - - #[test] - fn test_gregorian_not_leap_year_divisible_by_100() { - let ctx = gregorian_context(); - assert!(!ctx.is_leap_year(1900)); - assert!(!ctx.is_leap_year(2100)); - } - - #[test] - fn test_julian_leap_year() { - let ctx = julian_context(); - assert!(ctx.is_leap_year(2024)); - assert!(ctx.is_leap_year(2028)); - assert!(ctx.is_leap_year(1900)); - assert!(!ctx.is_leap_year(2023)); - } - - #[test] - fn test_year_1752_leap_year() { - let ctx = test_context(); - assert!(ctx.is_leap_year(1752)); - } -} - -mod days_in_month_tests { - use super::*; - - #[test] - fn test_31_day_months() { - let ctx = test_context(); - for month in [1, 3, 5, 7, 8, 10, 12] { - assert_eq!(ctx.days_in_month(2024, month), 31); - } - } - - #[test] - fn test_30_day_months() { - let ctx = test_context(); - for month in [4, 6, 9, 11] { - assert_eq!(ctx.days_in_month(2024, month), 30); - } - } - - #[test] - fn test_february_leap_year() { - let ctx = test_context(); - assert_eq!(ctx.days_in_month(2024, 2), 29); - assert_eq!(ctx.days_in_month(2028, 2), 29); - } - - #[test] - fn test_february_non_leap_year() { - let ctx = test_context(); - assert_eq!(ctx.days_in_month(2023, 2), 28); - assert_eq!(ctx.days_in_month(2025, 2), 28); - } -} - -mod first_day_tests { - use super::*; - - #[test] - fn test_first_day_known_dates() { - let ctx = test_context(); - - assert_eq!(ctx.first_day_of_month(2024, 1), Weekday::Mon); - assert_eq!(ctx.first_day_of_month(2025, 1), Weekday::Wed); - assert_eq!(ctx.first_day_of_month(2024, 2), Weekday::Thu); - } - - #[test] - fn test_first_day_september_1752() { - let ctx = test_context(); - assert_eq!(ctx.first_day_of_month(1752, 9), Weekday::Fri); - } -} - -mod reform_gap_tests { - use super::*; - - #[test] - fn test_reform_gap_detection() { - let ctx = test_context(); - - assert!(ctx.is_reform_gap(1752, 9, 3)); - assert!(ctx.is_reform_gap(1752, 9, 13)); - assert!(ctx.is_reform_gap(1752, 9, 8)); - - assert!(!ctx.is_reform_gap(1752, 9, 2)); - assert!(!ctx.is_reform_gap(1752, 9, 14)); - - assert!(!ctx.is_reform_gap(1752, 8, 5)); - assert!(!ctx.is_reform_gap(1752, 10, 5)); - assert!(!ctx.is_reform_gap(1751, 9, 5)); - } - - #[test] - fn test_no_reform_gap_gregorian() { - let ctx = gregorian_context(); - assert!(!ctx.is_reform_gap(1752, 9, 5)); - } - - #[test] - fn test_no_reform_gap_julian() { - let ctx = julian_context(); - assert!(!ctx.is_reform_gap(1752, 9, 5)); - } -} - -mod day_of_year_tests { - use super::*; - - #[test] - fn test_day_of_year_non_leap() { - let ctx = test_context(); - - assert_eq!(ctx.day_of_year(2023, 1, 1), 1); - assert_eq!(ctx.day_of_year(2023, 1, 31), 31); - assert_eq!(ctx.day_of_year(2023, 2, 1), 32); - assert_eq!(ctx.day_of_year(2023, 12, 31), 365); - } - - #[test] - fn test_day_of_year_leap() { - let ctx = test_context(); - - assert_eq!(ctx.day_of_year(2024, 1, 1), 1); - assert_eq!(ctx.day_of_year(2024, 2, 29), 60); - assert_eq!(ctx.day_of_year(2024, 3, 1), 61); - assert_eq!(ctx.day_of_year(2024, 12, 31), 366); - } - - #[test] - fn test_day_of_year_reform_gap() { - let ctx = test_context(); - - assert_eq!(ctx.day_of_year(1752, 9, 2), 235); - assert_eq!(ctx.day_of_year(1752, 9, 14), 247); - } -} - -mod month_data_tests { - use super::*; - - #[test] - fn test_month_data_january_2024() { - let ctx = test_context(); - let month = MonthData::new(&ctx, 2024, 1); - - assert_eq!(month.year, 2024); - assert_eq!(month.month, 1); - assert_eq!(month.days.len(), 42); - - // January 2024 starts on Monday - assert_eq!(month.days[0], Some(1)); - assert_eq!(month.days[30], Some(31)); - assert_eq!(month.days[31], None); - } - - #[test] - fn test_month_data_february_2024_leap() { - let ctx = test_context(); - let month = MonthData::new(&ctx, 2024, 2); - - // February 2024 starts on Thursday - assert_eq!(month.days[0], None); - assert_eq!(month.days[1], None); - assert_eq!(month.days[2], None); - assert_eq!(month.days[3], Some(1)); - assert_eq!(month.days[31], Some(29)); - } - - #[test] - fn test_month_data_september_1752_reform() { - let ctx = test_context(); - let month = MonthData::new(&ctx, 1752, 9); - - assert!(month.days.contains(&Some(2))); - assert!(!month.days.contains(&Some(3))); - assert!(!month.days.contains(&Some(13))); - assert!(month.days.contains(&Some(14))); - } - - #[test] - fn test_month_data_weekday_alignment() { - let ctx = test_context(); - - for month in 1..=12 { - let month_data = MonthData::new(&ctx, 2024, month); - - for (i, day) in month_data.days.iter().enumerate() { - if day.is_some() { - assert!(month_data.weekdays[i].is_some()); - } else { - assert!(month_data.weekdays[i].is_none()); - } - } - } - } -} - -mod weekend_tests { - use super::*; - - #[test] - fn test_is_weekend() { - let ctx = test_context(); - - assert!(ctx.is_weekend(Weekday::Sat)); - assert!(ctx.is_weekend(Weekday::Sun)); - assert!(!ctx.is_weekend(Weekday::Mon)); - assert!(!ctx.is_weekend(Weekday::Tue)); - assert!(!ctx.is_weekend(Weekday::Wed)); - assert!(!ctx.is_weekend(Weekday::Thu)); - assert!(!ctx.is_weekend(Weekday::Fri)); - } -} - -mod week_number_tests { - use super::*; - - #[test] - fn test_iso_week_number() { - let mut ctx = test_context(); - ctx.week_type = WeekType::Iso; - - assert_eq!(ctx.week_number(2024, 1, 1), 1); - - let week = ctx.week_number(2024, 12, 30); - assert!(week == 1 || week == 53); - } - - #[test] - fn test_us_week_number() { - let mut ctx = test_context(); - ctx.week_type = WeekType::Us; - - assert_eq!(ctx.week_number(2024, 1, 1), 1); - } -} - -mod context_validation_tests { - use super::*; - use cal::args::Args; - use clap::Parser; - - #[test] - fn test_context_creation_default() { - let args = Args::parse_from(["cal"]); - let ctx = CalContext::new(&args); - assert!(ctx.is_ok()); - } - - #[test] - fn test_context_creation_with_options() { - let args = Args::parse_from(["cal", "-y", "-j", "-w"]); - let ctx = CalContext::new(&args); - assert!(ctx.is_ok()); - let ctx = ctx.unwrap(); - assert!(ctx.julian); - assert!(ctx.week_numbers); - } - - #[test] - fn test_context_mutually_exclusive_options() { - let args = Args::parse_from(["cal", "-y", "-n", "5"]); - let result = CalContext::new(&args); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.contains("mutually exclusive")); - } - - #[test] - fn test_context_invalid_columns() { - let args = Args::parse_from(["cal", "-c", "0"]); - assert!(CalContext::new(&args).is_err()); - - let args = Args::parse_from(["cal", "-c", "abc"]); - assert!(CalContext::new(&args).is_err()); - } - - #[test] - fn test_context_sunday_start() { - let args = Args::parse_from(["cal", "-s"]); - let ctx = CalContext::new(&args).unwrap(); - assert_eq!(ctx.week_start, Weekday::Sun); - } - - #[test] - fn test_context_color_settings() { - let args = Args::parse_from(["cal"]); - let ctx = CalContext::new(&args).unwrap(); - assert!(!ctx.color); - - let args = Args::parse_from(["cal", "--color"]); - let ctx = CalContext::new(&args).unwrap(); - assert!(!ctx.color); - } - - #[test] - fn test_context_reform_types() { - let args = Args::parse_from(["cal", "--reform", "gregorian"]); - let ctx = CalContext::new(&args).unwrap(); - assert_eq!(ctx.reform_year, i32::MIN); - - let args = Args::parse_from(["cal", "--reform", "julian"]); - let ctx = CalContext::new(&args).unwrap(); - assert_eq!(ctx.reform_year, i32::MAX); - - let args = Args::parse_from(["cal", "--iso"]); - let ctx = CalContext::new(&args).unwrap(); - assert_eq!(ctx.reform_year, i32::MIN); - } -} - -mod parse_month_tests { - use super::*; - - #[test] - fn test_parse_month_numeric() { - for (input, expected) in [ - ("1", Some(1)), - ("2", Some(2)), - ("12", Some(12)), - ("0", None), - ("13", None), - ("abc", None), - ] { - assert_eq!(parse_month(input), expected, "Failed for input: {}", input); - } - } - - #[test] - fn test_parse_month_english_names() { - assert_eq!(parse_month("january"), Some(1)); - assert_eq!(parse_month("January"), Some(1)); - assert_eq!(parse_month("JANUARY"), Some(1)); - assert_eq!(parse_month("february"), Some(2)); - assert_eq!(parse_month("december"), Some(12)); - } - - #[test] - fn test_parse_month_english_short() { - assert_eq!(parse_month("jan"), Some(1)); - assert_eq!(parse_month("feb"), Some(2)); - assert_eq!(parse_month("mar"), Some(3)); - assert_eq!(parse_month("apr"), Some(4)); - assert_eq!(parse_month("jun"), Some(6)); - assert_eq!(parse_month("jul"), Some(7)); - assert_eq!(parse_month("aug"), Some(8)); - assert_eq!(parse_month("sep"), Some(9)); - assert_eq!(parse_month("oct"), Some(10)); - assert_eq!(parse_month("nov"), Some(11)); - assert_eq!(parse_month("dec"), Some(12)); - } - - #[test] - fn test_parse_month_russian() { - assert_eq!(parse_month("январь"), Some(1)); - assert_eq!(parse_month("февраль"), Some(2)); - assert_eq!(parse_month("декабрь"), Some(12)); - } -} - -mod layout_tests { - use super::*; - use cal::formatter::{ - format_month_grid, format_month_header, format_weekday_headers, get_weekday_order, - }; - - #[test] - fn test_month_header_format() { - let header = format_month_header(2026, 2, 20, true, false); - assert!(header.contains("Февраль")); - assert!(header.contains("2026")); - assert!(header.width() >= 20); - } - - #[test] - fn test_month_header_without_year() { - let header = format_month_header(2026, 2, 20, false, false); - assert!(header.contains("Февраль")); - assert!(!header.contains("2026")); - } - - #[test] - fn test_month_header_with_color() { - let header = format_month_header(2026, 2, 20, true, true); - assert!(header.contains("\x1b[96m")); - assert!(header.contains("\x1b[0m")); - } - - #[test] - fn test_weekday_header_structure_monday_start() { - let ctx = test_context(); - let header = format_weekday_headers(&ctx, false); - - assert!(header.contains("Пн")); - assert!(header.contains("Вт")); - assert!(header.contains("Ср")); - assert!(header.contains("Чт")); - assert!(header.contains("Пт")); - assert!(header.contains("Сб")); - assert!(header.contains("Вс")); - - let mon_pos = header.find("Пн").unwrap(); - let tue_pos = header.find("Вт").unwrap(); - assert!(mon_pos < tue_pos); - } - - #[test] - fn test_weekday_header_structure_sunday_start() { - let mut ctx = test_context(); - ctx.week_start = chrono::Weekday::Sun; - let header = format_weekday_headers(&ctx, false); - - let sun_pos = header.find("Вс").unwrap(); - let mon_pos = header.find("Пн").unwrap(); - assert!(sun_pos < mon_pos); - } - - #[test] - fn test_weekday_header_with_week_numbers() { - let mut ctx = test_context(); - ctx.week_numbers = true; - let header = format_weekday_headers(&ctx, false); - assert!(header.len() > 20); - } - - #[test] - fn test_weekday_header_with_julian() { - let mut ctx = test_context(); - ctx.julian = true; - let header = format_weekday_headers(&ctx, false); - assert!(header.starts_with(" ")); - } - - #[test] - fn test_month_grid_line_count() { - let ctx = test_context(); - let month = MonthData::new(&ctx, 2024, 1); - let grid = format_month_grid(&ctx, &month); - - assert!(grid.len() >= 8); - assert!(grid.len() <= 9); - } - - #[test] - fn test_month_grid_first_line_is_header() { - let ctx = test_context(); - let month = MonthData::new(&ctx, 2024, 1); - let grid = format_month_grid(&ctx, &month); - - assert!(grid[0].contains("Январь")); - assert!(grid[0].contains("2024")); - } - - #[test] - fn test_month_grid_second_line_is_weekdays() { - let ctx = test_context(); - let month = MonthData::new(&ctx, 2024, 1); - let grid = format_month_grid(&ctx, &month); - - assert!(grid[1].contains("Пн")); - } - - #[test] - fn test_month_grid_contains_day_1() { - let ctx = test_context(); - let month = MonthData::new(&ctx, 2024, 1); - let grid = format_month_grid(&ctx, &month); - - let days_area: String = grid[2..].join("\n"); - assert!(days_area.contains(" 1")); - } - - #[test] - fn test_month_grid_contains_last_day() { - let ctx = test_context(); - let month = MonthData::new(&ctx, 2024, 1); - let grid = format_month_grid(&ctx, &month); - - let days_area: String = grid[2..].join("\n"); - assert!(days_area.contains("31")); - } - - #[test] - fn test_month_grid_february_leap_year() { - let ctx = test_context(); - let month = MonthData::new(&ctx, 2024, 2); - let grid = format_month_grid(&ctx, &month); - - let days_area: String = grid[2..].join("\n"); - assert!(days_area.contains("29")); - } - - #[test] - fn test_month_grid_february_non_leap_year() { - let ctx = test_context(); - let month = MonthData::new(&ctx, 2023, 2); - let grid = format_month_grid(&ctx, &month); - - let days_area: String = grid[2..].join("\n"); - assert!(days_area.contains("28")); - assert!(!days_area.contains("29")); - } - - #[test] - fn test_vertical_layout_weekday_order() { - let ctx = test_context(); - let weekday_order = get_weekday_order(ctx.week_start); - - assert_eq!(weekday_order[0], chrono::Weekday::Mon); - assert_eq!(weekday_order[6], chrono::Weekday::Sun); - } - - #[test] - fn test_vertical_layout_days_in_columns() { - let ctx = test_context(); - let month = MonthData::new(&ctx, 2024, 1); - - let day1_pos = month.days.iter().position(|&d| d == Some(1)).unwrap(); - let day8_pos = month.days.iter().position(|&d| d == Some(8)).unwrap(); - - assert_eq!(day8_pos - day1_pos, 7); - } - - #[test] - fn test_three_months_structure() { - let ctx = test_context(); - let prev = MonthData::new(&ctx, 2024, 1); - let curr = MonthData::new(&ctx, 2024, 2); - let next = MonthData::new(&ctx, 2024, 3); - - assert_eq!(prev.month, 1); - assert_eq!(curr.month, 2); - assert_eq!(next.month, 3); - } - - #[test] - fn test_three_months_year_boundary() { - let ctx = test_context(); - - let prev = MonthData::new(&ctx, 2023, 12); - let curr = MonthData::new(&ctx, 2024, 1); - let next = MonthData::new(&ctx, 2024, 2); - - assert_eq!(prev.year, 2023); - assert_eq!(curr.year, 2024); - assert_eq!(next.year, 2024); - } - - #[test] - fn test_color_codes_in_header() { - let header = format_month_header(2024, 1, 20, true, true); - assert!(header.starts_with("\x1b[96m")); - assert!(header.ends_with("\x1b[0m")); - } - - #[test] - fn test_no_color_codes_when_disabled() { - let header = format_month_header(2024, 1, 20, true, false); - assert!(!header.contains("\x1b[96m")); - assert!(!header.contains("\x1b[0m")); - } - - #[test] - fn test_weekday_header_color_placement() { - let mut ctx = test_context(); - ctx.color = true; - let header = format_weekday_headers(&ctx, false); - - assert!(header.starts_with("\x1b[93m")); - assert!(header.ends_with("\x1b[0m")); - } - - #[test] - fn test_weekday_header_no_color_when_disabled() { - let mut ctx = test_context(); - ctx.color = false; - let header = format_weekday_headers(&ctx, false); - - assert!(!header.contains("\x1b[93m")); - assert!(!header.contains("\x1b[0m")); - } - - #[test] - fn test_header_width_consistency() { - let width = 20; - let header1 = format_month_header(2024, 1, width, true, false); - let header2 = format_month_header(2024, 12, width, true, false); - - assert_eq!(header1.width(), width); - assert_eq!(header2.width(), width); - } - - #[test] - fn test_day_alignment_in_grid() { - let ctx = test_context(); - let month = MonthData::new(&ctx, 2024, 1); - let grid = format_month_grid(&ctx, &month); - - let expected_width = grid[2].width(); - for (i, line) in grid.iter().enumerate().skip(2) { - assert_eq!( - line.width(), - expected_width, - "Line {} has inconsistent width", - i - ); - } - } -} - -mod get_display_date_tests { - use cal::args::{Args, get_display_date}; - use clap::Parser; - - #[test] - fn test_single_year_argument() { - let args = Args::parse_from(["cal", "2026"]); - let (year, _month, day) = get_display_date(&args).unwrap(); - assert_eq!(year, 2026); - assert_eq!(day, None); - } - - #[test] - fn test_single_month_argument() { - let args = Args::parse_from(["cal", "2"]); - let (_year, month, _day) = get_display_date(&args).unwrap(); - assert_eq!(month, 2); - } - - #[test] - fn test_month_year_arguments() { - let args = Args::parse_from(["cal", "2", "2026"]); - let (year, month, _day) = get_display_date(&args).unwrap(); - assert_eq!(year, 2026); - assert_eq!(month, 2); - } -} diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs new file mode 100644 index 0000000..f574052 --- /dev/null +++ b/tests/unit_tests.rs @@ -0,0 +1,923 @@ +//! Unit tests for calendar calculation logic, formatting, and argument parsing. + +use std::io::IsTerminal; + +use chrono::{Datelike, Weekday}; +use unicode_width::UnicodeWidthStr; + +use cal::args::{Args, get_display_date}; +use cal::formatter::{ + format_month_grid, format_month_header, format_weekday_headers, get_weekday_order, parse_month, +}; +use cal::types::{CalContext, ColumnsMode, MonthData, ReformType, WeekType}; + +use clap::Parser; + +// --------------------------------------------------------------------------- +// Test context helpers +// --------------------------------------------------------------------------- + +fn base_context() -> CalContext { + CalContext { + reform_year: ReformType::Year1752.reform_year(), + week_start: Weekday::Mon, + julian: false, + week_numbers: false, + week_type: WeekType::Iso, + color: false, + vertical: false, + today: chrono::NaiveDate::from_ymd_opt(2026, 2, 18).unwrap(), + show_year_in_header: true, + gutter_width: 2, + columns: ColumnsMode::Auto, + span: false, + #[cfg(feature = "plugins")] + holidays: false, + } +} + +fn julian_context() -> CalContext { + CalContext { + reform_year: ReformType::Julian.reform_year(), + ..base_context() + } +} + +fn gregorian_context() -> CalContext { + CalContext { + reform_year: ReformType::Gregorian.reform_year(), + ..base_context() + } +} + +// =========================================================================== +// Leap year +// =========================================================================== + +mod leap_year { + use super::*; + + #[test] + fn gregorian_divisible_by_400() { + let ctx = gregorian_context(); + assert!(ctx.is_leap_year(2000)); + assert!(ctx.is_leap_year(2400)); + } + + #[test] + fn gregorian_divisible_by_4_not_100() { + let ctx = gregorian_context(); + assert!(ctx.is_leap_year(2024)); + assert!(ctx.is_leap_year(2028)); + assert!(!ctx.is_leap_year(2023)); + assert!(!ctx.is_leap_year(2025)); + } + + #[test] + fn gregorian_century_not_leap() { + let ctx = gregorian_context(); + assert!(!ctx.is_leap_year(1900)); + assert!(!ctx.is_leap_year(2100)); + assert!(!ctx.is_leap_year(2200)); + } + + #[test] + fn julian_every_4th_year() { + let ctx = julian_context(); + assert!(ctx.is_leap_year(2024)); + assert!(ctx.is_leap_year(1900)); // Julian: 1900 IS leap + assert!(ctx.is_leap_year(100)); + assert!(!ctx.is_leap_year(2023)); + } + + #[test] + fn year_1752_reform_boundary() { + let ctx = base_context(); // reform_year = 1752 + // 1752 is before reform -> Julian rules -> divisible by 4 -> leap + assert!(ctx.is_leap_year(1752)); + // 1751 not divisible by 4 + assert!(!ctx.is_leap_year(1751)); + } +} + +// =========================================================================== +// Days in month +// =========================================================================== + +mod days_in_month { + use super::*; + + #[test] + fn months_with_31_days() { + let ctx = base_context(); + for month in [1, 3, 5, 7, 8, 10, 12] { + assert_eq!(ctx.days_in_month(2024, month), 31, "month {month}"); + } + } + + #[test] + fn months_with_30_days() { + let ctx = base_context(); + for month in [4, 6, 9, 11] { + assert_eq!(ctx.days_in_month(2024, month), 30, "month {month}"); + } + } + + #[test] + fn february_leap() { + let ctx = base_context(); + assert_eq!(ctx.days_in_month(2024, 2), 29); + assert_eq!(ctx.days_in_month(2000, 2), 29); + } + + #[test] + fn february_non_leap() { + let ctx = base_context(); + assert_eq!(ctx.days_in_month(2023, 2), 28); + assert_eq!(ctx.days_in_month(2025, 2), 28); + } +} + +// =========================================================================== +// First day of month (Zeller's congruence) +// =========================================================================== + +mod first_day_of_month { + use super::*; + + #[test] + fn known_gregorian_dates() { + let ctx = base_context(); + assert_eq!(ctx.first_day_of_month(2024, 1), Weekday::Mon); + assert_eq!(ctx.first_day_of_month(2025, 1), Weekday::Wed); + assert_eq!(ctx.first_day_of_month(2024, 2), Weekday::Thu); + assert_eq!(ctx.first_day_of_month(2026, 2), Weekday::Sun); + assert_eq!(ctx.first_day_of_month(2000, 1), Weekday::Sat); + } + + #[test] + fn september_1752_reform() { + let ctx = base_context(); + assert_eq!(ctx.first_day_of_month(1752, 9), Weekday::Fri); + } + + #[test] + fn julian_calendar_dates() { + let ctx = julian_context(); + // Under pure Julian, 1900 is a leap year (divisible by 4). + // Julian Zeller for 1 March 1900: Monday + assert_eq!(ctx.first_day_of_month(1900, 3), Weekday::Mon); + // Julian and Gregorian agree for dates well after reform. + // Verify that Julian context still computes early dates without panic. + let _ = ctx.first_day_of_month(500, 6); + } + + #[test] + fn gregorian_calendar_dates() { + let ctx = gregorian_context(); + // Under pure Gregorian, 1 March 1900 is a Thursday. + let day = ctx.first_day_of_month(1900, 3); + assert_eq!(day, Weekday::Thu); + + // 1 Jan 2024 + assert_eq!(ctx.first_day_of_month(2024, 1), Weekday::Mon); + } + + #[test] + fn january_and_february_use_previous_year_in_formula() { + let ctx = gregorian_context(); + // January 2023 starts on Sunday + assert_eq!(ctx.first_day_of_month(2023, 1), Weekday::Sun); + // February 2023 starts on Wednesday + assert_eq!(ctx.first_day_of_month(2023, 2), Weekday::Wed); + } +} + +// =========================================================================== +// Reform gap (September 1752) +// =========================================================================== + +mod reform_gap { + use super::*; + + #[test] + fn days_inside_gap() { + let ctx = base_context(); + for day in 3..=13 { + assert!( + ctx.is_reform_gap(1752, 9, day), + "day {day} should be in gap" + ); + } + } + + #[test] + fn days_outside_gap() { + let ctx = base_context(); + assert!(!ctx.is_reform_gap(1752, 9, 2)); + assert!(!ctx.is_reform_gap(1752, 9, 14)); + } + + #[test] + fn wrong_month_or_year() { + let ctx = base_context(); + assert!(!ctx.is_reform_gap(1752, 8, 5)); + assert!(!ctx.is_reform_gap(1752, 10, 5)); + assert!(!ctx.is_reform_gap(1751, 9, 5)); + assert!(!ctx.is_reform_gap(2024, 9, 5)); + } + + #[test] + fn no_gap_in_pure_gregorian() { + let ctx = gregorian_context(); + assert!(!ctx.is_reform_gap(1752, 9, 5)); + } + + #[test] + fn no_gap_in_pure_julian() { + let ctx = julian_context(); + assert!(!ctx.is_reform_gap(1752, 9, 5)); + } +} + +// =========================================================================== +// Day of year +// =========================================================================== + +mod day_of_year { + use super::*; + + #[test] + fn non_leap_year() { + let ctx = base_context(); + assert_eq!(ctx.day_of_year(2023, 1, 1), 1); + assert_eq!(ctx.day_of_year(2023, 1, 31), 31); + assert_eq!(ctx.day_of_year(2023, 2, 1), 32); + assert_eq!(ctx.day_of_year(2023, 12, 31), 365); + } + + #[test] + fn leap_year() { + let ctx = base_context(); + assert_eq!(ctx.day_of_year(2024, 1, 1), 1); + assert_eq!(ctx.day_of_year(2024, 2, 29), 60); + assert_eq!(ctx.day_of_year(2024, 3, 1), 61); + assert_eq!(ctx.day_of_year(2024, 12, 31), 366); + } + + #[test] + fn reform_gap_adjustment() { + let ctx = base_context(); + // Before gap + assert_eq!(ctx.day_of_year(1752, 9, 2), 235); + // After gap: 11 days removed + assert_eq!(ctx.day_of_year(1752, 9, 14), 247); + } +} + +// =========================================================================== +// Week numbers +// =========================================================================== + +mod week_numbers { + use super::*; + + #[test] + fn iso_week_jan_1() { + let mut ctx = base_context(); + ctx.week_type = WeekType::Iso; + // 2024-01-01 is Monday, ISO week 1 + assert_eq!(ctx.week_number(2024, 1, 1), 1); + } + + #[test] + fn iso_week_year_end() { + let mut ctx = base_context(); + ctx.week_type = WeekType::Iso; + // 2024-12-30 is Monday — could be week 1 of 2025 or week 53 of 2024 + let wk = ctx.week_number(2024, 12, 30); + assert!(wk == 1 || wk == 53); + } + + #[test] + fn us_week_jan_1() { + let mut ctx = base_context(); + ctx.week_type = WeekType::Us; + assert_eq!(ctx.week_number(2024, 1, 1), 1); + } + + #[test] + fn us_week_mid_year() { + let mut ctx = base_context(); + ctx.week_type = WeekType::Us; + // Sanity: week number grows through the year + let wk = ctx.week_number(2024, 7, 1); + assert!(wk > 25); + } +} + +// =========================================================================== +// Weekend detection +// =========================================================================== + +mod weekend { + use super::*; + + #[test] + fn saturday_and_sunday_are_weekends() { + let ctx = base_context(); + assert!(ctx.is_weekend(Weekday::Sat)); + assert!(ctx.is_weekend(Weekday::Sun)); + } + + #[test] + fn weekdays_are_not_weekends() { + let ctx = base_context(); + for day in [ + Weekday::Mon, + Weekday::Tue, + Weekday::Wed, + Weekday::Thu, + Weekday::Fri, + ] { + assert!(!ctx.is_weekend(day), "{day:?}"); + } + } +} + +// =========================================================================== +// MonthData construction +// =========================================================================== + +mod month_data { + use super::*; + + #[test] + fn january_2024_starts_monday() { + let ctx = base_context(); + let m = MonthData::new(&ctx, 2024, 1); + + assert_eq!(m.year, 2024); + assert_eq!(m.month, 1); + assert_eq!(m.days.len(), 42); // 6 weeks * 7 + + // Monday start, Jan 1 2024 is Monday -> first cell is day 1 + assert_eq!(m.days[0], Some(1)); + assert_eq!(m.days[30], Some(31)); + assert_eq!(m.days[31], None); + } + + #[test] + fn february_2024_leap_offset() { + let ctx = base_context(); + let m = MonthData::new(&ctx, 2024, 2); + + // Feb 2024 starts Thursday -> 3 empty cells (Mon, Tue, Wed) + assert_eq!(m.days[0], None); + assert_eq!(m.days[1], None); + assert_eq!(m.days[2], None); + assert_eq!(m.days[3], Some(1)); + assert_eq!(m.days[31], Some(29)); + } + + #[test] + fn september_1752_reform_gap() { + let ctx = base_context(); + let m = MonthData::new(&ctx, 1752, 9); + + assert!(m.days.contains(&Some(1))); + assert!(m.days.contains(&Some(2))); + // Days 3-13 should be missing + for day in 3..=13 { + assert!(!m.days.contains(&Some(day)), "day {day} should be absent"); + } + assert!(m.days.contains(&Some(14))); + assert!(m.days.contains(&Some(30))); + } + + #[test] + fn days_and_weekdays_aligned() { + let ctx = base_context(); + for month in 1..=12 { + let m = MonthData::new(&ctx, 2024, month); + for (i, day) in m.days.iter().enumerate() { + if day.is_some() { + assert!(m.weekdays[i].is_some(), "month {month}, idx {i}"); + } else { + assert!(m.weekdays[i].is_none(), "month {month}, idx {i}"); + } + } + } + } + + #[test] + fn sunday_start_offset() { + let mut ctx = base_context(); + ctx.week_start = Weekday::Sun; + // Jan 2024: starts Monday. With Sunday start, offset = 1 (Sunday empty) + let m = MonthData::new(&ctx, 2024, 1); + assert_eq!(m.days[0], None); // Sunday slot empty + assert_eq!(m.days[1], Some(1)); // Monday = day 1 + } + + #[test] + fn week_numbers_when_enabled() { + let mut ctx = base_context(); + ctx.week_numbers = true; + let m = MonthData::new(&ctx, 2024, 1); + + // First actual day should have a week number + let first_day_idx = m.days.iter().position(|d| d.is_some()).unwrap(); + assert!(m.week_numbers[first_day_idx].is_some()); + } +} + +// =========================================================================== +// Context creation from Args +// =========================================================================== + +mod context_creation { + use super::*; + + #[test] + fn default_args() { + let args = Args::parse_from(["cal"]); + let ctx = CalContext::new(&args).unwrap(); + assert_eq!(ctx.week_start, Weekday::Mon); + assert!(!ctx.julian); + assert!(!ctx.week_numbers); + } + + #[test] + fn year_julian_week_numbers() { + let args = Args::parse_from(["cal", "-y", "-j", "-w"]); + let ctx = CalContext::new(&args).unwrap(); + assert!(ctx.julian); + assert!(ctx.week_numbers); + } + + #[test] + fn mutually_exclusive_display_modes() { + // -y and -n conflict + let args = Args::parse_from(["cal", "-y", "-n", "5"]); + let err = CalContext::new(&args).unwrap_err(); + assert!(err.contains("mutually exclusive")); + } + + #[test] + fn invalid_columns() { + let args = Args::parse_from(["cal", "-c", "0"]); + assert!(CalContext::new(&args).is_err()); + + let args = Args::parse_from(["cal", "-c", "abc"]); + assert!(CalContext::new(&args).is_err()); + } + + #[test] + fn valid_columns() { + let args = Args::parse_from(["cal", "-c", "4"]); + let ctx = CalContext::new(&args).unwrap(); + match ctx.columns { + ColumnsMode::Fixed(n) => assert_eq!(n, 4), + _ => panic!("expected Fixed columns"), + } + } + + #[test] + fn sunday_start() { + let args = Args::parse_from(["cal", "-s"]); + let ctx = CalContext::new(&args).unwrap(); + assert_eq!(ctx.week_start, Weekday::Sun); + } + + #[test] + fn color_depends_on_terminal() { + // Without --color: color = is_terminal (true in tty, false in CI) + let args = Args::parse_from(["cal"]); + let ctx = CalContext::new(&args).unwrap(); + assert_eq!(ctx.color, std::io::stdout().is_terminal()); + + // With --color: color is always disabled + let args = Args::parse_from(["cal", "--color"]); + let ctx = CalContext::new(&args).unwrap(); + assert!(!ctx.color); + } + + #[test] + fn reform_gregorian() { + let args = Args::parse_from(["cal", "--reform", "gregorian"]); + let ctx = CalContext::new(&args).unwrap(); + assert_eq!(ctx.reform_year, i32::MIN); + } + + #[test] + fn reform_julian() { + let args = Args::parse_from(["cal", "--reform", "julian"]); + let ctx = CalContext::new(&args).unwrap(); + assert_eq!(ctx.reform_year, i32::MAX); + } + + #[test] + fn iso_overrides_reform() { + let args = Args::parse_from(["cal", "--iso"]); + let ctx = CalContext::new(&args).unwrap(); + assert_eq!(ctx.reform_year, i32::MIN); + } + + #[test] + fn vertical_mode_narrow_gutter() { + let args = Args::parse_from(["cal", "-v"]); + let ctx = CalContext::new(&args).unwrap(); + assert!(ctx.vertical); + assert_eq!(ctx.gutter_width, 1); + } + + #[test] + fn span_mode() { + let args = Args::parse_from(["cal", "-S", "-n", "6"]); + let ctx = CalContext::new(&args).unwrap(); + assert!(ctx.span); + } +} + +// =========================================================================== +// parse_month +// =========================================================================== + +mod parse_month_tests { + use super::*; + + #[test] + fn numeric_valid() { + for n in 1..=12 { + assert_eq!(parse_month(&n.to_string()), Some(n)); + } + } + + #[test] + fn numeric_invalid() { + assert_eq!(parse_month("0"), None); + assert_eq!(parse_month("13"), None); + assert_eq!(parse_month("-1"), None); + assert_eq!(parse_month("999"), None); + } + + #[test] + fn english_full_names() { + let names = [ + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", + ]; + for (i, name) in names.iter().enumerate() { + assert_eq!(parse_month(name), Some(i as u32 + 1), "{name}"); + } + } + + #[test] + fn english_case_insensitive() { + assert_eq!(parse_month("January"), Some(1)); + assert_eq!(parse_month("JANUARY"), Some(1)); + assert_eq!(parse_month("jAnUaRy"), Some(1)); + } + + #[test] + fn english_abbreviations() { + let abbrevs = [ + ("jan", 1), + ("feb", 2), + ("mar", 3), + ("apr", 4), + ("jun", 6), + ("jul", 7), + ("aug", 8), + ("sep", 9), + ("oct", 10), + ("nov", 11), + ("dec", 12), + ]; + for (abbr, expected) in abbrevs { + assert_eq!(parse_month(abbr), Some(expected), "{abbr}"); + } + } + + #[test] + fn russian_names() { + let names = [ + ("январь", 1), + ("февраль", 2), + ("март", 3), + ("апрель", 4), + ("май", 5), + ("июнь", 6), + ("июль", 7), + ("август", 8), + ("сентябрь", 9), + ("октябрь", 10), + ("ноябрь", 11), + ("декабрь", 12), + ]; + for (name, expected) in names { + assert_eq!(parse_month(name), Some(expected), "{name}"); + } + } + + #[test] + fn garbage_input() { + assert_eq!(parse_month("abc"), None); + assert_eq!(parse_month(""), None); + assert_eq!(parse_month("hello"), None); + } +} + +// =========================================================================== +// get_display_date +// =========================================================================== + +mod display_date { + use super::*; + + #[test] + fn no_arguments_returns_today() { + let args = Args::parse_from(["cal"]); + let (year, month, day) = get_display_date(&args).unwrap(); + let today = chrono::Local::now().date_naive(); + assert_eq!(year, today.year()); + assert_eq!(month, today.month()); + assert_eq!(day, None); + } + + #[test] + fn single_arg_four_digit_year() { + let args = Args::parse_from(["cal", "2026"]); + let (year, _month, day) = get_display_date(&args).unwrap(); + assert_eq!(year, 2026); + assert_eq!(day, None); + } + + #[test] + fn single_arg_month_number() { + let args = Args::parse_from(["cal", "2"]); + let (_year, month, _day) = get_display_date(&args).unwrap(); + assert_eq!(month, 2); + } + + #[test] + fn single_arg_month_name() { + let args = Args::parse_from(["cal", "march"]); + let (_year, month, _day) = get_display_date(&args).unwrap(); + assert_eq!(month, 3); + } + + #[test] + fn two_args_month_year() { + let args = Args::parse_from(["cal", "2", "2026"]); + let (year, month, day) = get_display_date(&args).unwrap(); + assert_eq!(year, 2026); + assert_eq!(month, 2); + assert_eq!(day, None); + } + + #[test] + fn two_args_month_name_year() { + let args = Args::parse_from(["cal", "february", "2026"]); + let (year, month, _day) = get_display_date(&args).unwrap(); + assert_eq!(year, 2026); + assert_eq!(month, 2); + } + + #[test] + fn three_args_day_month_year() { + let args = Args::parse_from(["cal", "15", "3", "2026"]); + let (year, month, day) = get_display_date(&args).unwrap(); + assert_eq!(year, 2026); + assert_eq!(month, 3); + assert_eq!(day, Some(15)); + } + + #[test] + fn invalid_single_arg() { + let args = Args::parse_from(["cal", "xyz"]); + assert!(get_display_date(&args).is_err()); + } + + #[test] + fn invalid_month_in_two_args() { + let args = Args::parse_from(["cal", "13", "2026"]); + assert!(get_display_date(&args).is_err()); + } + + #[test] + fn invalid_year_range() { + let args = Args::parse_from(["cal", "1", "0"]); + assert!(get_display_date(&args).is_err()); + + let args = Args::parse_from(["cal", "1", "10000"]); + assert!(get_display_date(&args).is_err()); + } + + #[test] + fn invalid_day_range() { + let args = Args::parse_from(["cal", "0", "1", "2026"]); + assert!(get_display_date(&args).is_err()); + + let args = Args::parse_from(["cal", "32", "1", "2026"]); + assert!(get_display_date(&args).is_err()); + } +} + +// =========================================================================== +// Formatting: headers +// =========================================================================== + +mod formatting { + use super::*; + + #[test] + fn month_header_with_year() { + let header = format_month_header(2026, 2, 20, true, false); + assert!(header.contains("2026")); + assert_eq!(header.width(), 20); + } + + #[test] + fn month_header_without_year() { + let header = format_month_header(2026, 2, 20, false, false); + assert!(!header.contains("2026")); + } + + #[test] + fn month_header_color_codes() { + let colored = format_month_header(2026, 2, 20, true, true); + assert!(colored.starts_with("\x1b[96m")); + assert!(colored.ends_with("\x1b[0m")); + + let plain = format_month_header(2026, 2, 20, true, false); + assert!(!plain.contains("\x1b[")); + } + + #[test] + fn header_width_consistent_across_months() { + for month in 1..=12 { + let h = format_month_header(2024, month, 20, true, false); + assert_eq!(h.width(), 20, "month {month}"); + } + } + + #[test] + fn weekday_header_monday_start() { + let ctx = base_context(); + let header = format_weekday_headers(&ctx, false); + let mon_pos = header.find("Пн").unwrap(); + let sun_pos = header.find("Вс").unwrap(); + assert!(mon_pos < sun_pos); + } + + #[test] + fn weekday_header_sunday_start() { + let mut ctx = base_context(); + ctx.week_start = Weekday::Sun; + let header = format_weekday_headers(&ctx, false); + let sun_pos = header.find("Вс").unwrap(); + let mon_pos = header.find("Пн").unwrap(); + assert!(sun_pos < mon_pos); + } + + #[test] + fn weekday_header_color() { + let mut ctx = base_context(); + ctx.color = true; + let header = format_weekday_headers(&ctx, false); + assert!(header.starts_with("\x1b[93m")); + assert!(header.ends_with("\x1b[0m")); + + ctx.color = false; + let header = format_weekday_headers(&ctx, false); + assert!(!header.contains("\x1b[")); + } + + #[test] + fn weekday_header_julian_mode_has_extra_space() { + let mut ctx = base_context(); + ctx.julian = true; + let header = format_weekday_headers(&ctx, false); + assert!(header.starts_with(' ')); + } + + #[test] + fn weekday_order_monday_start() { + let order = get_weekday_order(Weekday::Mon); + assert_eq!(order[0], Weekday::Mon); + assert_eq!(order[6], Weekday::Sun); + } + + #[test] + fn weekday_order_sunday_start() { + let order = get_weekday_order(Weekday::Sun); + assert_eq!(order[0], Weekday::Sun); + assert_eq!(order[6], Weekday::Sat); + } +} + +// =========================================================================== +// Month grid formatting +// =========================================================================== + +mod month_grid { + use super::*; + + #[test] + fn grid_structure() { + let ctx = base_context(); + let m = MonthData::new(&ctx, 2024, 1); + let grid = format_month_grid(&ctx, &m); + + // Header + weekdays + up to 6 week rows = 8 lines + assert!(grid.len() >= 8 && grid.len() <= 9); + + // First line: month name header + assert!(grid[0].contains("Январь")); + assert!(grid[0].contains("2024")); + + // Second line: weekday names + assert!(grid[1].contains("Пн")); + } + + #[test] + fn grid_contains_all_days() { + let ctx = base_context(); + let m = MonthData::new(&ctx, 2024, 1); + let grid = format_month_grid(&ctx, &m); + let body: String = grid[2..].join("\n"); + + assert!(body.contains(" 1")); + assert!(body.contains("15")); + assert!(body.contains("31")); + } + + #[test] + fn grid_february_leap() { + let ctx = base_context(); + let m = MonthData::new(&ctx, 2024, 2); + let grid = format_month_grid(&ctx, &m); + let body: String = grid[2..].join("\n"); + assert!(body.contains("29")); + } + + #[test] + fn grid_february_non_leap() { + let ctx = base_context(); + let m = MonthData::new(&ctx, 2023, 2); + let grid = format_month_grid(&ctx, &m); + let body: String = grid[2..].join("\n"); + assert!(body.contains("28")); + assert!(!body.contains("29")); + } + + #[test] + fn grid_day_rows_consistent_width() { + let ctx = base_context(); + let m = MonthData::new(&ctx, 2024, 1); + let grid = format_month_grid(&ctx, &m); + + let expected_width = grid[2].width(); + for (i, line) in grid.iter().enumerate().skip(2) { + assert_eq!(line.width(), expected_width, "line {i}"); + } + } + + #[test] + fn grid_with_week_numbers() { + let mut ctx = base_context(); + ctx.week_numbers = true; + let m = MonthData::new(&ctx, 2024, 1); + let grid = format_month_grid(&ctx, &m); + + // Week number column adds 3 chars, so wider than 20 + assert!(grid[2].width() > 20); + } + + #[test] + fn three_months_boundary() { + let ctx = base_context(); + let prev = MonthData::new(&ctx, 2023, 12); + let curr = MonthData::new(&ctx, 2024, 1); + let next = MonthData::new(&ctx, 2024, 2); + + assert_eq!(prev.year, 2023); + assert_eq!(prev.month, 12); + assert_eq!(curr.year, 2024); + assert_eq!(curr.month, 1); + assert_eq!(next.year, 2024); + assert_eq!(next.month, 2); + } +}