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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user