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

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