#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"); const nocal::Date wednesday{2026y / July / 1d}; for (unsigned start = 0; start < 7; ++start) { const weekday week_start{start}; check(nocal::weekday_index(wednesday, week_start) == (Wednesday.c_encoding() + 7U - start) % 7U, "weekday index is relative to each possible week start"); } check(nocal::weekday_index(wednesday, Monday) == nocal::monday_first_weekday(wednesday), "Monday weekday wrapper preserves configurable-index behavior"); check_throws([] { (void)nocal::parse_date("2023-02-29"); }, "invalid date is rejected"); check_throws( [] { (void)nocal::weekday_index( nocal::Date{2023y / February / 29d}, Monday); }, "weekday index rejects an invalid date"); check_throws( [wednesday] { (void)nocal::weekday_index(wednesday, weekday{8}); }, "weekday index rejects an invalid week start"); } 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.week_start == Monday, "default month layout remains Monday-first"); 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"); const auto sunday_layout = nocal::make_month_layout(2026y / July, Sunday); check(sunday_layout.week_start == Sunday, "month layout records its configured week start"); check(sunday_layout.first_visible_date() == nocal::Date{2026y / June / 28d} && sunday_layout.last_visible_date() == nocal::Date{2026y / August / 8d}, "Sunday-first July 2026 has the expected spill dates"); const auto* sunday_july_first = sunday_layout.find(nocal::Date{2026y / July / 1d}); check(sunday_july_first != nullptr && sunday_july_first->row == 0 && sunday_july_first->column == 3, "Sunday-first July 2026 places Wednesday in column three"); for (unsigned start = 0; start < 7; ++start) { const weekday week_start{start}; const auto configured = nocal::make_month_layout(2026y / July, week_start); check(configured.week_start == week_start, "each configured week start is retained by the layout"); check(weekday{nocal::to_sys_days(configured.first_visible_date())} == week_start, "each layout starts on its configured weekday"); for (std::size_t index = 1; index < configured.cells.size(); ++index) { check(nocal::to_sys_days(configured.cells[index].date) == nocal::to_sys_days(configured.cells[index - 1].date) + days{1}, "month layout cells are 42 consecutive dates"); } const auto* july_first = configured.find(nocal::Date{2026y / July / 1d}); check(july_first != nullptr && july_first->row == 0 && july_first->column == nocal::weekday_index(nocal::Date{2026y / July / 1d}, week_start), "July 2026 column follows the configured week start"); } check_throws( [] { (void)nocal::make_month_layout(2026y / July, weekday{8}); }, "month layout rejects an invalid week start"); 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_event_search() { nocal::Event event; event.title = "Quarterly Planning"; event.description = "Review the launch checklist"; event.location = "North Conference Room"; event.calendar_id = "TEAM-OPERATIONS"; check(nocal::event_matches_query(event, "quarterly"), "event search matches the title case-insensitively"); check(nocal::event_matches_query(event, "launch check"), "event search matches a description substring"); check(nocal::event_matches_query(event, "conference"), "event search matches a location substring"); check(nocal::event_matches_query(event, "team-operations"), "event search matches the calendar ID case-insensitively"); check(!nocal::event_matches_query(event, ""), "an empty event search query matches nothing"); check(!nocal::event_matches_query(event, "budget"), "event search rejects text absent from every searchable field"); event.title = "Caf\xC3\xA9 REVIEW"; check(nocal::event_matches_query(event, "Caf\xC3\xA9 review"), "event search preserves non-ASCII bytes while folding ASCII"); check(!nocal::event_matches_query(event, "CAF\xC3\x89"), "event search does not locale-fold non-ASCII bytes"); } void test_calendar_visibility() { const std::vector calendars{ {.id = "", .name = "Default", .color = {}, .visible = true, .read_only = false}, {.id = "personal", .name = "Personal", .color = {}, .visible = true, .read_only = false}, {.id = "work", .name = "Work", .color = {}, .visible = false, .read_only = false}, }; nocal::Event default_event; nocal::Event personal_event; personal_event.calendar_id = "personal"; nocal::Event work_event; work_event.calendar_id = "work"; nocal::Event new_event; new_event.calendar_id = "new-calendar"; check(nocal::event_is_visible(default_event, calendars), "an empty calendar ID uses the visible default calendar"); check(nocal::event_is_visible(personal_event, calendars), "an explicitly visible calendar exposes its events"); check(!nocal::event_is_visible(work_event, calendars), "a hidden calendar filters its events"); check(nocal::event_is_visible(new_event, calendars), "unknown calendar metadata defaults visible instead of hiding data"); } 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_rdate_only_query_boundaries_and_duration() { using namespace std::chrono; const nocal::Date july_10{2026y / July / 10d}; const nocal::Date july_11{2026y / July / 11d}; nocal::Event added; added.uid = "rdate-only"; added.title = added.uid; added.start = utc_time(2026y / July / 1d, 9); added.end = added.start + minutes{90}; added.recurrence_additions = {utc_time(july_10, 9), utc_time(july_11, 9)}; check(nocal::is_recurring(added), "an RDATE-only event is part of a recurrence set"); const std::vector events{added}; const auto occurrences = nocal::occurrences_overlapping( events, utc_time(july_10, 10), utc_time(july_11, 9)); check(occurrences.size() == 1 && occurrences.front().start == utc_time(july_10, 9), "RDATE overlap includes a carrying occurrence and excludes a start at the query end"); check(occurrences.size() == 1 && occurrences.front().end == utc_time(july_10, 10, 30), "UTC RDATE occurrences preserve DTSTART's elapsed duration"); } void test_rdate_merge_deduplication_and_exdates() { using namespace std::chrono; const nocal::Date july_1{2026y / July / 1d}; nocal::RecurrenceRule daily; daily.frequency = nocal::RecurrenceFrequency::daily; daily.count = 3U; auto merged = utc_recurring("rdate-merge", july_1, 9, july_1, 10, daily); merged.recurrence_additions = { merged.start, utc_time(2026y / July / 2d, 9), utc_time(2026y / July / 4d, 9), utc_time(2026y / July / 4d, 9)}; const auto all = nocal::occurrences_overlapping( std::span{&merged, 1}, utc_time(july_1, 0), utc_time(2026y / July / 5d, 0)); check(all.size() == 4, "DTSTART, RRULE, and RDATE form a set with duplicate starts collapsed"); check(all.size() == 4 && all[0].ordinal == 0U && all[1].ordinal == 1U && all[2].ordinal == 2U && all[3].start == utc_time(2026y / July / 4d, 9), "RDATE merging preserves existing RRULE ordinals"); merged.recurrence_exceptions = {merged.start, utc_time(2026y / July / 4d, 9)}; const auto filtered = nocal::occurrences_overlapping( std::span{&merged, 1}, utc_time(july_1, 0), utc_time(2026y / July / 5d, 0)); check(filtered.size() == 2 && filtered[0].start == utc_time(2026y / July / 2d, 9) && filtered[1].start == utc_time(2026y / July / 3d, 9), "EXDATE subtracts both DTSTART and RDATE-only starts from the recurrence set"); } void test_zoned_rdate_wall_duration() { using namespace std::chrono; nocal::Event added; added.uid = "zoned-rdate"; added.title = added.uid; added.time_basis = nocal::TimeBasis::zoned; added.time_zone = "Europe/London"; added.start = zoned_time(added.time_zone, 2026y / March / 28d, 0, 30); added.end = zoned_time(added.time_zone, 2026y / March / 28d, 2, 30); added.recurrence_additions = { zoned_time(added.time_zone, 2026y / March / 29d, 0, 30)}; const auto occurrences = nocal::occurrences_overlapping( std::span{&added, 1}, utc_time(2026y / March / 29d, 0), utc_time(2026y / March / 30d, 0)); check(occurrences.size() == 1 && occurrences[0].start == utc_time(2026y / March / 29d, 0, 30) && occurrences[0].end == utc_time(2026y / March / 29d, 1, 30), "zoned RDATE preserves a two-hour wall duration across the DST gap"); check(occurrences.size() == 1 && occurrences[0].end - occurrences[0].start == hours{1}, "zoned RDATE projects its wall-clock end through the DST transition"); } 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.recurrence_additions = {utc_time(day, 15)}; const auto invalid_rule_addition = nocal::occurrences_overlapping( std::span{&invalid, 1}, utc_time(day, 0), utc_time(2026y / July / 18d, 0)); check(invalid_rule_addition.size() == 2 && invalid_rule_addition[1].start == utc_time(day, 15), "a malformed RRULE does not suppress an independently valid RDATE"); 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_event_search(); test_calendar_visibility(); test_daily_dst_recurrence(); test_floating_dst_and_until(); test_weekly_count_and_exdates(); test_rdate_only_query_boundaries_and_duration(); test_rdate_merge_deduplication_and_exdates(); test_zoned_rdate_wall_duration(); 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; }