feat: embedded VTIMEZONE parsing, round-trip serialization

This commit is contained in:
Bernardo Magri
2026-07-23 15:39:41 +01:00
committed by Bernardo Magri
parent 725e48569e
commit 673a2c66c9
3 changed files with 311 additions and 25 deletions

View File

@@ -697,8 +697,14 @@ void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::pat
"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");
// VTIMEZONE is now parsed and preserved for round-trip, so the calendar
// is safe to rewrite even though the custom TZID is unused by the event.
const auto vtz_result = nocal::storage::IcsStore::load(custom_zone);
require(vtz_result.safe_to_rewrite,
"VTIMEZONE definition should be safe when round-trippable");
require(vtz_result.vtimezones.size() == 1 &&
vtz_result.vtimezones[0].tzid == "Custom/Test",
"VTIMEZONE should be captured even when no event references it");
struct InvalidRdate {
std::string start;
@@ -813,6 +819,131 @@ void test_save_validation_preserves_existing_bytes(const std::filesystem::path&
require_no_temporary_files(path);
}
void test_vtimezone_custom_tzid_safe_and_round_trips(const std::filesystem::path& root) {
const auto path = root / "vtimezone-custom.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"BEGIN:VTIMEZONE\r\n"
"TZID:Custom/Example\r\n"
"BEGIN:STANDARD\r\n"
"TZOFFSETFROM:+0100\r\n"
"TZOFFSETTO:+0000\r\n"
"DTSTART:19701025T030000\r\n"
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\r\n"
"END:STANDARD\r\n"
"BEGIN:DAYLIGHT\r\n"
"TZOFFSETFROM:+0000\r\n"
"TZOFFSETTO:+0100\r\n"
"DTSTART:19700329T020000\r\n"
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\r\n"
"END:DAYLIGHT\r\n"
"END:VTIMEZONE\r\n"
"BEGIN:VEVENT\r\n"
"UID:custom-tz\r\n"
"DTSTART;TZID=Custom/Example:20260717T100000\r\n"
"DTEND;TZID=Custom/Example:20260717T110000\r\n"
"SUMMARY:Custom zone event\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite,
"VTIMEZONE-defined TZID should make the calendar safe to rewrite");
require(loaded.events.size() == 1, "VTIMEZONE calendar should contain the event");
require(loaded.events[0].time_zone == "Custom/Example",
"VTIMEZONE-defined TZID should be retained on the event");
require(loaded.events[0].time_basis == nocal::TimeBasis::zoned,
"zoned time basis should be preserved");
require(loaded.vtimezones.size() == 1,
"VTIMEZONE definition should be captured");
require(loaded.vtimezones[0].tzid == "Custom/Example",
"VTIMEZONE TZID should match");
require(!loaded.vtimezones[0].raw.empty(),
"VTIMEZONE raw content should be captured");
// Verify a VTIMEZONE-DST warning was emitted for unknown TZID.
bool has_dst_warning = false;
for (const auto& w : loaded.warnings) {
if (w.find("DST") != std::string::npos) {
has_dst_warning = true;
break;
}
}
require(has_dst_warning,
"should warn that DST handling is approximate for custom VTIMEZONE");
// Round-trip: save with VTIMEZONE preserved, then reload.
const auto saved_revision = nocal::storage::IcsStore::save(
path, loaded.events, loaded.revision, loaded.vtimezones);
require(saved_revision.existed(), "save should return an existing revision");
const std::string bytes = read_fixture(path);
require(bytes.find("BEGIN:VTIMEZONE") != std::string::npos,
"VTIMEZONE block should be serialized on save");
require(bytes.find("TZID:Custom/Example") != std::string::npos,
"VTIMEZONE TZID should appear in serialized output");
require(bytes.find("BEGIN:STANDARD") != std::string::npos,
"VTIMEZONE sub-components should be preserved");
const auto reloaded = nocal::storage::IcsStore::load(path);
require(reloaded.safe_to_rewrite, "round-tripped VTIMEZONE calendar should be safe");
require(reloaded.events.size() == 1, "round-trip event count should match");
require(reloaded.events[0].time_zone == "Custom/Example",
"round-trip TZID should be preserved");
require(reloaded.vtimezones.size() == 1,
"round-trip VTIMEZONE count should match");
require(reloaded.vtimezones[0].tzid == "Custom/Example",
"round-trip VTIMEZONE TZID should match");
require_no_temporary_files(path);
}
void test_vtimezone_multiple_tzids(const std::filesystem::path& root) {
const auto path = root / "vtimezone-multiple.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"BEGIN:VTIMEZONE\r\nTZID:Custom/A\r\nEND:VTIMEZONE\r\n"
"BEGIN:VTIMEZONE\r\nTZID:Custom/B\r\nEND:VTIMEZONE\r\n"
"BEGIN:VEVENT\r\n"
"UID:evt-a\r\n"
"DTSTART;TZID=Custom/A:20260717T100000\r\n"
"DTEND;TZID=Custom/A:20260717T110000\r\n"
"SUMMARY:Event A\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:evt-b\r\n"
"DTSTART;TZID=Custom/B:20260718T100000\r\n"
"DTEND;TZID=Custom/B:20260718T110000\r\n"
"SUMMARY:Event B\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite,
"multiple VTIMEZONE-defined TZIDs should be safe");
require(loaded.events.size() == 2, "should parse all events");
require(loaded.vtimezones.size() == 2, "should capture both VTIMEZONE definitions");
}
void test_vtimezone_undefined_tzid_still_unsafe(const std::filesystem::path& root) {
const auto path = root / "vtimezone-undefined.ics";
// Event uses Custom/Unknown which is NOT defined in any VTIMEZONE.
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VTIMEZONE\r\nTZID:Custom/Defined\r\nEND:VTIMEZONE\r\n"
"BEGIN:VEVENT\r\nUID:undef\r\n"
"DTSTART;TZID=Custom/Unknown:20260717T100000\r\n"
"DTEND;TZID=Custom/Unknown:20260717T110000\r\n"
"SUMMARY:Undefined TZID\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite,
"undefined TZID should still be unsafe even when other VTIMEZONEs exist");
require(loaded.events.size() == 1, "event should still be browseable");
}
} // namespace
int main() {
@@ -843,6 +974,9 @@ int main() {
test_supported_rdates(root);
test_unsupported_recurrence_and_zones_are_unsafe(root);
test_save_validation_preserves_existing_bytes(root);
test_vtimezone_custom_tzid_safe_and_round_trips(root);
test_vtimezone_multiple_tzids(root);
test_vtimezone_undefined_tzid_still_unsafe(root);
std::filesystem::remove_all(root, ignored);
std::cout << "ics_tests: ok\n";
return 0;