feat: add recurrence and time zone support
This commit is contained in:
@@ -411,6 +411,259 @@ void test_unsupported_data_is_not_rewrite_safe(const std::filesystem::path& root
|
||||
require(!loaded.warnings.empty(), "unsafe calendar did not explain why editing is disabled");
|
||||
}
|
||||
|
||||
void test_time_basis_round_trips_and_dst(const std::filesystem::path& root) {
|
||||
const auto path = root / "time-bases.ics";
|
||||
write_fixture(path,
|
||||
"BEGIN:VCALENDAR\r\n"
|
||||
"VERSION:2.0\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:floating\r\nSUMMARY:Floating\r\n"
|
||||
"DTSTART:20260717T101500\r\nDTEND:20260717T111500\r\nEND:VEVENT\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:utc-basis\r\nSUMMARY:UTC\r\n"
|
||||
"DTSTART:20260717T101500Z\r\nDTEND:20260717T111500Z\r\nEND:VEVENT\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:london\r\nSUMMARY:London\r\n"
|
||||
"DTSTART;TZID=Europe/London:20260717T101500\r\n"
|
||||
"DTEND;TZID=Europe/London:20260717T111500\r\nRRULE:FREQ=DAILY;COUNT=3\r\n"
|
||||
"EXDATE;TZID=Europe/London:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:dst\r\nSUMMARY:DST transition\r\n"
|
||||
"DTSTART;TZID=Europe/London:20260329T003000\r\n"
|
||||
"DTEND;TZID=Europe/London:20260329T023000\r\nEND:VEVENT\r\n"
|
||||
"END:VCALENDAR\r\n");
|
||||
|
||||
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||
require(loaded.safe_to_rewrite && loaded.warnings.empty(),
|
||||
"supported time bases were not rewrite-safe");
|
||||
require(loaded.events.size() == 4, "time-basis fixture event count differs");
|
||||
require(loaded.events[0].time_basis == nocal::TimeBasis::floating,
|
||||
"floating DATE-TIME lost its basis");
|
||||
require(loaded.events[1].time_basis == nocal::TimeBasis::utc,
|
||||
"UTC DATE-TIME lost its basis");
|
||||
require(loaded.events[2].time_basis == nocal::TimeBasis::zoned
|
||||
&& loaded.events[2].time_zone == "Europe/London",
|
||||
"TZID DATE-TIME lost its zone");
|
||||
require(loaded.events[2].start == sys_days{year{2026}/July/17} + 9h + 15min,
|
||||
"Europe/London summer offset was not applied");
|
||||
require(loaded.events[2].recurrence_exceptions
|
||||
== std::vector<nocal::TimePoint>{
|
||||
sys_days{year{2026}/July/18} + 9h + 15min,
|
||||
sys_days{year{2026}/July/19} + 9h + 15min},
|
||||
"zoned EXDATE values were not converted with TZID");
|
||||
require(loaded.events[3].end - loaded.events[3].start == 1h,
|
||||
"Europe/London DST transition was not converted correctly");
|
||||
|
||||
const auto round_trip = root / "time-bases-round-trip.ics";
|
||||
nocal::storage::IcsStore::save(round_trip, loaded.events);
|
||||
const std::string serialized = read_fixture(round_trip);
|
||||
require(serialized.find("DTSTART:20260717T101500\r\n") != std::string::npos,
|
||||
"floating DTSTART was not serialized without a suffix");
|
||||
require(serialized.find("DTSTART:20260717T101500Z\r\n") != std::string::npos,
|
||||
"UTC DTSTART was not serialized with Z");
|
||||
require(serialized.find("DTSTART;TZID=Europe/London:20260717T101500\r\n")
|
||||
!= std::string::npos,
|
||||
"zoned DTSTART was not serialized with TZID and local fields");
|
||||
require(serialized.find(
|
||||
"EXDATE;TZID=Europe/London:20260718T101500,20260719T101500")
|
||||
!= std::string::npos,
|
||||
"zoned EXDATE values were not grouped with TZID");
|
||||
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
|
||||
require(reloaded.safe_to_rewrite && reloaded.events.size() == loaded.events.size(),
|
||||
"time-basis output did not reload safely");
|
||||
for (std::size_t index = 0; index < loaded.events.size(); ++index) {
|
||||
require(reloaded.events[index].start == loaded.events[index].start
|
||||
&& reloaded.events[index].end == loaded.events[index].end
|
||||
&& reloaded.events[index].time_basis == loaded.events[index].time_basis
|
||||
&& reloaded.events[index].time_zone == loaded.events[index].time_zone
|
||||
&& reloaded.events[index].recurrence == loaded.events[index].recurrence
|
||||
&& reloaded.events[index].recurrence_exceptions
|
||||
== loaded.events[index].recurrence_exceptions,
|
||||
"time-basis semantic round trip differs");
|
||||
}
|
||||
}
|
||||
|
||||
void test_supported_recurrence_and_exdates(const std::filesystem::path& root) {
|
||||
const auto path = root / "recurrence.ics";
|
||||
write_fixture(path,
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:daily\r\nDTSTART:20260701T090000Z\r\n"
|
||||
"DTEND:20260701T100000Z\r\nRRULE:FREQ=DAILY;INTERVAL=2;COUNT=5\r\n"
|
||||
"EXDATE:20260703T090000Z,20260705T090000Z\r\n"
|
||||
"EXDATE:20260707T090000Z\r\nEND:VEVENT\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:weekly\r\nDTSTART:20260701T090000\r\n"
|
||||
"DTEND:20260701T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;"
|
||||
"UNTIL=20260801T090000\r\nEND:VEVENT\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:monthly\r\nDTSTART:20260701T090000Z\r\n"
|
||||
"DTEND:20260701T100000Z\r\nRRULE:FREQ=MONTHLY;BYMONTHDAY=1,-1\r\n"
|
||||
"END:VEVENT\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:yearly\r\nDTSTART;VALUE=DATE:20260701\r\n"
|
||||
"DTEND;VALUE=DATE:20260702\r\nRRULE:FREQ=YEARLY;BYMONTH=1,7;"
|
||||
"UNTIL=20290701\r\nEXDATE;VALUE=DATE:20270701,20280701\r\nEND:VEVENT\r\n"
|
||||
"END:VCALENDAR\r\n");
|
||||
|
||||
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||
require(loaded.safe_to_rewrite && loaded.warnings.empty(),
|
||||
"supported recurrence was not rewrite-safe");
|
||||
require(loaded.events.size() == 4, "recurrence fixture event count differs");
|
||||
const auto& daily = *loaded.events[0].recurrence;
|
||||
require(daily.frequency == nocal::RecurrenceFrequency::daily
|
||||
&& daily.interval == 2 && daily.count == 5 && !daily.until,
|
||||
"daily RRULE fields differ");
|
||||
require(loaded.events[0].recurrence_exceptions.size() == 3,
|
||||
"multiple/comma-separated EXDATE values were not collected");
|
||||
const auto& weekly = *loaded.events[1].recurrence;
|
||||
require(weekly.frequency == nocal::RecurrenceFrequency::weekly
|
||||
&& weekly.by_weekday == std::vector<weekday>{Monday, Wednesday, Friday}
|
||||
&& weekly.until.has_value(),
|
||||
"weekly RRULE fields differ");
|
||||
const auto& monthly = *loaded.events[2].recurrence;
|
||||
require(monthly.frequency == nocal::RecurrenceFrequency::monthly
|
||||
&& monthly.by_month_day == std::vector<int>{1, -1},
|
||||
"monthly RRULE fields differ");
|
||||
const auto& yearly = *loaded.events[3].recurrence;
|
||||
require(yearly.frequency == nocal::RecurrenceFrequency::yearly
|
||||
&& yearly.by_month == std::vector<unsigned>{1, 7} && yearly.until,
|
||||
"yearly RRULE fields differ");
|
||||
require(loaded.events[3].recurrence_exceptions.size() == 2,
|
||||
"all-day EXDATE values differ");
|
||||
|
||||
const auto round_trip = root / "recurrence-round-trip.ics";
|
||||
nocal::storage::IcsStore::save(round_trip, loaded.events);
|
||||
const std::string serialized = read_fixture(round_trip);
|
||||
require(serialized.find("RRULE:FREQ=DAILY;INTERVAL=2;COUNT=5") != std::string::npos,
|
||||
"daily RRULE was not serialized");
|
||||
require(serialized.find("EXDATE:20260703T090000Z,20260705T090000Z,20260707T090000Z")
|
||||
!= std::string::npos,
|
||||
"timed EXDATE values were not grouped");
|
||||
require(serialized.find("EXDATE;VALUE=DATE:20270701,20280701") != std::string::npos,
|
||||
"all-day EXDATE values were not grouped with VALUE=DATE");
|
||||
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
|
||||
require(reloaded.safe_to_rewrite && reloaded.warnings.empty(),
|
||||
"serialized recurrence did not reload safely");
|
||||
require(reloaded.events.size() == loaded.events.size(),
|
||||
"recurrence semantic round-trip event count differs");
|
||||
for (std::size_t index = 0; index < loaded.events.size(); ++index) {
|
||||
require(reloaded.events[index].recurrence == loaded.events[index].recurrence
|
||||
&& reloaded.events[index].recurrence_exceptions
|
||||
== loaded.events[index].recurrence_exceptions,
|
||||
"recurrence semantic round trip differs");
|
||||
}
|
||||
}
|
||||
|
||||
void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) {
|
||||
const std::vector<std::string> unsupported_properties{
|
||||
"RRULE:FREQ=WEEKLY;BYDAY=1MO",
|
||||
"RRULE:FREQ=DAILY;BYDAY=MO",
|
||||
"RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z",
|
||||
"RRULE:FREQ=HOURLY",
|
||||
"RRULE:FREQ=DAILY;WKST=MO",
|
||||
"RDATE:20260718T090000Z",
|
||||
"RECURRENCE-ID:20260718T090000Z"};
|
||||
for (std::size_t index = 0; index < unsupported_properties.size(); ++index) {
|
||||
const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics");
|
||||
write_fixture(path,
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:unsupported\r\n"
|
||||
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
|
||||
+ unsupported_properties[index] + "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||
require(!loaded.safe_to_rewrite && !loaded.warnings.empty(),
|
||||
"unsupported recurrence feature was considered rewrite-safe");
|
||||
require(loaded.events.size() == 1,
|
||||
"unsupported recurrence prevented base-event browseability");
|
||||
}
|
||||
|
||||
const auto multiple = root / "multiple-rrule.ics";
|
||||
write_fixture(multiple,
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:multiple\r\n"
|
||||
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
|
||||
"RRULE:FREQ=DAILY\r\nRRULE:FREQ=WEEKLY\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
require(!nocal::storage::IcsStore::load(multiple).safe_to_rewrite,
|
||||
"multiple RRULE properties were considered rewrite-safe");
|
||||
|
||||
const auto unknown_zone = root / "unknown-zone.ics";
|
||||
write_fixture(unknown_zone,
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:unknown-zone\r\n"
|
||||
"DTSTART;TZID=Custom/Mars:20260717T090000\r\n"
|
||||
"DTEND;TZID=Custom/Mars:20260717T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
const auto unknown = nocal::storage::IcsStore::load(unknown_zone);
|
||||
require(!unknown.safe_to_rewrite && unknown.events.size() == 1,
|
||||
"unknown TZID was not read-only and browseable");
|
||||
|
||||
const auto custom_zone = root / "vtimezone.ics";
|
||||
write_fixture(custom_zone,
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTIMEZONE\r\nTZID:Custom/Test\r\n"
|
||||
"END:VTIMEZONE\r\nBEGIN:VEVENT\r\nUID:custom\r\n"
|
||||
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
|
||||
"END:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
require(!nocal::storage::IcsStore::load(custom_zone).safe_to_rewrite,
|
||||
"VTIMEZONE definition was considered rewrite-safe");
|
||||
|
||||
const std::vector<std::pair<std::string, std::string>> mismatched_ends{
|
||||
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000"},
|
||||
{"DTSTART;TZID=Europe/London:20260717T090000",
|
||||
"DTEND;TZID=Europe/Paris:20260717T100000"},
|
||||
{"DTSTART;VALUE=DATE:20260717", "DTEND:20260718T100000Z"}};
|
||||
for (std::size_t index = 0; index < mismatched_ends.size(); ++index) {
|
||||
const auto path = root / ("mismatched-time-" + std::to_string(index) + ".ics");
|
||||
write_fixture(path,
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:mismatch\r\n"
|
||||
+ mismatched_ends[index].first + "\r\n" + mismatched_ends[index].second
|
||||
+ "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||
require(!loaded.safe_to_rewrite && loaded.events.empty(),
|
||||
"mismatched DTSTART/DTEND kinds were accepted");
|
||||
}
|
||||
}
|
||||
|
||||
void test_save_validation_preserves_existing_bytes(const std::filesystem::path& root) {
|
||||
const auto path = root / "validation-preserves.ics";
|
||||
const std::string original = "existing bytes must survive validation";
|
||||
const std::string original_backup = "existing backup must survive validation";
|
||||
write_fixture(path, original);
|
||||
const auto backup = nocal::storage::IcsStore::backup_path(path);
|
||||
write_fixture(backup, original_backup);
|
||||
|
||||
nocal::Event invalid_zone = event_named("zone@example.test", "Invalid zone");
|
||||
invalid_zone.time_basis = nocal::TimeBasis::zoned;
|
||||
invalid_zone.time_zone = "Custom/Unknown";
|
||||
bool zone_failed = false;
|
||||
try {
|
||||
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{invalid_zone});
|
||||
} catch (const std::invalid_argument&) {
|
||||
zone_failed = true;
|
||||
}
|
||||
require(zone_failed && read_fixture(path) == original,
|
||||
"invalid TZID validation changed existing bytes");
|
||||
require(read_fixture(backup) == original_backup,
|
||||
"invalid TZID validation changed existing backup bytes");
|
||||
|
||||
nocal::Event invalid_rule = event_named("rule@example.test", "Invalid rule");
|
||||
nocal::RecurrenceRule rule;
|
||||
rule.frequency = nocal::RecurrenceFrequency::daily;
|
||||
rule.by_weekday.push_back(Monday);
|
||||
invalid_rule.recurrence = rule;
|
||||
bool rule_failed = false;
|
||||
try {
|
||||
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{invalid_rule});
|
||||
} catch (const std::invalid_argument&) {
|
||||
rule_failed = true;
|
||||
}
|
||||
require(rule_failed && read_fixture(path) == original,
|
||||
"invalid RRULE validation changed existing bytes");
|
||||
require(read_fixture(backup) == original_backup,
|
||||
"invalid RRULE validation changed existing backup bytes");
|
||||
|
||||
nocal::Event lone_exception = event_named("exdate@example.test", "Lone exception");
|
||||
lone_exception.recurrence_exceptions.push_back(lone_exception.start + 24h);
|
||||
bool exdate_failed = false;
|
||||
try {
|
||||
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{lone_exception});
|
||||
} catch (const std::invalid_argument&) {
|
||||
exdate_failed = true;
|
||||
}
|
||||
require(exdate_failed && read_fixture(path) == original
|
||||
&& read_fixture(backup) == original_backup,
|
||||
"EXDATE-without-RRULE validation changed calendar or backup bytes");
|
||||
require_no_temporary_files(path);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
@@ -436,6 +689,10 @@ int main() {
|
||||
test_lock_failure_preserves_existing_bytes(root);
|
||||
test_rename_failure_cleans_temporary_file(root);
|
||||
test_unsupported_data_is_not_rewrite_safe(root);
|
||||
test_time_basis_round_trips_and_dst(root);
|
||||
test_supported_recurrence_and_exdates(root);
|
||||
test_unsupported_recurrence_and_zones_are_unsafe(root);
|
||||
test_save_validation_preserves_existing_bytes(root);
|
||||
std::filesystem::remove_all(root, ignored);
|
||||
std::cout << "ics_tests: ok\n";
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user