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:
2026-07-18 10:14:53 +01:00
parent 989332ef9a
commit cfca6ab6d0
16 changed files with 498 additions and 69 deletions

View File

@@ -65,6 +65,7 @@ void check_invalid_argument(Function&& function, const std::string_view message)
recurrence.by_month_day = {1, -1};
recurrence.by_month = {1, 7};
event.recurrence = std::move(recurrence);
event.recurrence_additions = {at(20)};
event.recurrence_exceptions = {at(16), at(18)};
return event;
}
@@ -79,6 +80,7 @@ void check_invalid_argument(Function&& function, const std::string_view message)
left.time_basis == right.time_basis &&
left.time_zone == right.time_zone &&
left.recurrence == right.recurrence &&
left.recurrence_additions == right.recurrence_additions &&
left.recurrence_exceptions == right.recurrence_exceptions;
}
@@ -163,6 +165,8 @@ void test_every_event_field_participates_in_collision_equality()
{"time basis", [](auto& event) { event.time_basis = nocal::TimeBasis::utc; }},
{"time zone", [](auto& event) { event.time_zone = "America/New_York"; }},
{"recurrence", [](auto& event) { event.recurrence->interval += 1; }},
{"recurrence additions",
[](auto& event) { event.recurrence_additions.push_back(at(19)); }},
{"recurrence exclusions",
[](auto& event) { event.recurrence_exceptions.push_back(at(19)); }},
};

View File

@@ -404,6 +404,90 @@ void test_weekly_count_and_exdates()
"weekly BYDAY expansion is chronological across week boundaries");
}
void test_rdate_only_query_boundaries_and_duration()
{
using namespace std::chrono;
const nocal::Date july_10{2026y / July / 10d};
const nocal::Date july_11{2026y / July / 11d};
nocal::Event added;
added.uid = "rdate-only";
added.title = added.uid;
added.start = utc_time(2026y / July / 1d, 9);
added.end = added.start + minutes{90};
added.recurrence_additions = {utc_time(july_10, 9),
utc_time(july_11, 9)};
check(nocal::is_recurring(added),
"an RDATE-only event is part of a recurrence set");
const std::vector<nocal::Event> events{added};
const auto occurrences = nocal::occurrences_overlapping(
events, utc_time(july_10, 10), utc_time(july_11, 9));
check(occurrences.size() == 1 &&
occurrences.front().start == utc_time(july_10, 9),
"RDATE overlap includes a carrying occurrence and excludes a start at the query end");
check(occurrences.size() == 1 &&
occurrences.front().end == utc_time(july_10, 10, 30),
"UTC RDATE occurrences preserve DTSTART's elapsed duration");
}
void test_rdate_merge_deduplication_and_exdates()
{
using namespace std::chrono;
const nocal::Date july_1{2026y / July / 1d};
nocal::RecurrenceRule daily;
daily.frequency = nocal::RecurrenceFrequency::daily;
daily.count = 3U;
auto merged = utc_recurring("rdate-merge", july_1, 9, july_1, 10, daily);
merged.recurrence_additions = {
merged.start, utc_time(2026y / July / 2d, 9),
utc_time(2026y / July / 4d, 9), utc_time(2026y / July / 4d, 9)};
const auto all = nocal::occurrences_overlapping(
std::span<const nocal::Event>{&merged, 1}, utc_time(july_1, 0),
utc_time(2026y / July / 5d, 0));
check(all.size() == 4,
"DTSTART, RRULE, and RDATE form a set with duplicate starts collapsed");
check(all.size() == 4 && all[0].ordinal == 0U && all[1].ordinal == 1U &&
all[2].ordinal == 2U &&
all[3].start == utc_time(2026y / July / 4d, 9),
"RDATE merging preserves existing RRULE ordinals");
merged.recurrence_exceptions = {merged.start,
utc_time(2026y / July / 4d, 9)};
const auto filtered = nocal::occurrences_overlapping(
std::span<const nocal::Event>{&merged, 1}, utc_time(july_1, 0),
utc_time(2026y / July / 5d, 0));
check(filtered.size() == 2 &&
filtered[0].start == utc_time(2026y / July / 2d, 9) &&
filtered[1].start == utc_time(2026y / July / 3d, 9),
"EXDATE subtracts both DTSTART and RDATE-only starts from the recurrence set");
}
void test_zoned_rdate_wall_duration()
{
using namespace std::chrono;
nocal::Event added;
added.uid = "zoned-rdate";
added.title = added.uid;
added.time_basis = nocal::TimeBasis::zoned;
added.time_zone = "Europe/London";
added.start = zoned_time(added.time_zone, 2026y / March / 28d, 0, 30);
added.end = zoned_time(added.time_zone, 2026y / March / 28d, 2, 30);
added.recurrence_additions = {
zoned_time(added.time_zone, 2026y / March / 29d, 0, 30)};
const auto occurrences = nocal::occurrences_overlapping(
std::span<const nocal::Event>{&added, 1},
utc_time(2026y / March / 29d, 0), utc_time(2026y / March / 30d, 0));
check(occurrences.size() == 1 &&
occurrences[0].start == utc_time(2026y / March / 29d, 0, 30) &&
occurrences[0].end == utc_time(2026y / March / 29d, 1, 30),
"zoned RDATE preserves a two-hour wall duration across the DST gap");
check(occurrences.size() == 1 &&
occurrences[0].end - occurrences[0].start == hours{1},
"zoned RDATE projects its wall-clock end through the DST transition");
}
void test_monthly_and_yearly_selectors()
{
using namespace std::chrono;
@@ -482,6 +566,14 @@ void test_occurrence_overlap_sorting_and_invalid_rules()
occurrences[3].source->uid == "later",
"equal occurrence times sort by title and UID");
invalid.recurrence_additions = {utc_time(day, 15)};
const auto invalid_rule_addition = nocal::occurrences_overlapping(
std::span<const nocal::Event>{&invalid, 1}, utc_time(day, 0),
utc_time(2026y / July / 18d, 0));
check(invalid_rule_addition.size() == 2 &&
invalid_rule_addition[1].start == utc_time(day, 15),
"a malformed RRULE does not suppress an independently valid RDATE");
invalid.time_basis = nocal::TimeBasis::zoned;
invalid.time_zone = "Not/A_Real_Zone";
invalid.recurrence->interval = 1U;
@@ -504,6 +596,9 @@ int main()
test_daily_dst_recurrence();
test_floating_dst_and_until();
test_weekly_count_and_exdates();
test_rdate_only_query_boundaries_and_duration();
test_rdate_merge_deduplication_and_exdates();
test_zoned_rdate_wall_duration();
test_monthly_and_yearly_selectors();
test_occurrence_overlap_sorting_and_invalid_rules();
if (failures != 0) {

View File

@@ -246,6 +246,8 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap()
};
event.recurrence_exceptions.push_back(
zoned_time(event.time_zone, nocal::Date{2026y / March / 9d}, 2, 30));
event.recurrence_additions.push_back(
zoned_time(event.time_zone, nocal::Date{2026y / March / 12d}, 2, 30));
nocal::tui::EventEditor editor{event};
check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-03-07" &&
@@ -262,8 +264,9 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap()
check(editor.result().time_basis == nocal::TimeBasis::zoned &&
editor.result().time_zone == "America/New_York" &&
editor.result().recurrence == event.recurrence &&
editor.result().recurrence_additions == event.recurrence_additions &&
editor.result().recurrence_exceptions == event.recurrence_exceptions,
"editing preserves time basis, TZID, recurrence, and exceptions");
"editing preserves time basis, TZID, recurrence additions, and exceptions");
nocal::tui::EventEditor series_kind_editor{event};
focus(series_kind_editor, nocal::tui::EditorField::all_day);
@@ -273,8 +276,21 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap()
series_kind_editor.error().find("recurring series") != std::string_view::npos,
"a recurring series rejects timed/all-day conversion to preserve its exceptions");
auto rdate_only = event;
rdate_only.recurrence.reset();
nocal::tui::EventEditor rdate_editor{rdate_only};
check(rdate_editor.render(80, 18, false).find("EDIT ENTIRE RECURRING SERIES")
!= std::string::npos,
"an RDATE-only event is presented as a recurring series");
focus(rdate_editor, nocal::tui::EditorField::all_day);
(void)rdate_editor.handle_key(" ");
check(rdate_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
rdate_editor.error().find("recurring series") != std::string_view::npos,
"an RDATE-only series rejects timed/all-day conversion");
auto one_off = event;
one_off.recurrence.reset();
one_off.recurrence_additions.clear();
one_off.recurrence_exceptions.clear();
nocal::tui::EventEditor all_day_editor{one_off};
focus(all_day_editor, nocal::tui::EditorField::all_day);

