feat: add recurrence and time zone support
This commit is contained in:
@@ -2,8 +2,12 @@
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -79,6 +83,39 @@ nocal::Event event(std::string uid, nocal::Date start_date, unsigned start_hour,
|
||||
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;
|
||||
@@ -118,6 +155,222 @@ void test_event_queries()
|
||||
"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<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()
|
||||
@@ -125,6 +378,11 @@ 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;
|
||||
|
||||
Reference in New Issue
Block a user