Files
nocal/tests/domain_tests.cpp
Bernardo Magri 989332ef9a feat: make month week start configurable
Add a Monday-default --week-start option for every weekday and carry it through domain layout, full and compact rendering, event query windows, print mode, and the interactive TUI.

Preserve byte-compatible Monday output and cover rotated headers, spill dates, invalid input, sanitizer behavior, and live terminal restoration.
2026-07-18 09:58:27 +01:00

516 lines
21 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");
const nocal::Date wednesday{2026y / July / 1d};
for (unsigned start = 0; start < 7; ++start) {
const weekday week_start{start};
check(nocal::weekday_index(wednesday, week_start) ==
(Wednesday.c_encoding() + 7U - start) % 7U,
"weekday index is relative to each possible week start");
}
check(nocal::weekday_index(wednesday, Monday) ==
nocal::monday_first_weekday(wednesday),
"Monday weekday wrapper preserves configurable-index behavior");
check_throws([] { (void)nocal::parse_date("2023-02-29"); },
"invalid date is rejected");
check_throws(
[] {
(void)nocal::weekday_index(
nocal::Date{2023y / February / 29d}, Monday);
},
"weekday index rejects an invalid date");
check_throws(
[wednesday] { (void)nocal::weekday_index(wednesday, weekday{8}); },
"weekday index rejects an invalid week start");
}
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.week_start == Monday,
"default month layout remains Monday-first");
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");
const auto sunday_layout = nocal::make_month_layout(2026y / July, Sunday);
check(sunday_layout.week_start == Sunday,
"month layout records its configured week start");
check(sunday_layout.first_visible_date() ==
nocal::Date{2026y / June / 28d} &&
sunday_layout.last_visible_date() ==
nocal::Date{2026y / August / 8d},
"Sunday-first July 2026 has the expected spill dates");
const auto* sunday_july_first =
sunday_layout.find(nocal::Date{2026y / July / 1d});
check(sunday_july_first != nullptr && sunday_july_first->row == 0 &&
sunday_july_first->column == 3,
"Sunday-first July 2026 places Wednesday in column three");
for (unsigned start = 0; start < 7; ++start) {
const weekday week_start{start};
const auto configured =
nocal::make_month_layout(2026y / July, week_start);
check(configured.week_start == week_start,
"each configured week start is retained by the layout");
check(weekday{nocal::to_sys_days(configured.first_visible_date())} ==
week_start,
"each layout starts on its configured weekday");
for (std::size_t index = 1; index < configured.cells.size(); ++index) {
check(nocal::to_sys_days(configured.cells[index].date) ==
nocal::to_sys_days(configured.cells[index - 1].date) +
days{1},
"month layout cells are 42 consecutive dates");
}
const auto* july_first =
configured.find(nocal::Date{2026y / July / 1d});
check(july_first != nullptr && july_first->row == 0 &&
july_first->column ==
nocal::weekday_index(nocal::Date{2026y / July / 1d},
week_start),
"July 2026 column follows the configured week start");
}
check_throws(
[] {
(void)nocal::make_month_layout(2026y / July, weekday{8});
},
"month layout rejects an invalid week start");
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;
}