#include "nocal/domain/domain.hpp" #include #include #include #include #include #include #include #include #include #include #include namespace { int failures = 0; void check(bool condition, std::string_view message) { if (!condition) { ++failures; std::cerr << "FAIL: " << message << '\n'; } } template void check_throws(Function&& function, std::string_view message) { try { function(); check(false, message); } catch (const std::exception&) { } } void test_dates() { using namespace std::chrono; const nocal::Date leap{2024y / February / 29d}; check(nocal::valid_date(leap), "leap day is valid"); check(nocal::format_date(leap) == "2024-02-29", "date formats as ISO 8601"); check(nocal::parse_date("2024-02-29") == leap, "date parses from ISO 8601"); check(nocal::from_sys_days(nocal::to_sys_days(leap) + days{1}) == nocal::Date{2024y / March / 1d}, "civil date arithmetic crosses a leap month"); check(nocal::monday_first_weekday(nocal::Date{2026y / July / 6d}) == 0, "Monday has index zero"); check(nocal::monday_first_weekday(nocal::Date{2026y / July / 12d}) == 6, "Sunday has index six"); check_throws([] { (void)nocal::parse_date("2023-02-29"); }, "invalid date is rejected"); } void test_month_layout() { using namespace std::chrono; const auto layout = nocal::make_month_layout(2026y / July); check(layout.cells.size() == 42, "month layout always has 42 cells"); check(layout.first_visible_date() == nocal::Date{2026y / June / 29d}, "July 2026 begins after Monday-leading spill cells"); check(layout.last_visible_date() == nocal::Date{2026y / August / 9d}, "six-week grid has the expected final date"); check(layout.at(0, 0).column == 0 && layout.at(5, 6).row == 5, "cell coordinates match their positions"); check(!layout.at(0, 0).in_month && layout.at(0, 2).in_month, "spill cells are distinguished from current-month cells"); const auto* july_17 = layout.find(nocal::Date{2026y / July / 17d}); check(july_17 != nullptr && july_17->row == 2 && july_17->column == 4, "date lookup finds the correct Friday cell"); check_throws([&] { (void)layout.at(6, 0); }, "out-of-grid access is rejected"); } nocal::Event event(std::string uid, nocal::Date start_date, unsigned start_hour, nocal::Date end_date, unsigned end_hour, bool all_day = false) { nocal::Event result; result.uid = std::move(uid); result.title = result.uid; result.start = nocal::make_local_time(start_date, start_hour); result.end = nocal::make_local_time(end_date, end_hour); result.all_day = all_day; return result; } nocal::TimePoint utc_time(nocal::Date date, unsigned hour, unsigned minute = 0) { using namespace std::chrono; return nocal::TimePoint{duration_cast( nocal::to_sys_days(date).time_since_epoch() + hours{hour} + minutes{minute})}; } nocal::TimePoint zoned_time(std::string_view zone_name, nocal::Date date, unsigned hour, unsigned minute = 0) { using namespace std::chrono; const auto* zone = locate_zone(zone_name); const local_time local{ duration_cast(local_days{date}.time_since_epoch() + hours{hour} + minutes{minute})}; return time_point_cast( zone->to_sys(local, choose::earliest)); } nocal::Event utc_recurring(std::string uid, nocal::Date start_date, unsigned start_hour, nocal::Date end_date, unsigned end_hour, nocal::RecurrenceRule rule) { nocal::Event result; result.uid = std::move(uid); result.title = result.uid; result.start = utc_time(start_date, start_hour); result.end = utc_time(end_date, end_hour); result.recurrence = std::move(rule); return result; } void test_event_queries() { using namespace std::chrono; const nocal::Date previous{2026y / July / 16d}; const nocal::Date day{2026y / July / 17d}; const nocal::Date next{2026y / July / 18d}; std::vector events; events.push_back(event("late", day, 15, day, 16)); events.push_back(event("all-day", day, 0, next, 0, true)); events.push_back(event("early", day, 9, day, 10)); events.push_back(event("carry", previous, 23, day, 1)); events.push_back(event("ends-at-boundary", previous, 22, day, 0)); events.push_back(event("next-day", next, 0, next, 1)); const nocal::Date august_first{2026y / August / 1d}; events.push_back(event("next-month", august_first, 0, august_first, 1)); nocal::Event instant = event("instant", day, 12, day, 12); events.push_back(instant); const auto on_day = nocal::events_on_day(events, day); check(on_day.size() == 5, "day query uses overlap and half-open boundaries"); check(on_day[0].get().uid == "all-day", "all-day events sort before timed events"); check(on_day[1].get().uid == "carry", "overlapping prior-day event is included"); check(on_day[2].get().uid == "early" && on_day[3].get().uid == "instant" && on_day[4].get().uid == "late", "timed events sort by start time"); check(nocal::event_occurs_on(instant, day), "zero-duration event occurs on the day containing its instant"); const auto in_july = nocal::events_in_month(events, 2026y / July); check(in_july.size() == 7, "month query excludes an event beginning August 1"); nocal::Event invalid = event("invalid", day, 12, day, 11); check(!nocal::event_overlaps(invalid, nocal::local_day_start(day), nocal::local_day_end(day)), "invalid backwards event never overlaps"); } void test_daily_dst_recurrence() { using namespace std::chrono; const nocal::Date march_28{2026y / March / 28d}; const nocal::Date march_29{2026y / March / 29d}; const nocal::Date march_30{2026y / March / 30d}; nocal::Event event; event.uid = "london-daily"; event.title = event.uid; event.time_basis = nocal::TimeBasis::zoned; event.time_zone = "Europe/London"; event.start = zoned_time(event.time_zone, march_28, 9); event.end = zoned_time(event.time_zone, march_28, 10); nocal::RecurrenceRule rule; rule.frequency = nocal::RecurrenceFrequency::daily; rule.count = 3U; event.recurrence = rule; const std::vector events{event}; const auto occurrences = nocal::occurrences_overlapping( events, utc_time(march_28, 0), utc_time(2026y / March / 31d, 0)); check(occurrences.size() == 3, "daily recurrence includes DTSTART and honors COUNT"); check(occurrences.size() == 3 && occurrences[0].start == utc_time(march_28, 9) && occurrences[1].start == utc_time(march_29, 8) && occurrences[2].start == utc_time(march_30, 8), "zoned daily recurrence preserves 09:00 wall time across DST"); check(occurrences.size() == 3 && occurrences[1].end - occurrences[1].start == hours{1}, "zoned recurrence preserves the wall-clock end relationship"); } void test_floating_dst_and_until() { using namespace std::chrono; const char* previous_tz_value = std::getenv("TZ"); const std::optional previous_tz = previous_tz_value == nullptr ? std::nullopt : std::optional{previous_tz_value}; std::string london_zone_file = "/usr/share/zoneinfo/Europe/London"; if (!std::filesystem::exists(london_zone_file)) { const auto local_zone_file = std::filesystem::canonical("/etc/localtime"); if (local_zone_file.string().ends_with("/Europe/London")) { london_zone_file = local_zone_file.string(); } } const std::string london_tz = ':' + london_zone_file; if (!std::filesystem::exists(london_zone_file) || ::setenv("TZ", london_tz.c_str(), 1) != 0) { check(false, "test can select the process local time zone"); return; } ::tzset(); const nocal::Date march_28{2026y / March / 28d}; nocal::Event floating; floating.uid = "floating-daily"; floating.title = floating.uid; floating.time_basis = nocal::TimeBasis::floating; floating.start = nocal::make_local_time(march_28, 9); floating.end = nocal::make_local_time(march_28, 10); nocal::RecurrenceRule daily; daily.frequency = nocal::RecurrenceFrequency::daily; daily.count = 3U; floating.recurrence = daily; auto until = utc_recurring("until", 2026y / July / 1d, 9, 2026y / July / 1d, 10, nocal::RecurrenceRule{}); until.recurrence->until = utc_time(2026y / July / 3d, 9); const std::vector floating_events{floating}; const auto floating_occurrences = nocal::occurrences_overlapping( floating_events, utc_time(march_28, 0), utc_time(2026y / March / 31d, 0)); check(floating_occurrences.size() == 3 && floating_occurrences[0].start == utc_time(march_28, 9) && floating_occurrences[1].start == utc_time(2026y / March / 29d, 8) && floating_occurrences[2].start == utc_time(2026y / March / 30d, 8), "floating recurrence follows the selected process-local DST rules"); const std::vector until_events{until}; const auto until_occurrences = nocal::occurrences_overlapping( until_events, utc_time(2026y / July / 1d, 0), utc_time(2026y / July / 10d, 0)); check(until_occurrences.size() == 3 && until_occurrences.back().start == until.recurrence->until, "UNTIL is inclusive and bounds uncounted recurrence expansion"); if (previous_tz) { (void)::setenv("TZ", previous_tz->c_str(), 1); } else { (void)::unsetenv("TZ"); } ::tzset(); } void test_weekly_count_and_exdates() { using namespace std::chrono; const nocal::Date monday{2026y / July / 6d}; nocal::RecurrenceRule rule; rule.frequency = nocal::RecurrenceFrequency::weekly; rule.count = 6U; rule.by_weekday = {Monday, Wednesday, Friday}; auto weekly = utc_recurring("weekly", monday, 9, monday, 10, rule); weekly.recurrence_exceptions = {weekly.start, utc_time(2026y / July / 10d, 9)}; const std::vector events{weekly}; const auto occurrences = nocal::occurrences_overlapping( events, utc_time(monday, 0), utc_time(2026y / July / 20d, 0)); check(occurrences.size() == 4, "EXDATE removes DTSTART and another generated weekly start"); check(occurrences.size() == 4 && occurrences[0].ordinal == 1 && occurrences[1].ordinal == 3 && occurrences[2].ordinal == 4 && occurrences[3].ordinal == 5, "weekly ordinals and COUNT include excluded DTSTART occurrences"); check(occurrences.size() == 4 && occurrences[0].start == utc_time(2026y / July / 8d, 9) && occurrences[1].start == utc_time(2026y / July / 13d, 9), "weekly BYDAY expansion is chronological across week boundaries"); } void test_monthly_and_yearly_selectors() { using namespace std::chrono; nocal::RecurrenceRule monthly_rule; monthly_rule.frequency = nocal::RecurrenceFrequency::monthly; monthly_rule.count = 4U; monthly_rule.by_month_day = {-1}; auto monthly = utc_recurring("month-end", 2027y / January / 31d, 9, 2027y / January / 31d, 10, monthly_rule); nocal::RecurrenceRule yearly_rule; yearly_rule.frequency = nocal::RecurrenceFrequency::yearly; yearly_rule.count = 4U; yearly_rule.by_month = {1U, 6U}; auto yearly = utc_recurring("selected-months", 2024y / June / 15d, 9, 2024y / June / 15d, 10, yearly_rule); const std::vector events{monthly, yearly}; const auto month_occurrences = nocal::occurrences_overlapping( std::span{events}.first(1), utc_time(2027y / January / 1d, 0), utc_time(2027y / May / 1d, 0)); check(month_occurrences.size() == 4, "negative BYMONTHDAY expands to each month's final day"); check(month_occurrences.size() == 4 && month_occurrences[1].start == utc_time(2027y / February / 28d, 9) && month_occurrences[2].start == utc_time(2027y / March / 31d, 9) && month_occurrences[3].start == utc_time(2027y / April / 30d, 9), "monthly negative selectors account for varying month lengths"); const auto year_occurrences = nocal::occurrences_overlapping( std::span{events}.subspan(1), utc_time(2024y / January / 1d, 0), utc_time(2026y / February / 1d, 0)); check(year_occurrences.size() == 4, "yearly BYMONTH count includes DTSTART"); check(year_occurrences.size() == 4 && year_occurrences[1].start == utc_time(2025y / January / 15d, 9) && year_occurrences[2].start == utc_time(2025y / June / 15d, 9) && year_occurrences[3].start == utc_time(2026y / January / 15d, 9), "yearly BYMONTH reuses DTSTART's day and wall time"); } void test_occurrence_overlap_sorting_and_invalid_rules() { using namespace std::chrono; const nocal::Date day{2026y / July / 17d}; nocal::RecurrenceRule daily; daily.frequency = nocal::RecurrenceFrequency::daily; daily.count = 3U; auto overnight = utc_recurring("overnight", 2026y / July / 16d, 23, day, 2, daily); nocal::RecurrenceRule once; once.frequency = nocal::RecurrenceFrequency::daily; once.count = 1U; auto later = utc_recurring("later", day, 12, day, 13, once); auto all_day = utc_recurring("all-day", day, 0, 2026y / July / 18d, 0, once); all_day.all_day = true; auto invalid = later; invalid.uid = "invalid"; invalid.title = invalid.uid; invalid.recurrence->interval = 0U; std::vector events{later, overnight, invalid, all_day}; const auto occurrences = nocal::occurrences_overlapping( events, utc_time(day, 0), utc_time(2026y / July / 18d, 0)); check(occurrences.size() == 5, "occurrence overlap includes prior multi-day starts and invalid-rule base"); check(occurrences.size() == 5 && occurrences[0].source->uid == "all-day" && occurrences[1].source->uid == "overnight" && occurrences[1].ordinal == 0 && occurrences[4].source->uid == "overnight" && occurrences[4].ordinal == 1, "occurrences sort all-day first, then by generated start"); check(occurrences.size() == 5 && occurrences[2].source->uid == "invalid" && occurrences[3].source->uid == "later", "equal occurrence times sort by title and UID"); invalid.time_basis = nocal::TimeBasis::zoned; invalid.time_zone = "Not/A_Real_Zone"; invalid.recurrence->interval = 1U; const std::vector bad_zone{invalid}; const auto retained = nocal::occurrences_overlapping( bad_zone, utc_time(day, 0), utc_time(2026y / July / 18d, 0)); check(retained.size() == 1 && retained[0].ordinal == 0, "unrepresentable recurrence metadata safely retains DTSTART"); } } // namespace int main() { test_dates(); test_month_layout(); test_event_queries(); test_daily_dst_recurrence(); test_floating_dst_and_until(); test_weekly_count_and_exdates(); test_monthly_and_yearly_selectors(); test_occurrence_overlap_sorting_and_invalid_rules(); if (failures != 0) { std::cerr << failures << " domain test(s) failed\n"; return EXIT_FAILURE; } std::cout << "domain tests passed\n"; return EXIT_SUCCESS; }