Files
Marcos Paulo 3ea90ed933 feat(templates): wave 1 implementation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:59:07 -03:00

288 lines
11 KiB
Rust

//! Integration tests for date math, formatting, expansion, daily paths and the month grid.
use notescraft_templates::{Context, Date, DateTime, Weekday, daily_note_path, expand, format, month_grid, parse_daily_name};
fn dt(y: i32, mo: u8, d: u8, h: u8, mi: u8, s: u8) -> DateTime {
DateTime::new(Date::new(y, mo, d).expect("valid date"), h, mi, s).expect("valid time")
}
#[test]
fn package_name_is_correct() {
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-templates");
}
#[test]
fn epoch_day_round_trip_1900_to_2200() {
for year in 1900..=2200 {
for month in 1..=12u8 {
let last = Date::days_in_month(year, month).expect("month");
for day in 1..=last {
let date = Date::new(year, month, day).expect("valid date");
let days = date.to_days_since_epoch();
assert_eq!(Date::from_days_since_epoch(days).expect("round trip"), date, "{date:?}");
}
}
}
}
#[test]
fn epoch_anchors() {
assert_eq!(Date::new(1970, 1, 1).unwrap().to_days_since_epoch(), 0);
assert_eq!(Date::new(1970, 1, 2).unwrap().to_days_since_epoch(), 1);
assert_eq!(Date::new(1969, 12, 31).unwrap().to_days_since_epoch(), -1);
assert_eq!(Date::new(2000, 3, 1).unwrap().to_days_since_epoch(), 11017);
}
#[test]
fn known_weekdays_and_leap_years() {
let d = Date::new(2026, 10, 13).expect("valid");
assert_eq!(d.weekday(), Weekday::Tuesday);
assert_eq!(Date::new(2026, 10, 12).expect("valid").weekday(), Weekday::Monday);
assert_eq!(Date::new(2026, 10, 17).expect("valid").weekday(), Weekday::Saturday);
assert_eq!(Date::new(2026, 10, 18).expect("valid").weekday(), Weekday::Sunday);
assert_eq!(Date::new(1970, 1, 1).expect("valid").weekday(), Weekday::Thursday);
assert!(Date::new(2000, 2, 29).is_ok(), "2000 is a leap year");
assert!(Date::new(1900, 2, 29).is_err(), "1900 is not a leap year");
assert!(Date::new(2024, 2, 29).is_ok(), "2024 is a leap year");
assert!(Date::new(2023, 2, 29).is_err());
assert!(Date::new(2026, 13, 1).is_err());
assert!(Date::new(2026, 0, 1).is_err());
assert!(Date::new(2026, 4, 31).is_err());
assert!(Date::new(2026, 1, 0).is_err());
assert_eq!(Date::days_in_month(2024, 2), Some(29));
assert_eq!(Date::days_in_month(1900, 2), Some(28));
assert_eq!(Date::days_in_month(2026, 4), Some(30));
assert_eq!(Date::days_in_month(2026, 13), None);
}
#[test]
fn add_days_crosses_month_and_year() {
let d = Date::new(2026, 12, 31).expect("valid");
assert_eq!(d.add_days(1).expect("in range"), Date::new(2027, 1, 1).expect("valid"));
assert_eq!(d.add_days(-364).expect("in range"), Date::new(2026, 1, 1).expect("valid"));
let leap = Date::new(2024, 2, 28).expect("valid");
assert_eq!(leap.add_days(1).expect("in range"), Date::new(2024, 2, 29).expect("valid"));
assert!(Date::new(9999, 12, 31).expect("valid").add_days(1).is_err(), "out of the supported year range");
}
#[test]
fn now_with_offset_is_valid() {
for offset in [-720, -180, 0, 60, 330, 840] {
let now = DateTime::now_with_offset(offset).expect("current time is in range");
assert!(now.hour() <= 23 && now.minute() <= 59 && now.second() <= 59);
}
assert!(DateTime::now_with_offset(i32::MAX).is_err());
assert!(DateTime::now_with_offset(i32::MIN).is_err());
assert!(Date::today_utc().is_ok());
}
#[test]
fn every_token() {
let d = dt(2026, 10, 13, 15, 4, 5);
let cases: [(&str, &str); 23] = [
("YYYY", "2026"),
("YY", "26"),
("MMMM", "October"),
("MMM", "Oct"),
("MM", "10"),
("M", "10"),
("DD", "13"),
("D", "13"),
("Do", "13th"),
("dddd", "Tuesday"),
("ddd", "Tue"),
("d", "2"),
("HH", "15"),
("H", "15"),
("hh", "03"),
("h", "3"),
("mm", "04"),
("m", "4"),
("ss", "05"),
("s", "5"),
("A", "PM"),
("a", "pm"),
("[YYYY] YYYY", "YYYY 2026"),
];
for (token, want) in cases {
assert_eq!(format(&d, token), want, "token {token}");
}
}
#[test]
fn tokens_pad_and_meridiem_edges() {
let midnight = dt(2026, 1, 5, 0, 0, 0);
assert_eq!(format(&midnight, "HH:mm:ss hh:mm A"), "00:00:00 12:00 AM");
assert_eq!(format(&midnight, "h a"), "12 am");
let noon = dt(2026, 1, 5, 12, 30, 0);
assert_eq!(format(&noon, "HH:mm hh:mm a"), "12:30 12:30 pm");
let late = dt(2026, 11, 30, 23, 59, 59);
assert_eq!(format(&late, "YYYY-MM-DD HH:mm:ss"), "2026-11-30 23:59:59");
assert_eq!(format(&late, "YY-M-D h:m:s"), "26-11-30 11:59:59");
}
#[test]
fn ordinals_follow_english_rules() {
for (day, want) in
[(1u8, "1st"), (2, "2nd"), (3, "3rd"), (4, "4th"), (11, "11th"), (12, "12th"), (13, "13th"), (21, "21st"), (22, "22nd"), (23, "23rd"), (31, "31st")]
{
let d = dt(2026, 1, day, 0, 0, 0);
assert_eq!(format(&d, "Do"), want, "day {day}");
}
}
#[test]
fn bracket_escapes_and_unknown_characters() {
let d = dt(2026, 10, 13, 15, 4, 5);
assert_eq!(format(&d, "[today is] YYYY"), "today is 2026");
assert_eq!(format(&d, "[Do] Do"), "Do 13th");
assert_eq!(format(&d, "[[nested]"), "[nested");
assert_eq!(format(&d, "![YYYY"), "![2026");
assert_eq!(format(&d, "Q q # 100% ZZ"), "Q q # 100% ZZ");
assert_eq!(format(&d, ""), "");
}
#[test]
fn expand_variables() {
let ctx = Context { title: "Meeting notes", now: dt(2026, 10, 13, 15, 4, 5) };
assert_eq!(expand("# {{title}}\n", &ctx), "# Meeting notes\n");
assert_eq!(expand("{{date}}", &ctx), "2026-10-13");
assert_eq!(expand("{{time}}", &ctx), "15:04");
assert_eq!(expand("{{date:dddd D MMMM YYYY}}", &ctx), "Tuesday 13 October 2026");
assert_eq!(expand("{{time:h:mm a}}", &ctx), "3:04 pm");
assert_eq!(expand("{{title}}{{title}}", &ctx), "Meeting notesMeeting notes");
}
#[test]
fn expand_leaves_unknown_and_broken_variables_alone() {
let ctx = Context { title: "T", now: dt(2026, 10, 13, 15, 4, 5) };
assert_eq!(expand("{{unknown}}", &ctx), "{{unknown}}");
assert_eq!(expand("{{date:BOGUS}}", &ctx), "BOGUS");
assert_eq!(expand("{{ title }}", &ctx), "{{ title }}");
assert_eq!(expand("{{date", &ctx), "{{date");
assert_eq!(expand("{date}}", &ctx), "{date}}");
assert_eq!(expand("plain text", &ctx), "plain text");
}
#[test]
fn expand_does_not_recurse() {
let ctx = Context { title: "{{date}} {{title}}", now: dt(2026, 10, 13, 15, 4, 5) };
assert_eq!(expand("{{title}}", &ctx), "{{date}} {{title}}");
assert_eq!(expand("{{date:YYYY}}-{{date:YYYY}}", &ctx), "2026-2026");
}
#[test]
fn expand_handles_huge_and_empty_input() {
let ctx = Context { title: "", now: dt(2026, 10, 13, 0, 0, 0) };
assert_eq!(expand("", &ctx), "");
let big = "{{date:YYYY-MM-DD}}".repeat(200_000);
let out = expand(&big, &ctx);
assert_eq!(out.len(), 200_000 * 10);
let unterminated = "{{".repeat(500_000);
assert_eq!(expand(&unterminated, &ctx), unterminated);
}
#[test]
fn daily_note_paths() {
let date = Date::new(2026, 10, 13).expect("valid");
assert_eq!(daily_note_path("Daily", "YYYY-MM-DD", &date).expect("ok"), "Daily/2026-10-13.md");
assert_eq!(daily_note_path("", "YYYY-MM-DD", &date).expect("ok"), "2026-10-13.md");
assert_eq!(daily_note_path("Daily/", "YYYY-MM-DD", &date).expect("ok"), "Daily/2026-10-13.md");
assert_eq!(daily_note_path("Daily", "YYYY/MM/YYYY-MM-DD", &date).expect("ok"), "Daily/2026/10/2026-10-13.md");
assert_eq!(daily_note_path("Daily\\Notes", "YYYY-MM-DD", &date).expect("ok"), "Daily/Notes/2026-10-13.md");
assert_eq!(daily_note_path("Daily", "DD-MM-YYYY", &date).expect("ok"), "Daily/13-10-2026.md");
}
#[test]
fn daily_note_path_rejects_traversal() {
let date = Date::new(2026, 10, 13).expect("valid");
for (folder, fmt) in [
("..", "YYYY-MM-DD"),
("../evil", "YYYY-MM-DD"),
("Daily/..", "YYYY-MM-DD"),
("a/../b", "YYYY-MM-DD"),
("Daily", ".."),
("Daily", "../YYYY-MM-DD"),
("Daily", "YYYY/../YYYY-MM-DD"),
] {
assert!(daily_note_path(folder, fmt, &date).is_err(), "{folder} {fmt} must be rejected");
}
}
#[test]
fn parse_daily_name_common_formats() {
let want = Date::new(2026, 10, 13).expect("valid");
for fmt in ["YYYY-MM-DD", "YYYY/MM/YYYY-MM-DD", "DD-MM-YYYY", "YYYYMMDD", "YYYY_MM_DD", "ddd, DD MMM YYYY", "M-D-YYYY", "YYYY-MM-DD HH-mm"] {
let stem = match fmt {
"DD-MM-YYYY" => "13-10-2026",
"YYYYMMDD" => "20261013",
"YYYY_MM_DD" => "2026_10_13",
"ddd, DD MMM YYYY" => "Tue, 13 Oct 2026",
"M-D-YYYY" => "10-13-2026",
"YYYY-MM-DD HH-mm" => "2026-10-13 15-04",
_ => "2026-10-13",
};
assert_eq!(parse_daily_name(fmt, stem), Some(want), "format {fmt}");
}
assert_eq!(parse_daily_name("YYYY-MM-DD", "2026-10-13"), Some(want));
}
#[test]
fn parse_daily_name_rejects_bad_input() {
for (fmt, stem) in [
("YYYY-MM-DD", "2026-10"),
("YYYY-MM-DD", "not-a-date"),
("YYYY-MM-DD", "2026-10-13 extra"),
("YYYY-MM-DD", "2026-13-13"),
("YYYY-MM-DD", "2023-02-29"),
("DD-MM-YYYY", "31-02-2026"),
("YYYY-MM-DD", ""),
("", "2026-10-13"),
("YYYY-MM-DD", "2026-10-33"),
("GGGG-MM-DD", "abcd-10-13"),
("dd-MM-yyyy", "13-10-2026"),
("YYYY-MM-DD", "2026-10-13-13"),
] {
assert_eq!(parse_daily_name(fmt, stem), None, "{fmt} / {stem} must not parse");
}
}
#[test]
fn month_grid_october_2026_sunday_start() {
let grid = month_grid(2026, 10, Weekday::Sunday).expect("ok");
assert_eq!(grid[0], [None, None, None, None, Some(1), Some(2), Some(3)]);
assert_eq!(grid[1], [Some(4), Some(5), Some(6), Some(7), Some(8), Some(9), Some(10)]);
assert_eq!(grid[4], [Some(25), Some(26), Some(27), Some(28), Some(29), Some(30), Some(31)]);
assert_eq!(grid[5], [None; 7]);
let filled: Vec<u8> = grid.iter().flatten().filter_map(|c| *c).collect();
assert_eq!(filled.len(), 31);
assert_eq!(filled.first().copied(), Some(1));
assert_eq!(filled.last().copied(), Some(31));
}
#[test]
fn month_grid_other_starts_and_months() {
let monday = month_grid(2026, 10, Weekday::Monday).expect("ok");
assert_eq!(monday[0], [None, None, None, Some(1), Some(2), Some(3), Some(4)]);
let feb_2026 = month_grid(2026, 2, Weekday::Sunday).expect("ok");
let feb_days: Vec<u8> = feb_2026.iter().flatten().filter_map(|c| *c).collect();
assert_eq!(feb_days.len(), 28);
assert_eq!(feb_2026[0][0], Some(1));
assert_eq!(feb_2026[3][6], Some(28));
assert_eq!(feb_2026[4], [None; 7]);
assert_eq!(feb_2026[5], [None; 7]);
let feb_2024 = month_grid(2024, 2, Weekday::Sunday).expect("ok");
assert_eq!(feb_2024.iter().flatten().filter_map(|c| *c).count(), 29);
assert_eq!(feb_2024[4][4], Some(29));
let june_2026 = month_grid(2026, 6, Weekday::Monday).expect("ok");
assert_eq!(june_2026[0][0], Some(1));
assert!(month_grid(2026, 13, Weekday::Sunday).is_err());
assert!(month_grid(2026, 0, Weekday::Sunday).is_err());
assert!(month_grid(99_999, 1, Weekday::Sunday).is_err());
}