feat: support safe recurrence additions
Parse, persist, expand, and round-trip compatible RDATE values while preserving whole-series TUI behavior and conservative rewrite guards. Verified: normal, warning-as-error, and ASan/UBSan builds pass all 9 suites; storage failure paths preserve calendar and backup bytes; live PTY restores terminal state.
This commit is contained in:
@@ -473,6 +473,8 @@ void test_time_basis_round_trips_and_dst(const std::filesystem::path& root) {
|
||||
&& 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_additions
|
||||
== loaded.events[index].recurrence_additions
|
||||
&& reloaded.events[index].recurrence_exceptions
|
||||
== loaded.events[index].recurrence_exceptions,
|
||||
"time-basis semantic round trip differs");
|
||||
@@ -541,12 +543,115 @@ void test_supported_recurrence_and_exdates(const std::filesystem::path& root) {
|
||||
"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_additions
|
||||
== loaded.events[index].recurrence_additions
|
||||
&& reloaded.events[index].recurrence_exceptions
|
||||
== loaded.events[index].recurrence_exceptions,
|
||||
"recurrence semantic round trip differs");
|
||||
}
|
||||
}
|
||||
|
||||
void test_supported_rdates(const std::filesystem::path& root) {
|
||||
const auto path = root / "rdates.ics";
|
||||
write_fixture(path,
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:utc-rdate\r\nSUMMARY:UTC additions\r\n"
|
||||
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
|
||||
"RDATE:20260718T090000Z,20260720T090000Z\r\n"
|
||||
"RDATE:20260722T090000Z\r\nEXDATE:20260720T090000Z\r\nEND:VEVENT\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:floating-rdate\r\nSUMMARY:Floating additions\r\n"
|
||||
"DTSTART:20260717T101500\r\nDTEND:20260717T111500\r\n"
|
||||
"RDATE:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:zoned-rdate\r\nSUMMARY:Zoned additions\r\n"
|
||||
"DTSTART;TZID=Europe/London:20260717T101500\r\n"
|
||||
"DTEND;TZID=Europe/London:20260717T111500\r\n"
|
||||
"RDATE;TZID=Europe/London:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:all-day-rdate\r\nSUMMARY:All-day additions\r\n"
|
||||
"DTSTART;VALUE=DATE:20260717\r\nDTEND;VALUE=DATE:20260718\r\n"
|
||||
"RDATE;VALUE=DATE:20260719,20260721\r\nEND:VEVENT\r\n"
|
||||
"BEGIN:VEVENT\r\nUID:mixed-rdate\r\nSUMMARY:Rule and additions\r\n"
|
||||
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
|
||||
"RRULE:FREQ=DAILY;COUNT=3\r\nRDATE:20260725T090000Z,20260727T090000Z\r\n"
|
||||
"EXDATE:20260718T090000Z,20260725T090000Z\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 RDATE values were not rewrite-safe");
|
||||
require(loaded.events.size() == 5, "RDATE fixture event count differs");
|
||||
require(!loaded.events[0].recurrence.has_value(),
|
||||
"RDATE-only event acquired an RRULE");
|
||||
require(loaded.events[0].recurrence_additions
|
||||
== std::vector<nocal::TimePoint>{
|
||||
sys_days{year{2026}/July/18} + 9h,
|
||||
sys_days{year{2026}/July/20} + 9h,
|
||||
sys_days{year{2026}/July/22} + 9h},
|
||||
"multiple or comma-separated UTC RDATE values were not collected");
|
||||
require(loaded.events[0].recurrence_exceptions
|
||||
== std::vector<nocal::TimePoint>{sys_days{year{2026}/July/20} + 9h},
|
||||
"RDATE-only EXDATE value was not retained");
|
||||
require(loaded.events[1].time_basis == nocal::TimeBasis::floating
|
||||
&& loaded.events[1].recurrence_additions
|
||||
== std::vector<nocal::TimePoint>{
|
||||
sys_days{year{2026}/July/18} + 10h + 15min,
|
||||
sys_days{year{2026}/July/19} + 10h + 15min},
|
||||
"floating RDATE values did not preserve their time basis");
|
||||
require(loaded.events[2].time_basis == nocal::TimeBasis::zoned
|
||||
&& loaded.events[2].time_zone == "Europe/London"
|
||||
&& loaded.events[2].recurrence_additions
|
||||
== std::vector<nocal::TimePoint>{
|
||||
sys_days{year{2026}/July/18} + 9h + 15min,
|
||||
sys_days{year{2026}/July/19} + 9h + 15min},
|
||||
"zoned RDATE values were not converted with DTSTART's TZID");
|
||||
require(loaded.events[3].all_day
|
||||
&& loaded.events[3].recurrence_additions
|
||||
== std::vector<nocal::TimePoint>{
|
||||
sys_days{year{2026}/July/19}, sys_days{year{2026}/July/21}},
|
||||
"all-day RDATE values were not parsed as DATE values");
|
||||
require(loaded.events[4].recurrence.has_value()
|
||||
&& loaded.events[4].recurrence_additions
|
||||
== std::vector<nocal::TimePoint>{
|
||||
sys_days{year{2026}/July/25} + 9h,
|
||||
sys_days{year{2026}/July/27} + 9h}
|
||||
&& loaded.events[4].recurrence_exceptions
|
||||
== std::vector<nocal::TimePoint>{
|
||||
sys_days{year{2026}/July/18} + 9h,
|
||||
sys_days{year{2026}/July/25} + 9h},
|
||||
"mixed RRULE, RDATE, and EXDATE values differ");
|
||||
|
||||
const auto round_trip = root / "rdates-round-trip.ics";
|
||||
nocal::storage::IcsStore::save(round_trip, loaded.events);
|
||||
const std::string serialized = read_fixture(round_trip);
|
||||
require(serialized.find(
|
||||
"RDATE:20260718T090000Z,20260720T090000Z,20260722T090000Z")
|
||||
!= std::string::npos,
|
||||
"UTC RDATE values were not grouped on serialization");
|
||||
require(serialized.find("RDATE:20260718T101500,20260719T101500")
|
||||
!= std::string::npos,
|
||||
"floating RDATE values were not serialized without a suffix");
|
||||
require(serialized.find(
|
||||
"RDATE;TZID=Europe/London:20260718T101500,20260719T101500")
|
||||
!= std::string::npos,
|
||||
"zoned RDATE values were not serialized with TZID");
|
||||
require(serialized.find("RDATE;VALUE=DATE:20260719,20260721")
|
||||
!= std::string::npos,
|
||||
"all-day RDATE values were not serialized with VALUE=DATE");
|
||||
|
||||
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
|
||||
require(reloaded.safe_to_rewrite && reloaded.warnings.empty(),
|
||||
"serialized RDATE calendar did not reload safely");
|
||||
require(reloaded.events.size() == loaded.events.size(),
|
||||
"RDATE 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_additions
|
||||
== loaded.events[index].recurrence_additions
|
||||
&& reloaded.events[index].recurrence_exceptions
|
||||
== loaded.events[index].recurrence_exceptions,
|
||||
"RDATE 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",
|
||||
@@ -554,7 +659,7 @@ void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::pat
|
||||
"RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z",
|
||||
"RRULE:FREQ=HOURLY",
|
||||
"RRULE:FREQ=DAILY;WKST=MO",
|
||||
"RDATE:20260718T090000Z",
|
||||
"RDATE;VALUE=PERIOD:20260718T090000Z/PT1H",
|
||||
"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");
|
||||
@@ -595,6 +700,37 @@ void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::pat
|
||||
require(!nocal::storage::IcsStore::load(custom_zone).safe_to_rewrite,
|
||||
"VTIMEZONE definition was considered rewrite-safe");
|
||||
|
||||
struct InvalidRdate {
|
||||
std::string start;
|
||||
std::string end;
|
||||
std::string property;
|
||||
};
|
||||
const std::vector<InvalidRdate> invalid_rdates{
|
||||
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z",
|
||||
"RDATE:20260718T090000"},
|
||||
{"DTSTART;TZID=Europe/London:20260717T090000",
|
||||
"DTEND;TZID=Europe/London:20260717T100000",
|
||||
"RDATE;TZID=Europe/Paris:20260718T090000"},
|
||||
{"DTSTART;VALUE=DATE:20260717", "DTEND;VALUE=DATE:20260718",
|
||||
"RDATE:20260719T090000Z"},
|
||||
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z",
|
||||
"RDATE;TZID=Custom/Mars:20260718T090000"},
|
||||
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z",
|
||||
"RDATE:not-a-date"}};
|
||||
for (std::size_t index = 0; index < invalid_rdates.size(); ++index) {
|
||||
const auto path = root / ("invalid-rdate-" + std::to_string(index) + ".ics");
|
||||
const auto& fixture = invalid_rdates[index];
|
||||
write_fixture(path,
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:invalid-rdate\r\n"
|
||||
+ fixture.start + "\r\n" + fixture.end + "\r\n" + fixture.property
|
||||
+ "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||
require(!loaded.safe_to_rewrite && !loaded.warnings.empty(),
|
||||
"invalid RDATE was considered rewrite-safe");
|
||||
require(loaded.events.size() == 1 && loaded.events[0].recurrence_additions.empty(),
|
||||
"invalid RDATE prevented base-event browsing or became an occurrence");
|
||||
}
|
||||
|
||||
const std::vector<std::pair<std::string, std::string>> mismatched_ends{
|
||||
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000"},
|
||||
{"DTSTART;TZID=Europe/London:20260717T090000",
|
||||
@@ -660,7 +796,20 @@ void test_save_validation_preserves_existing_bytes(const std::filesystem::path&
|
||||
}
|
||||
require(exdate_failed && read_fixture(path) == original
|
||||
&& read_fixture(backup) == original_backup,
|
||||
"EXDATE-without-RRULE validation changed calendar or backup bytes");
|
||||
"EXDATE-without-RRULE-or-RDATE validation changed calendar or backup bytes");
|
||||
|
||||
nocal::Event duplicate_rdate = event_named("rdate@example.test", "Duplicate RDATE");
|
||||
duplicate_rdate.recurrence_additions = {
|
||||
duplicate_rdate.start + 24h, duplicate_rdate.start + 24h};
|
||||
bool rdate_failed = false;
|
||||
try {
|
||||
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{duplicate_rdate});
|
||||
} catch (const std::invalid_argument&) {
|
||||
rdate_failed = true;
|
||||
}
|
||||
require(rdate_failed && read_fixture(path) == original
|
||||
&& read_fixture(backup) == original_backup,
|
||||
"duplicate RDATE validation changed calendar or backup bytes");
|
||||
require_no_temporary_files(path);
|
||||
}
|
||||
|
||||
@@ -691,6 +840,7 @@ int main() {
|
||||
test_unsupported_data_is_not_rewrite_safe(root);
|
||||
test_time_basis_round_trips_and_dst(root);
|
||||
test_supported_recurrence_and_exdates(root);
|
||||
test_supported_rdates(root);
|
||||
test_unsupported_recurrence_and_zones_are_unsafe(root);
|
||||
test_save_validation_preserves_existing_bytes(root);
|
||||
std::filesystem::remove_all(root, ignored);
|
||||
|
||||
Reference in New Issue
Block a user