feat: add recurrence and time zone support
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user