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

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