feat: add recurrence and time zone support

This commit is contained in:
2026-07-17 21:52:02 +01:00
parent 22c6399056
commit c19098004e
18 changed files with 2321 additions and 132 deletions

View File

@@ -2,8 +2,12 @@
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
@@ -79,6 +83,39 @@ nocal::Event event(std::string uid, nocal::Date start_date, unsigned start_hour,
return result;
}
nocal::TimePoint utc_time(nocal::Date date, unsigned hour, unsigned minute = 0)
{
using namespace std::chrono;
return nocal::TimePoint{duration_cast<nocal::Clock::duration>(
nocal::to_sys_days(date).time_since_epoch() + hours{hour} +
minutes{minute})};
}
nocal::TimePoint zoned_time(std::string_view zone_name, nocal::Date date,
unsigned hour, unsigned minute = 0)
{
using namespace std::chrono;
const auto* zone = locate_zone(zone_name);
const local_time<nocal::Clock::duration> local{
duration_cast<nocal::Clock::duration>(local_days{date}.time_since_epoch() +
hours{hour} + minutes{minute})};
return time_point_cast<nocal::Clock::duration>(
zone->to_sys(local, choose::earliest));
}
nocal::Event utc_recurring(std::string uid, nocal::Date start_date,
unsigned start_hour, nocal::Date end_date,
unsigned end_hour, nocal::RecurrenceRule rule)
{
nocal::Event result;
result.uid = std::move(uid);
result.title = result.uid;
result.start = utc_time(start_date, start_hour);
result.end = utc_time(end_date, end_hour);
result.recurrence = std::move(rule);
return result;
}
void test_event_queries()
{
using namespace std::chrono;
@@ -118,6 +155,222 @@ void test_event_queries()
"invalid backwards event never overlaps");
}
void test_daily_dst_recurrence()
{
using namespace std::chrono;
const nocal::Date march_28{2026y / March / 28d};
const nocal::Date march_29{2026y / March / 29d};
const nocal::Date march_30{2026y / March / 30d};
nocal::Event event;
event.uid = "london-daily";
event.title = event.uid;
event.time_basis = nocal::TimeBasis::zoned;
event.time_zone = "Europe/London";
event.start = zoned_time(event.time_zone, march_28, 9);
event.end = zoned_time(event.time_zone, march_28, 10);
nocal::RecurrenceRule rule;
rule.frequency = nocal::RecurrenceFrequency::daily;
rule.count = 3U;
event.recurrence = rule;
const std::vector<nocal::Event> events{event};
const auto occurrences = nocal::occurrences_overlapping(
events, utc_time(march_28, 0), utc_time(2026y / March / 31d, 0));
check(occurrences.size() == 3,
"daily recurrence includes DTSTART and honors COUNT");
check(occurrences.size() == 3 &&
occurrences[0].start == utc_time(march_28, 9) &&
occurrences[1].start == utc_time(march_29, 8) &&
occurrences[2].start == utc_time(march_30, 8),
"zoned daily recurrence preserves 09:00 wall time across DST");
check(occurrences.size() == 3 &&
occurrences[1].end - occurrences[1].start == hours{1},
"zoned recurrence preserves the wall-clock end relationship");
}
void test_floating_dst_and_until()
{
using namespace std::chrono;
const char* previous_tz_value = std::getenv("TZ");
const std::optional<std::string> previous_tz =
previous_tz_value == nullptr
? std::nullopt
: std::optional<std::string>{previous_tz_value};
std::string london_zone_file = "/usr/share/zoneinfo/Europe/London";
if (!std::filesystem::exists(london_zone_file)) {
const auto local_zone_file = std::filesystem::canonical("/etc/localtime");
if (local_zone_file.string().ends_with("/Europe/London")) {
london_zone_file = local_zone_file.string();
}
}
const std::string london_tz = ':' + london_zone_file;
if (!std::filesystem::exists(london_zone_file) ||
::setenv("TZ", london_tz.c_str(), 1) != 0) {
check(false, "test can select the process local time zone");
return;
}
::tzset();
const nocal::Date march_28{2026y / March / 28d};
nocal::Event floating;
floating.uid = "floating-daily";
floating.title = floating.uid;
floating.time_basis = nocal::TimeBasis::floating;
floating.start = nocal::make_local_time(march_28, 9);
floating.end = nocal::make_local_time(march_28, 10);
nocal::RecurrenceRule daily;
daily.frequency = nocal::RecurrenceFrequency::daily;
daily.count = 3U;
floating.recurrence = daily;
auto until = utc_recurring("until", 2026y / July / 1d, 9,
2026y / July / 1d, 10,
nocal::RecurrenceRule{});
until.recurrence->until = utc_time(2026y / July / 3d, 9);
const std::vector<nocal::Event> floating_events{floating};
const auto floating_occurrences = nocal::occurrences_overlapping(
floating_events, utc_time(march_28, 0),
utc_time(2026y / March / 31d, 0));
check(floating_occurrences.size() == 3 &&
floating_occurrences[0].start == utc_time(march_28, 9) &&
floating_occurrences[1].start ==
utc_time(2026y / March / 29d, 8) &&
floating_occurrences[2].start ==
utc_time(2026y / March / 30d, 8),
"floating recurrence follows the selected process-local DST rules");
const std::vector<nocal::Event> until_events{until};
const auto until_occurrences = nocal::occurrences_overlapping(
until_events, utc_time(2026y / July / 1d, 0),
utc_time(2026y / July / 10d, 0));
check(until_occurrences.size() == 3 &&
until_occurrences.back().start == until.recurrence->until,
"UNTIL is inclusive and bounds uncounted recurrence expansion");
if (previous_tz) {
(void)::setenv("TZ", previous_tz->c_str(), 1);
} else {
(void)::unsetenv("TZ");
}
::tzset();
}
void test_weekly_count_and_exdates()
{
using namespace std::chrono;
const nocal::Date monday{2026y / July / 6d};
nocal::RecurrenceRule rule;
rule.frequency = nocal::RecurrenceFrequency::weekly;
rule.count = 6U;
rule.by_weekday = {Monday, Wednesday, Friday};
auto weekly = utc_recurring("weekly", monday, 9, monday, 10, rule);
weekly.recurrence_exceptions = {weekly.start,
utc_time(2026y / July / 10d, 9)};
const std::vector<nocal::Event> events{weekly};
const auto occurrences = nocal::occurrences_overlapping(
events, utc_time(monday, 0), utc_time(2026y / July / 20d, 0));
check(occurrences.size() == 4,
"EXDATE removes DTSTART and another generated weekly start");
check(occurrences.size() == 4 && occurrences[0].ordinal == 1 &&
occurrences[1].ordinal == 3 && occurrences[2].ordinal == 4 &&
occurrences[3].ordinal == 5,
"weekly ordinals and COUNT include excluded DTSTART occurrences");
check(occurrences.size() == 4 &&
occurrences[0].start == utc_time(2026y / July / 8d, 9) &&
occurrences[1].start == utc_time(2026y / July / 13d, 9),
"weekly BYDAY expansion is chronological across week boundaries");
}
void test_monthly_and_yearly_selectors()
{
using namespace std::chrono;
nocal::RecurrenceRule monthly_rule;
monthly_rule.frequency = nocal::RecurrenceFrequency::monthly;
monthly_rule.count = 4U;
monthly_rule.by_month_day = {-1};
auto monthly = utc_recurring("month-end", 2027y / January / 31d, 9,
2027y / January / 31d, 10, monthly_rule);
nocal::RecurrenceRule yearly_rule;
yearly_rule.frequency = nocal::RecurrenceFrequency::yearly;
yearly_rule.count = 4U;
yearly_rule.by_month = {1U, 6U};
auto yearly = utc_recurring("selected-months", 2024y / June / 15d, 9,
2024y / June / 15d, 10, yearly_rule);
const std::vector<nocal::Event> events{monthly, yearly};
const auto month_occurrences = nocal::occurrences_overlapping(
std::span<const nocal::Event>{events}.first(1),
utc_time(2027y / January / 1d, 0), utc_time(2027y / May / 1d, 0));
check(month_occurrences.size() == 4,
"negative BYMONTHDAY expands to each month's final day");
check(month_occurrences.size() == 4 &&
month_occurrences[1].start == utc_time(2027y / February / 28d, 9) &&
month_occurrences[2].start == utc_time(2027y / March / 31d, 9) &&
month_occurrences[3].start == utc_time(2027y / April / 30d, 9),
"monthly negative selectors account for varying month lengths");
const auto year_occurrences = nocal::occurrences_overlapping(
std::span<const nocal::Event>{events}.subspan(1),
utc_time(2024y / January / 1d, 0), utc_time(2026y / February / 1d, 0));
check(year_occurrences.size() == 4,
"yearly BYMONTH count includes DTSTART");
check(year_occurrences.size() == 4 &&
year_occurrences[1].start == utc_time(2025y / January / 15d, 9) &&
year_occurrences[2].start == utc_time(2025y / June / 15d, 9) &&
year_occurrences[3].start == utc_time(2026y / January / 15d, 9),
"yearly BYMONTH reuses DTSTART's day and wall time");
}
void test_occurrence_overlap_sorting_and_invalid_rules()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::RecurrenceRule daily;
daily.frequency = nocal::RecurrenceFrequency::daily;
daily.count = 3U;
auto overnight = utc_recurring("overnight", 2026y / July / 16d, 23, day, 2,
daily);
nocal::RecurrenceRule once;
once.frequency = nocal::RecurrenceFrequency::daily;
once.count = 1U;
auto later = utc_recurring("later", day, 12, day, 13, once);
auto all_day = utc_recurring("all-day", day, 0, 2026y / July / 18d, 0,
once);
all_day.all_day = true;
auto invalid = later;
invalid.uid = "invalid";
invalid.title = invalid.uid;
invalid.recurrence->interval = 0U;
std::vector<nocal::Event> events{later, overnight, invalid, all_day};
const auto occurrences = nocal::occurrences_overlapping(
events, utc_time(day, 0), utc_time(2026y / July / 18d, 0));
check(occurrences.size() == 5,
"occurrence overlap includes prior multi-day starts and invalid-rule base");
check(occurrences.size() == 5 && occurrences[0].source->uid == "all-day" &&
occurrences[1].source->uid == "overnight" &&
occurrences[1].ordinal == 0 &&
occurrences[4].source->uid == "overnight" &&
occurrences[4].ordinal == 1,
"occurrences sort all-day first, then by generated start");
check(occurrences.size() == 5 &&
occurrences[2].source->uid == "invalid" &&
occurrences[3].source->uid == "later",
"equal occurrence times sort by title and UID");
invalid.time_basis = nocal::TimeBasis::zoned;
invalid.time_zone = "Not/A_Real_Zone";
invalid.recurrence->interval = 1U;
const std::vector<nocal::Event> bad_zone{invalid};
const auto retained = nocal::occurrences_overlapping(
bad_zone, utc_time(day, 0), utc_time(2026y / July / 18d, 0));
check(retained.size() == 1 && retained[0].ordinal == 0,
"unrepresentable recurrence metadata safely retains DTSTART");
}
} // namespace
int main()
@@ -125,6 +378,11 @@ int main()
test_dates();
test_month_layout();
test_event_queries();
test_daily_dst_recurrence();
test_floating_dst_and_until();
test_weekly_count_and_exdates();
test_monthly_and_yearly_selectors();
test_occurrence_overlap_sorting_and_invalid_rules();
if (failures != 0) {
std::cerr << failures << " domain test(s) failed\n";
return EXIT_FAILURE;

View File

@@ -7,6 +7,7 @@
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
namespace {
@@ -215,6 +216,83 @@ void test_rendering()
check(exact_width, "wide Unicode input retains exact terminal-cell width");
}
nocal::TimePoint zoned_time(const std::string_view zone_name, const nocal::Date date,
const unsigned hour, const unsigned minute)
{
const auto* zone = std::chrono::locate_zone(zone_name);
const auto local = std::chrono::local_days{date} + std::chrono::hours{hour} +
std::chrono::minutes{minute};
return zone->to_sys(local, std::chrono::choose::earliest);
}
void test_zoned_edit_preserves_series_and_rejects_dst_gap()
{
using namespace std::chrono;
nocal::Event event;
event.uid = "zoned-series";
event.title = "New York standup";
event.time_basis = nocal::TimeBasis::zoned;
event.time_zone = "America/New_York";
event.start = zoned_time(event.time_zone, nocal::Date{2026y / March / 7d}, 2, 30);
event.end = zoned_time(event.time_zone, nocal::Date{2026y / March / 7d}, 3, 30);
event.recurrence = nocal::RecurrenceRule{
.frequency = nocal::RecurrenceFrequency::daily,
.interval = 1,
.count = 4,
.until = std::nullopt,
.by_weekday = {},
.by_month_day = {},
.by_month = {},
};
event.recurrence_exceptions.push_back(
zoned_time(event.time_zone, nocal::Date{2026y / March / 9d}, 2, 30));
nocal::tui::EventEditor editor{event};
check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-03-07" &&
editor.field_value(nocal::tui::EditorField::start_time) == "02:30",
"a zoned editor displays civil values in the event source timezone");
const auto form = editor.render(80, 18, false);
check(form.find("America/New_York") != std::string::npos,
"a zoned editor explicitly labels its source timezone");
check(form.find("EDIT ENTIRE RECURRING SERIES") != std::string::npos,
"the editor clearly states that edits affect the recurring series");
replace_field(editor, "Renamed standup");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"a valid zoned series edit submits");
check(editor.result().time_basis == nocal::TimeBasis::zoned &&
editor.result().time_zone == "America/New_York" &&
editor.result().recurrence == event.recurrence &&
editor.result().recurrence_exceptions == event.recurrence_exceptions,
"editing preserves time basis, TZID, recurrence, and exceptions");
nocal::tui::EventEditor series_kind_editor{event};
focus(series_kind_editor, nocal::tui::EditorField::all_day);
(void)series_kind_editor.handle_key(" ");
check(series_kind_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
series_kind_editor.focused_field() == nocal::tui::EditorField::all_day &&
series_kind_editor.error().find("recurring series") != std::string_view::npos,
"a recurring series rejects timed/all-day conversion to preserve its exceptions");
auto one_off = event;
one_off.recurrence.reset();
one_off.recurrence_exceptions.clear();
nocal::tui::EventEditor all_day_editor{one_off};
focus(all_day_editor, nocal::tui::EditorField::all_day);
(void)all_day_editor.handle_key(" ");
check(all_day_editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit &&
all_day_editor.result().all_day &&
all_day_editor.result().time_basis == nocal::TimeBasis::floating &&
all_day_editor.result().time_zone.empty(),
"a one-off zoned event converted to all-day normalizes to floating DATE semantics");
nocal::tui::EventEditor gap_editor{event};
focus(gap_editor, nocal::tui::EditorField::start_date);
replace_field(gap_editor, "2026-03-08");
check(gap_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
gap_editor.error().find("cannot be represented") != std::string_view::npos,
"a nonexistent source-zone civil time in the DST gap is rejected");
}
} // namespace
int main()
@@ -225,6 +303,7 @@ int main()
test_all_day_exclusive_end();
test_unicode_backspace_navigation_and_cancel();
test_rendering();
test_zoned_edit_preserves_series_and_rejects_dst_gap();
if (failures != 0) {
std::cerr << failures << " editor test(s) failed\n";
return EXIT_FAILURE;

View File

@@ -411,6 +411,259 @@ void test_unsupported_data_is_not_rewrite_safe(const std::filesystem::path& root
require(!loaded.warnings.empty(), "unsafe calendar did not explain why editing is disabled");
}
void test_time_basis_round_trips_and_dst(const std::filesystem::path& root) {
const auto path = root / "time-bases.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"BEGIN:VEVENT\r\nUID:floating\r\nSUMMARY:Floating\r\n"
"DTSTART:20260717T101500\r\nDTEND:20260717T111500\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:utc-basis\r\nSUMMARY:UTC\r\n"
"DTSTART:20260717T101500Z\r\nDTEND:20260717T111500Z\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:london\r\nSUMMARY:London\r\n"
"DTSTART;TZID=Europe/London:20260717T101500\r\n"
"DTEND;TZID=Europe/London:20260717T111500\r\nRRULE:FREQ=DAILY;COUNT=3\r\n"
"EXDATE;TZID=Europe/London:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:dst\r\nSUMMARY:DST transition\r\n"
"DTSTART;TZID=Europe/London:20260329T003000\r\n"
"DTEND;TZID=Europe/London:20260329T023000\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 time bases were not rewrite-safe");
require(loaded.events.size() == 4, "time-basis fixture event count differs");
require(loaded.events[0].time_basis == nocal::TimeBasis::floating,
"floating DATE-TIME lost its basis");
require(loaded.events[1].time_basis == nocal::TimeBasis::utc,
"UTC DATE-TIME lost its basis");
require(loaded.events[2].time_basis == nocal::TimeBasis::zoned
&& loaded.events[2].time_zone == "Europe/London",
"TZID DATE-TIME lost its zone");
require(loaded.events[2].start == sys_days{year{2026}/July/17} + 9h + 15min,
"Europe/London summer offset was not applied");
require(loaded.events[2].recurrence_exceptions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 9h + 15min,
sys_days{year{2026}/July/19} + 9h + 15min},
"zoned EXDATE values were not converted with TZID");
require(loaded.events[3].end - loaded.events[3].start == 1h,
"Europe/London DST transition was not converted correctly");
const auto round_trip = root / "time-bases-round-trip.ics";
nocal::storage::IcsStore::save(round_trip, loaded.events);
const std::string serialized = read_fixture(round_trip);
require(serialized.find("DTSTART:20260717T101500\r\n") != std::string::npos,
"floating DTSTART was not serialized without a suffix");
require(serialized.find("DTSTART:20260717T101500Z\r\n") != std::string::npos,
"UTC DTSTART was not serialized with Z");
require(serialized.find("DTSTART;TZID=Europe/London:20260717T101500\r\n")
!= std::string::npos,
"zoned DTSTART was not serialized with TZID and local fields");
require(serialized.find(
"EXDATE;TZID=Europe/London:20260718T101500,20260719T101500")
!= std::string::npos,
"zoned EXDATE values were not grouped with TZID");
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
require(reloaded.safe_to_rewrite && reloaded.events.size() == loaded.events.size(),
"time-basis output did not reload safely");
for (std::size_t index = 0; index < loaded.events.size(); ++index) {
require(reloaded.events[index].start == loaded.events[index].start
&& reloaded.events[index].end == loaded.events[index].end
&& 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_exceptions
== loaded.events[index].recurrence_exceptions,
"time-basis semantic round trip differs");
}
}
void test_supported_recurrence_and_exdates(const std::filesystem::path& root) {
const auto path = root / "recurrence.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\nUID:daily\r\nDTSTART:20260701T090000Z\r\n"
"DTEND:20260701T100000Z\r\nRRULE:FREQ=DAILY;INTERVAL=2;COUNT=5\r\n"
"EXDATE:20260703T090000Z,20260705T090000Z\r\n"
"EXDATE:20260707T090000Z\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:weekly\r\nDTSTART:20260701T090000\r\n"
"DTEND:20260701T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;"
"UNTIL=20260801T090000\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:monthly\r\nDTSTART:20260701T090000Z\r\n"
"DTEND:20260701T100000Z\r\nRRULE:FREQ=MONTHLY;BYMONTHDAY=1,-1\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:yearly\r\nDTSTART;VALUE=DATE:20260701\r\n"
"DTEND;VALUE=DATE:20260702\r\nRRULE:FREQ=YEARLY;BYMONTH=1,7;"
"UNTIL=20290701\r\nEXDATE;VALUE=DATE:20270701,20280701\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 recurrence was not rewrite-safe");
require(loaded.events.size() == 4, "recurrence fixture event count differs");
const auto& daily = *loaded.events[0].recurrence;
require(daily.frequency == nocal::RecurrenceFrequency::daily
&& daily.interval == 2 && daily.count == 5 && !daily.until,
"daily RRULE fields differ");
require(loaded.events[0].recurrence_exceptions.size() == 3,
"multiple/comma-separated EXDATE values were not collected");
const auto& weekly = *loaded.events[1].recurrence;
require(weekly.frequency == nocal::RecurrenceFrequency::weekly
&& weekly.by_weekday == std::vector<weekday>{Monday, Wednesday, Friday}
&& weekly.until.has_value(),
"weekly RRULE fields differ");
const auto& monthly = *loaded.events[2].recurrence;
require(monthly.frequency == nocal::RecurrenceFrequency::monthly
&& monthly.by_month_day == std::vector<int>{1, -1},
"monthly RRULE fields differ");
const auto& yearly = *loaded.events[3].recurrence;
require(yearly.frequency == nocal::RecurrenceFrequency::yearly
&& yearly.by_month == std::vector<unsigned>{1, 7} && yearly.until,
"yearly RRULE fields differ");
require(loaded.events[3].recurrence_exceptions.size() == 2,
"all-day EXDATE values differ");
const auto round_trip = root / "recurrence-round-trip.ics";
nocal::storage::IcsStore::save(round_trip, loaded.events);
const std::string serialized = read_fixture(round_trip);
require(serialized.find("RRULE:FREQ=DAILY;INTERVAL=2;COUNT=5") != std::string::npos,
"daily RRULE was not serialized");
require(serialized.find("EXDATE:20260703T090000Z,20260705T090000Z,20260707T090000Z")
!= std::string::npos,
"timed EXDATE values were not grouped");
require(serialized.find("EXDATE;VALUE=DATE:20270701,20280701") != std::string::npos,
"all-day EXDATE values were not grouped with VALUE=DATE");
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
require(reloaded.safe_to_rewrite && reloaded.warnings.empty(),
"serialized recurrence did not reload safely");
require(reloaded.events.size() == loaded.events.size(),
"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_exceptions
== loaded.events[index].recurrence_exceptions,
"recurrence 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",
"RRULE:FREQ=DAILY;BYDAY=MO",
"RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z",
"RRULE:FREQ=HOURLY",
"RRULE:FREQ=DAILY;WKST=MO",
"RDATE:20260718T090000Z",
"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");
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:unsupported\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
+ unsupported_properties[index] + "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite && !loaded.warnings.empty(),
"unsupported recurrence feature was considered rewrite-safe");
require(loaded.events.size() == 1,
"unsupported recurrence prevented base-event browseability");
}
const auto multiple = root / "multiple-rrule.ics";
write_fixture(multiple,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:multiple\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
"RRULE:FREQ=DAILY\r\nRRULE:FREQ=WEEKLY\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
require(!nocal::storage::IcsStore::load(multiple).safe_to_rewrite,
"multiple RRULE properties were considered rewrite-safe");
const auto unknown_zone = root / "unknown-zone.ics";
write_fixture(unknown_zone,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:unknown-zone\r\n"
"DTSTART;TZID=Custom/Mars:20260717T090000\r\n"
"DTEND;TZID=Custom/Mars:20260717T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
const auto unknown = nocal::storage::IcsStore::load(unknown_zone);
require(!unknown.safe_to_rewrite && unknown.events.size() == 1,
"unknown TZID was not read-only and browseable");
const auto custom_zone = root / "vtimezone.ics";
write_fixture(custom_zone,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTIMEZONE\r\nTZID:Custom/Test\r\n"
"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");
const std::vector<std::pair<std::string, std::string>> mismatched_ends{
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000"},
{"DTSTART;TZID=Europe/London:20260717T090000",
"DTEND;TZID=Europe/Paris:20260717T100000"},
{"DTSTART;VALUE=DATE:20260717", "DTEND:20260718T100000Z"}};
for (std::size_t index = 0; index < mismatched_ends.size(); ++index) {
const auto path = root / ("mismatched-time-" + std::to_string(index) + ".ics");
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:mismatch\r\n"
+ mismatched_ends[index].first + "\r\n" + mismatched_ends[index].second
+ "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite && loaded.events.empty(),
"mismatched DTSTART/DTEND kinds were accepted");
}
}
void test_save_validation_preserves_existing_bytes(const std::filesystem::path& root) {
const auto path = root / "validation-preserves.ics";
const std::string original = "existing bytes must survive validation";
const std::string original_backup = "existing backup must survive validation";
write_fixture(path, original);
const auto backup = nocal::storage::IcsStore::backup_path(path);
write_fixture(backup, original_backup);
nocal::Event invalid_zone = event_named("zone@example.test", "Invalid zone");
invalid_zone.time_basis = nocal::TimeBasis::zoned;
invalid_zone.time_zone = "Custom/Unknown";
bool zone_failed = false;
try {
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{invalid_zone});
} catch (const std::invalid_argument&) {
zone_failed = true;
}
require(zone_failed && read_fixture(path) == original,
"invalid TZID validation changed existing bytes");
require(read_fixture(backup) == original_backup,
"invalid TZID validation changed existing backup bytes");
nocal::Event invalid_rule = event_named("rule@example.test", "Invalid rule");
nocal::RecurrenceRule rule;
rule.frequency = nocal::RecurrenceFrequency::daily;
rule.by_weekday.push_back(Monday);
invalid_rule.recurrence = rule;
bool rule_failed = false;
try {
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{invalid_rule});
} catch (const std::invalid_argument&) {
rule_failed = true;
}
require(rule_failed && read_fixture(path) == original,
"invalid RRULE validation changed existing bytes");
require(read_fixture(backup) == original_backup,
"invalid RRULE validation changed existing backup bytes");
nocal::Event lone_exception = event_named("exdate@example.test", "Lone exception");
lone_exception.recurrence_exceptions.push_back(lone_exception.start + 24h);
bool exdate_failed = false;
try {
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{lone_exception});
} catch (const std::invalid_argument&) {
exdate_failed = true;
}
require(exdate_failed && read_fixture(path) == original
&& read_fixture(backup) == original_backup,
"EXDATE-without-RRULE validation changed calendar or backup bytes");
require_no_temporary_files(path);
}
} // namespace
int main() {
@@ -436,6 +689,10 @@ int main() {
test_lock_failure_preserves_existing_bytes(root);
test_rename_failure_cleans_temporary_file(root);
test_unsupported_data_is_not_rewrite_safe(root);
test_time_basis_round_trips_and_dst(root);
test_supported_recurrence_and_exdates(root);
test_unsupported_recurrence_and_zones_are_unsafe(root);
test_save_validation_preserves_existing_bytes(root);
std::filesystem::remove_all(root, ignored);
std::cout << "ics_tests: ok\n";
return 0;

View File

@@ -2,9 +2,12 @@
#include "nocal/tui/Screen.hpp"
#include "nocal/tui/TuiApp.hpp"
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <iostream>
#include <optional>
#include <span>
#include <stdexcept>
#include <string>
@@ -56,12 +59,20 @@ bool focus_is(const nocal::tui::TuiApp& app, const std::string_view uid)
return app.state().focused_event_id && *app.state().focused_event_id == uid;
}
bool focus_starts_with(const nocal::tui::TuiApp& app, const std::string_view prefix)
{
return app.state().focused_event_id && app.state().focused_event_id->starts_with(prefix);
}
bool same_event(const nocal::Event& left, const nocal::Event& right)
{
return left.uid == right.uid && left.title == right.title && left.start == right.start &&
left.end == right.end && left.all_day == right.all_day &&
left.location == right.location && left.description == right.description &&
left.calendar_id == right.calendar_id && left.color == right.color;
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_exceptions == right.recurrence_exceptions;
}
void replace_editor_title(nocal::tui::TuiApp& app, const std::string_view title)
@@ -564,7 +575,11 @@ void test_focused_line_rendering()
.description = {},
.detail_title = "Plain item",
.detail_all_day = false,
.detail_start_time_of_day = hours{8}},
.detail_start_time_of_day = hours{8},
.segment = nocal::tui::ItemSegment::single,
.recurring = false,
.recurrence_summary = {},
.source_time_zone = {}},
{.id = "focused",
.title = "Focused item",
.day = day,
@@ -577,7 +592,11 @@ void test_focused_line_rendering()
.description = {},
.detail_title = "Focused item",
.detail_all_day = false,
.detail_start_time_of_day = hours{9} + minutes{15}},
.detail_start_time_of_day = hours{9} + minutes{15},
.segment = nocal::tui::ItemSegment::single,
.recurring = false,
.recurrence_summary = {},
.source_time_zone = {}},
};
const auto frame = nocal::tui::render_month(items, state);
@@ -698,6 +717,164 @@ void test_narrow_detail_wrapping_without_ansi()
"narrow detail rendering emits no ANSI when styling is disabled");
}
void test_multiday_segment_labels_and_series_messaging()
{
using namespace std::chrono;
const auto first = year{2026} / July / std::chrono::day{16};
const auto middle = year{2026} / July / std::chrono::day{17};
const auto last = year{2026} / July / std::chrono::day{18};
std::vector<nocal::tui::CalendarItem> items{
{.id = "series@occ:1", .title = "Migration", .day = first,
.time_of_day = std::nullopt, .all_day = true,
.end_time_of_day = std::nullopt, .start_day = first, .end_day = last,
.location = {}, .description = {}, .detail_title = "Migration",
.detail_all_day = true, .detail_start_time_of_day = std::nullopt,
.segment = nocal::tui::ItemSegment::start,
.recurring = true, .recurrence_summary = "Every week",
.source_time_zone = "Europe/London"},
{.id = "series@occ:1", .title = "Migration", .day = middle,
.time_of_day = std::nullopt, .all_day = true,
.end_time_of_day = std::nullopt, .start_day = first, .end_day = last,
.location = {}, .description = {}, .detail_title = "Migration",
.detail_all_day = true, .detail_start_time_of_day = std::nullopt,
.segment = nocal::tui::ItemSegment::continuation,
.recurring = true, .recurrence_summary = "Every week",
.source_time_zone = "Europe/London"},
{.id = "series@occ:1", .title = "Migration", .day = last,
.time_of_day = std::nullopt, .all_day = true,
.end_time_of_day = std::nullopt, .start_day = first, .end_day = last,
.location = {}, .description = {}, .detail_title = "Migration",
.detail_all_day = true, .detail_start_time_of_day = std::nullopt,
.segment = nocal::tui::ItemSegment::end,
.recurring = true, .recurrence_summary = "Every week",
.source_time_zone = "Europe/London"},
};
auto state = screen_for(middle, 112, 38);
state.focused_event_id = "series@occ:1";
const auto month = nocal::tui::render_month(items, state);
check(month.find("│ Migration") != std::string::npos,
"a middle multi-day segment has a visible continuation marker without ANSI");
state.show_event_details = true;
const auto reader = nocal::tui::render_month(items, state);
check(reader.find("RECURRING APPOINTMENT") != std::string::npos &&
reader.find("Every week") != std::string::npos &&
reader.find("Europe/London") != std::string::npos &&
reader.find("every occurrence") != std::string::npos,
"the reader identifies recurrence, source timezone, and whole-series edits");
state.show_event_details = false;
state.confirm_delete = true;
const auto confirmation = nocal::tui::render_month(items, state);
check(confirmation.find("entire recurring series") != std::string::npos,
"recurring delete confirmation explicitly targets the whole series");
}
void test_recurring_occurrence_navigation_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("duplicate", "Daily series", previous, 9, 0, previous, 10, 0);
series.recurrence = nocal::RecurrenceRule{
.frequency = nocal::RecurrenceFrequency::daily, .interval = 1, .count = 4,
.until = std::nullopt, .by_weekday = {}, .by_month_day = {}, .by_month = {}};
auto duplicate = make_event("duplicate", "Duplicate UID", selected, 11, 0, selected, 12, 0);
std::vector<nocal::Event> events{series, duplicate};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
check(focus_starts_with(app, "@nocal:0@occ:"),
"a recurring duplicate-UID occurrence has a stable source-and-start identity");
const auto series_focus = app.state().focused_event_id;
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "@nocal:1"),
"Tab navigates from a recurring instance to a duplicate-UID one-off event");
app.dispatch(nocal::tui::Action::previous_event);
check(app.state().focused_event_id == series_focus,
"occurrence navigation returns to the exact recurring instance");
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Edited entire series");
app.handle_input("\x13");
check(events.front().title == "Edited entire series" && events.front().recurrence.has_value() &&
app.state().notification == "Recurring series updated." &&
focus_starts_with(app, "@nocal:0@occ:"),
"editing an occurrence updates its source series and retains coherent focus");
app.dispatch(nocal::tui::Action::undo);
check(events.front().title == "Daily series" && events.front().recurrence == series.recurrence,
"undo restores the complete recurring series metadata");
app.dispatch(nocal::tui::Action::redo);
check(events.front().title == "Edited entire series" && events.front().recurrence.has_value(),
"redo reapplies the whole-series edit");
app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("y");
check(events.size() == 1 && events.front().title == "Duplicate UID" &&
app.state().notification == "Recurring series deleted.",
"deleting a recurring occurrence removes only its entire source series");
app.dispatch(nocal::tui::Action::undo);
check(events.size() == 2 && events.front().recurrence.has_value(),
"undo restores a deleted recurring series");
}
void test_recurrence_grid_expansion_and_source_zone_display()
{
using namespace std::chrono;
const auto first = year{2026} / July / std::chrono::day{17};
auto recurring = make_event("grid-series", "Grid repeat", first, 9, 0, first, 10, 0);
recurring.recurrence = nocal::RecurrenceRule{
.frequency = nocal::RecurrenceFrequency::daily, .interval = 1, .count = 3,
.until = std::nullopt, .by_weekday = {}, .by_month_day = {}, .by_month = {}};
std::vector<nocal::Event> events{recurring};
auto state = screen_for(first, 140, 38);
const auto frame = nocal::tui::render_month(events, state);
std::size_t repeats = 0;
for (std::size_t at = 0; (at = frame.find("Grid repeat", at)) != std::string::npos;
at += std::string_view{"Grid repeat"}.size()) {
++repeats;
}
check(repeats == 3, "the month adapter expands recurring instances only into visible days");
auto compact_state = screen_for(first, 32, 12);
compact_state.ansi = false;
const auto compact = nocal::tui::render_month(events, compact_state);
check(compact.find("\x1b[") == std::string::npos &&
std::count(compact.begin(), compact.end(), '\n') == 11,
"recurrence rendering preserves compact NO_COLOR frame geometry");
const char* prior_tz_value = std::getenv("TZ");
const std::optional<std::string> prior_tz = prior_tz_value
? std::optional{std::string{prior_tz_value}}
: std::nullopt;
(void)::setenv("TZ", "UTC0", 1);
::tzset();
const auto* zone = std::chrono::locate_zone("America/New_York");
nocal::Event zoned;
zoned.uid = "zoned-reader";
zoned.title = "Zoned meeting";
zoned.time_basis = nocal::TimeBasis::zoned;
zoned.time_zone = "America/New_York";
zoned.start = zone->to_sys(local_days{first} + hours{9}, choose::earliest);
zoned.end = zone->to_sys(local_days{first} + hours{10}, choose::earliest);
std::vector<nocal::Event> zoned_events{zoned};
auto zoned_state = screen_for(first, 100, 28);
const auto occurrences = nocal::occurrences_on_day(zoned_events, first);
check(!occurrences.empty(), "the zoned test event occurs on the user's displayed day");
if (!occurrences.empty()) {
zoned_state.focused_event_id =
nocal::tui::occurrence_focus_id(zoned_events, occurrences.front());
zoned_state.show_event_details = true;
const auto reader = nocal::tui::render_month(zoned_events, zoned_state);
check(reader.find("America/New_York") != std::string::npos &&
reader.find("13:00 14:00") != std::string::npos &&
reader.find("09:00 10:00") == std::string::npos,
"the reader names the source TZID while displaying instants in the user zone");
}
if (prior_tz) (void)::setenv("TZ", prior_tz->c_str(), 1);
else (void)::unsetenv("TZ");
::tzset();
}
} // namespace
int main()
@@ -721,6 +898,9 @@ int main()
test_timed_event_details();
test_all_day_and_multiday_details();
test_narrow_detail_wrapping_without_ansi();
test_multiday_segment_labels_and_series_messaging();
test_recurring_occurrence_navigation_and_whole_series_crud();
test_recurrence_grid_expansion_and_source_zone_display();
if (failures != 0) {
std::cerr << failures << " TUI focus test(s) failed\n";
return EXIT_FAILURE;

View File

@@ -44,12 +44,16 @@ void test_full_month_render()
.time_of_day = hours{9} + minutes{30}, .all_day = false,
.end_time_of_day = std::nullopt, .start_day = {}, .end_day = {},
.location = {}, .description = {}, .detail_title = {},
.detail_all_day = false, .detail_start_time_of_day = std::nullopt},
.detail_all_day = false, .detail_start_time_of_day = std::nullopt,
.segment = nocal::tui::ItemSegment::single, .recurring = false,
.recurrence_summary = {}, .source_time_zone = {}},
{.id = "2", .title = "Release day", .day = year{2026} / July / day{17},
.time_of_day = std::nullopt, .all_day = true,
.end_time_of_day = std::nullopt, .start_day = {}, .end_day = {},
.location = {}, .description = {}, .detail_title = {},
.detail_all_day = true, .detail_start_time_of_day = std::nullopt},
.detail_all_day = true, .detail_start_time_of_day = std::nullopt,
.segment = nocal::tui::ItemSegment::single, .recurring = false,
.recurrence_summary = {}, .source_time_zone = {}},
};
const auto frame = nocal::tui::render_month(items, state);