View File

@@ -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);

View File

@@ -72,6 +72,7 @@ bool same_event(const nocal::Event& left, const nocal::Event& right)
left.calendar_id == right.calendar_id && left.color == right.color &&
left.time_basis == right.time_basis && left.time_zone == right.time_zone &&
left.recurrence == right.recurrence &&
left.recurrence_additions == right.recurrence_additions &&
left.recurrence_exceptions == right.recurrence_exceptions;
}
@@ -819,6 +820,36 @@ void test_recurring_occurrence_navigation_and_whole_series_crud()
"undo restores a deleted recurring series");
}
void test_rdate_only_occurrence_identity_and_whole_series_crud()
{
using namespace std::chrono;
const auto selected = nocal::today();
const auto previous = nocal::from_sys_days(nocal::to_sys_days(selected) - days{1});
auto series = make_event("rdate-only", "Extra date", previous, 9, 0,
previous, 10, 0);
series.recurrence_additions = {
nocal::make_local_time(selected, 9, 0),
};
std::vector<nocal::Event> events{series};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
check(focus_starts_with(app, "rdate-only@occ:"),
"an RDATE-only occurrence has a source-and-start focus identity");
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Edited extra-date series");
app.handle_input("\x13");
check(events.front().title == "Edited extra-date series" &&
events.front().recurrence_additions == series.recurrence_additions &&
app.state().notification == "Recurring series updated.",
"editing an RDATE-only occurrence updates and preserves the whole series");
app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("y");
check(events.empty() && app.state().notification == "Recurring series deleted.",
"deleting an RDATE-only occurrence removes its entire source series");
}
void test_recurrence_grid_expansion_and_source_zone_display()
{
using namespace std::chrono;
@@ -1241,6 +1272,7 @@ int main()
test_narrow_detail_wrapping_without_ansi();
test_multiday_segment_labels_and_series_messaging();
test_recurring_occurrence_navigation_and_whole_series_crud();
test_rdate_only_occurrence_identity_and_whole_series_crud();
test_recurrence_grid_expansion_and_source_zone_display();
test_search_prompt_results_and_occurrence_jump();
test_calendar_visibility_filters_without_partial_saves();