Files
nocal/tests/domain_tests.cpp
Bernardo Magri 88d370bf99 feat: add calendar visibility picker
Derive session calendars from event metadata and apply one visibility contract to month rendering, appointment focus, and search without filtering persistence writes.

Add keyboard picker coverage, full-model save regression tests, documentation, warning-clean builds, sanitizer verification, and live PTY terminal restoration checks.
2026-07-18 09:01:27 +01:00

451 lines
18 KiB
C++

#include "nocal/domain/domain.hpp"
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace {
int failures = 0;
void check(bool condition, std::string_view message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
template <class Function>
void check_throws(Function&& function, std::string_view message)
{
try {
function();
check(false, message);
} catch (const std::exception&) {
}
}
void test_dates()
{
using namespace std::chrono;
const nocal::Date leap{2024y / February / 29d};
check(nocal::valid_date(leap), "leap day is valid");
check(nocal::format_date(leap) == "2024-02-29", "date formats as ISO 8601");
check(nocal::parse_date("2024-02-29") == leap, "date parses from ISO 8601");
check(nocal::from_sys_days(nocal::to_sys_days(leap) + days{1}) ==
nocal::Date{2024y / March / 1d},
"civil date arithmetic crosses a leap month");
check(nocal::monday_first_weekday(nocal::Date{2026y / July / 6d}) == 0,
"Monday has index zero");
check(nocal::monday_first_weekday(nocal::Date{2026y / July / 12d}) == 6,
"Sunday has index six");
check_throws([] { (void)nocal::parse_date("2023-02-29"); },
"invalid date is rejected");
}
void test_month_layout()
{
using namespace std::chrono;
const auto layout = nocal::make_month_layout(2026y / July);
check(layout.cells.size() == 42, "month layout always has 42 cells");
check(layout.first_visible_date() == nocal::Date{2026y / June / 29d},
"July 2026 begins after Monday-leading spill cells");
check(layout.last_visible_date() == nocal::Date{2026y / August / 9d},
"six-week grid has the expected final date");
check(layout.at(0, 0).column == 0 && layout.at(5, 6).row == 5,
"cell coordinates match their positions");
check(!layout.at(0, 0).in_month && layout.at(0, 2).in_month,
"spill cells are distinguished from current-month cells");
const auto* july_17 = layout.find(nocal::Date{2026y / July / 17d});
check(july_17 != nullptr && july_17->row == 2 && july_17->column == 4,
"date lookup finds the correct Friday cell");
check_throws([&] { (void)layout.at(6, 0); }, "out-of-grid access is rejected");
}
nocal::Event event(std::string uid, nocal::Date start_date, unsigned start_hour,
nocal::Date end_date, unsigned end_hour, bool all_day = false)
{
nocal::Event result;
result.uid = std::move(uid);
result.title = result.uid;
result.start = nocal::make_local_time(start_date, start_hour);
result.end = nocal::make_local_time(end_date, end_hour);
result.all_day = all_day;
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;
const nocal::Date previous{2026y / July / 16d};
const nocal::Date day{2026y / July / 17d};
const nocal::Date next{2026y / July / 18d};
std::vector<nocal::Event> events;
events.push_back(event("late", day, 15, day, 16));
events.push_back(event("all-day", day, 0, next, 0, true));
events.push_back(event("early", day, 9, day, 10));
events.push_back(event("carry", previous, 23, day, 1));
events.push_back(event("ends-at-boundary", previous, 22, day, 0));
events.push_back(event("next-day", next, 0, next, 1));
const nocal::Date august_first{2026y / August / 1d};
events.push_back(event("next-month", august_first, 0, august_first, 1));
nocal::Event instant = event("instant", day, 12, day, 12);
events.push_back(instant);
const auto on_day = nocal::events_on_day(events, day);
check(on_day.size() == 5, "day query uses overlap and half-open boundaries");
check(on_day[0].get().uid == "all-day", "all-day events sort before timed events");
check(on_day[1].get().uid == "carry", "overlapping prior-day event is included");
check(on_day[2].get().uid == "early" && on_day[3].get().uid == "instant" &&
on_day[4].get().uid == "late",
"timed events sort by start time");
check(nocal::event_occurs_on(instant, day),
"zero-duration event occurs on the day containing its instant");
const auto in_july = nocal::events_in_month(events, 2026y / July);
check(in_july.size() == 7, "month query excludes an event beginning August 1");
nocal::Event invalid = event("invalid", day, 12, day, 11);
check(!nocal::event_overlaps(invalid, nocal::local_day_start(day),
nocal::local_day_end(day)),
"invalid backwards event never overlaps");
}
void test_event_search()
{
nocal::Event event;
event.title = "Quarterly Planning";
event.description = "Review the launch checklist";
event.location = "North Conference Room";
event.calendar_id = "TEAM-OPERATIONS";
check(nocal::event_matches_query(event, "quarterly"),
"event search matches the title case-insensitively");
check(nocal::event_matches_query(event, "launch check"),
"event search matches a description substring");
check(nocal::event_matches_query(event, "conference"),
"event search matches a location substring");
check(nocal::event_matches_query(event, "team-operations"),
"event search matches the calendar ID case-insensitively");
check(!nocal::event_matches_query(event, ""),
"an empty event search query matches nothing");
check(!nocal::event_matches_query(event, "budget"),
"event search rejects text absent from every searchable field");
event.title = "Caf\xC3\xA9 REVIEW";
check(nocal::event_matches_query(event, "Caf\xC3\xA9 review"),
"event search preserves non-ASCII bytes while folding ASCII");
check(!nocal::event_matches_query(event, "CAF\xC3\x89"),
"event search does not locale-fold non-ASCII bytes");
}
void test_calendar_visibility()
{
const std::vector<nocal::Calendar> calendars{
{.id = "", .name = "Default", .color = {}, .visible = true,
.read_only = false},
{.id = "personal", .name = "Personal", .color = {}, .visible = true,
.read_only = false},
{.id = "work", .name = "Work", .color = {}, .visible = false,
.read_only = false},
};
nocal::Event default_event;
nocal::Event personal_event;
personal_event.calendar_id = "personal";
nocal::Event work_event;
work_event.calendar_id = "work";
nocal::Event new_event;
new_event.calendar_id = "new-calendar";
check(nocal::event_is_visible(default_event, calendars),
"an empty calendar ID uses the visible default calendar");
check(nocal::event_is_visible(personal_event, calendars),
"an explicitly visible calendar exposes its events");
check(!nocal::event_is_visible(work_event, calendars),
"a hidden calendar filters its events");
check(nocal::event_is_visible(new_event, calendars),
"unknown calendar metadata defaults visible instead of hiding data");
}
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()
{
test_dates();
test_month_layout();
test_event_queries();
test_event_search();
test_calendar_visibility();
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;
}
std::cout << "domain tests passed\n";
return EXIT_SUCCESS;
}