Initial commit: Rust calendar utility

This commit is contained in:
2026-02-19 06:57:09 +03:00
commit 56a8b34710
19 changed files with 4755 additions and 0 deletions

700
tests/integration_tests.rs Normal file
View File

@@ -0,0 +1,700 @@
//! 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);
}
}