422 lines
17 KiB
C++
422 lines
17 KiB
C++
#include "nocal/domain/domain.hpp"
|
|
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
#include <iostream>
|
|
#include <optional>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
int failures = 0;
|
|
|
|
void check(bool condition, std::string_view message)
|
|
{
|
|
if (!condition) {
|
|
++failures;
|
|
std::cerr << "FAIL: " << message << '\n';
|
|
}
|
|
}
|
|
|
|
template <class Function>
|
|
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::Clock::duration>(
|
|
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<nocal::Clock::duration> local{
|
|
duration_cast<nocal::Clock::duration>(local_days{date}.time_since_epoch() +
|
|
hours{hour} + minutes{minute})};
|
|
return time_point_cast<nocal::Clock::duration>(
|
|
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<nocal::Event> 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_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<nocal::Event> 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<std::string> previous_tz =
|
|
previous_tz_value == nullptr
|
|
? std::nullopt
|
|
: std::optional<std::string>{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<nocal::Event> 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<nocal::Event> 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<nocal::Event> 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<nocal::Event> events{monthly, yearly};
|
|
|
|
const auto month_occurrences = nocal::occurrences_overlapping(
|
|
std::span<const nocal::Event>{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<const nocal::Event>{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<nocal::Event> 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<nocal::Event> 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_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;
|
|
}
|