Bump to 0.1.1: fix bugs, rewrite tests

Bug fixes:
- Fix Zeller's formula for Julian calendar (separate branch without
  century correction) and use rem_euclid for negative modulo safety
- Pass country code (cc=) to isdayoff.ru API in holiday plugin
- Sync clap --version with Cargo.toml (was hardcoded "1.0.0")

Tests:
- Rename integration_tests.rs to unit_tests.rs (they are unit tests)
- Fix race condition on env vars in plugin tests (Mutex + remove_var)
- Fix incorrect assertions (color tty detection, locale fallback)
- Add missing test cases: Julian/Gregorian Zeller, display date
  parsing, Sunday offset, week numbers, edge cases (87 tests total)
- Remove brittle version-checking tests
This commit is contained in:
2026-02-19 13:42:54 +03:00
parent 2cadb74fd2
commit 6eb630abb8
10 changed files with 1030 additions and 766 deletions

4
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"]

View File

@@ -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"

View File

@@ -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<String> {
let url = format!("{}?year={}&month={:02}&pre=1", API_URL_MONTH, year, month);
fn fetch_holidays(year: i32, month: u32, country: &str) -> Option<String> {
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<String> {
}
/// Fetch holiday data for entire year from isdayoff.ru API.
pub fn fetch_holidays_year(year: i32, _country: &str) -> Option<String> {
let url = format!("{}?year={}&pre=1", API_URL_YEAR, year);
pub fn fetch_holidays_year(year: i32, country: &str) -> Option<String> {
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(),

View File

@@ -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");
}
}

View File

@@ -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");
}

View File

@@ -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).

View File

@@ -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,

View File

@@ -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);
}
}

923
tests/unit_tests.rs Normal file
View File

@@ -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);
}
}