feat: initialize Nocal terminal calendar

This commit is contained in:
2026-07-17 21:24:06 +01:00
commit 22c6399056
37 changed files with 6146 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
#!/bin/sh
set -eu
if [ "$#" -ne 1 ]; then
echo "usage: cli_recovery_test.sh /path/to/nocal" >&2
exit 2
fi
nocal=$1
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/nocal-cli-recovery.XXXXXX")
trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM
calendar=$tmpdir/calendar.ics
backup=$calendar.bak
expected=$tmpdir/expected.ics
cat >"$calendar" <<'EOF'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nocal CLI test//EN
BEGIN:VEVENT
UID:current@nocal.test
DTSTART:20260717T090000
DTEND:20260717T100000
SUMMARY:Current appointment
END:VEVENT
END:VCALENDAR
EOF
cat >"$backup" <<'EOF'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nocal CLI test//EN
BEGIN:VEVENT
UID:backup@nocal.test
DTSTART:20260718T140000
DTEND:20260718T150000
SUMMARY:Recovered appointment
END:VEVENT
END:VCALENDAR
EOF
cp "$backup" "$expected"
help=$("$nocal" --help)
case $help in
*--restore-backup*"u undoes the last change, Ctrl-R redoes it"*) ;;
*)
echo "help does not document recovery and undo/redo" >&2
exit 1
;;
esac
if "$nocal" --restore-backup --demo --calendar "$calendar" >"$tmpdir/out" 2>"$tmpdir/err"; then
echo "--restore-backup with --demo unexpectedly succeeded" >&2
exit 1
fi
grep -F -- "--restore-backup cannot be combined with --demo" "$tmpdir/err" >/dev/null
if "$nocal" --restore-backup --print --calendar "$calendar" >"$tmpdir/out" 2>"$tmpdir/err"; then
echo "--restore-backup with --print unexpectedly succeeded" >&2
exit 1
fi
grep -F -- "--restore-backup cannot be combined with --print" "$tmpdir/err" >/dev/null
missing_calendar=$tmpdir/missing-backup.ics
cp "$calendar" "$missing_calendar"
cp "$missing_calendar" "$tmpdir/missing-before.ics"
if "$nocal" --restore-backup --calendar "$missing_calendar" \
>"$tmpdir/out" 2>"$tmpdir/err"; then
echo "restore without an adjacent backup unexpectedly succeeded" >&2
exit 1
fi
cmp "$missing_calendar" "$tmpdir/missing-before.ics"
actual=$("$nocal" --restore-backup --calendar "$calendar")
expected_summary="Restored backup $backup to $calendar"
if [ "$actual" != "$expected_summary" ]; then
echo "unexpected restore summary: $actual" >&2
exit 1
fi
cmp "$calendar" "$expected"
cmp "$backup" "$expected"

134
tests/domain_tests.cpp Normal file
View File

@@ -0,0 +1,134 @@
#include "nocal/domain/domain.hpp"
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <stdexcept>
#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;
}
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");
}
} // namespace
int main()
{
test_dates();
test_month_layout();
test_event_queries();
if (failures != 0) {
std::cerr << failures << " domain test(s) failed\n";
return EXIT_FAILURE;
}
std::cout << "domain tests passed\n";
return EXIT_SUCCESS;
}

234
tests/editor_tests.cpp Normal file
View File

@@ -0,0 +1,234 @@
#include "nocal/domain/date.hpp"
#include "nocal/tui/EventEditor.hpp"
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <string>
#include <string_view>
namespace {
int failures = 0;
void check(const bool condition, const std::string_view message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
void replace_field(nocal::tui::EventEditor& editor, const std::string_view value)
{
while (!editor.field_value(editor.focused_field()).empty()) {
(void)editor.handle_key("\x7f");
}
(void)editor.handle_key(value);
}
void focus(nocal::tui::EventEditor& editor, const nocal::tui::EditorField target)
{
while (editor.focused_field() != target) (void)editor.handle_key("\t");
}
void test_create_and_validation()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::tui::EventEditor editor{day};
check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-07-17",
"new editor starts on selected date");
check(editor.field_value(nocal::tui::EditorField::start_time) == "09:00" &&
editor.field_value(nocal::tui::EditorField::end_time) == "10:00",
"new editor supplies useful default times");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::title && !editor.error().empty(),
"blank title blocks submission and focuses the invalid field");
(void)editor.handle_key("Design review");
focus(editor, nocal::tui::EditorField::end_time);
replace_field(editor, "08:00");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.error().find("after") != std::string_view::npos,
"backwards timed interval is rejected");
replace_field(editor, "10:30");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"valid timed event submits");
check(editor.result().title == "Design review" && !editor.result().uid.empty(),
"submitted event contains title and generated UID");
check(editor.result().end > editor.result().start && !editor.result().all_day,
"submitted timed event has a strict interval");
nocal::tui::EventEditor second{day};
check(second.result().uid != editor.result().uid, "locally generated UIDs do not collide");
}
void test_invalid_civil_inputs()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
(void)editor.handle_key("Planning");
focus(editor, nocal::tui::EditorField::start_date);
replace_field(editor, "2026-02-30");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::start_date,
"invalid civil date is rejected");
replace_field(editor, "2026-07-17");
focus(editor, nocal::tui::EditorField::start_time);
replace_field(editor, "25:10");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::start_time,
"out-of-range clock time is rejected");
check(editor.render(64, 18, false).find("Start time must be") != std::string::npos,
"validation error is visible in the rendered form");
}
void test_edit_preserves_identity_and_metadata()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::Event existing;
existing.uid = "stable-id";
existing.title = "Old title";
existing.start = nocal::make_local_time(day, 14, 0);
existing.end = nocal::make_local_time(day, 15, 0);
existing.location = "Studio";
existing.description = "Original notes";
existing.calendar_id = "work";
existing.color = "blue";
nocal::tui::EventEditor editor{existing};
replace_field(editor, "New title");
focus(editor, nocal::tui::EditorField::notes);
check(editor.field_value(nocal::tui::EditorField::notes) == "Original notes",
"edit initializes notes from description");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"edited event submits");
check(editor.result().uid == "stable-id" && editor.result().calendar_id == "work" &&
editor.result().color == "blue" && editor.result().description == "Original notes",
"editing preserves identity, calendar, color, and unchanged description");
check(editor.result().title == "New title", "editing updates changed fields");
}
void test_all_day_exclusive_end()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("Conference");
focus(editor, nocal::tui::EditorField::all_day);
(void)editor.handle_key(" ");
check(editor.all_day(), "Space toggles all-day on its control");
check(editor.field_value(nocal::tui::EditorField::end_date) == "2026-07-18",
"all-day toggle supplies the normal exclusive next-day end");
focus(editor, nocal::tui::EditorField::end_date);
replace_field(editor, "2026-07-17");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::end_date,
"same-day exclusive all-day end is rejected");
replace_field(editor, "2026-07-18");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"next-day exclusive all-day end submits");
check(editor.result().all_day && nocal::local_date(editor.result().start) == day &&
nocal::local_date(editor.result().end) == nocal::Date{2026y / July / 18d},
"all-day result uses exclusive civil-day bounds");
}
void test_unicode_backspace_navigation_and_cancel()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
(void)editor.handle_key("Café ☕");
(void)editor.handle_key("\x7f");
check(editor.field_value(nocal::tui::EditorField::title) == "Café ",
"Backspace removes one complete UTF-8 code point");
(void)editor.handle_key("\x1b[B");
check(editor.focused_field() == nocal::tui::EditorField::start_date,
"Down arrow advances field focus");
(void)editor.handle_key("\x1b[A");
check(editor.focused_field() == nocal::tui::EditorField::title,
"Up arrow reverses field focus");
check(editor.handle_key("\x1b") == nocal::tui::EditorOutcome::cancel,
"Escape cancels without submission");
}
std::string strip_ansi(const std::string_view input)
{
std::string output;
for (std::size_t index = 0; index < input.size();) {
if (input[index] == '\x1b' && index + 1 < input.size() && input[index + 1] == '[') {
index += 2;
while (index < input.size() && input[index] != 'm') ++index;
if (index < input.size()) ++index;
} else {
output += input[index++];
}
}
return output;
}
void test_rendering()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
const auto plain = editor.render(72, 18, false);
check(std::count(plain.begin(), plain.end(), '\n') == 17,
"render fills requested height exactly");
check(plain.find("NEW APPOINTMENT") != std::string::npos &&
plain.find("Ctrl-S save") != std::string::npos &&
plain.find("[ ] timed appointment") != std::string::npos,
"render exposes form identity and controls");
check(plain.find("\x1b[") == std::string::npos, "ANSI can be disabled");
const auto styled = editor.render(72, 18, true);
check(styled.find("\x1b[") != std::string::npos && strip_ansi(styled) == plain,
"semantic ANSI attributes do not alter frame geometry");
const auto compact = editor.render(24, 6, false);
check(std::count(compact.begin(), compact.end(), '\n') == 5,
"compact render also fills requested height");
(void)editor.handle_key("会議 ☕");
const auto wide = editor.render(40, 18, false);
std::size_t start = 0;
bool exact_width = true;
while (start <= wide.size()) {
const auto end = wide.find('\n', start);
const auto line = wide.substr(start, end == std::string::npos ? wide.size() - start
: end - start);
int cells = 0;
for (std::size_t at = 0; at < line.size();) {
const auto first = static_cast<unsigned char>(line[at]);
std::size_t length = 1;
if ((first & 0xe0U) == 0xc0U) length = 2;
else if ((first & 0xf0U) == 0xe0U) length = 3;
else if ((first & 0xf8U) == 0xf0U) length = 4;
const auto fragment = std::string_view{line}.substr(at, length);
if (fragment == "" || fragment == "" || fragment == "") cells += 2;
else ++cells;
at += length;
}
exact_width = exact_width && cells == 40;
if (end == std::string::npos) break;
start = end + 1;
}
check(exact_width, "wide Unicode input retains exact terminal-cell width");
}
} // namespace
int main()
{
test_create_and_validation();
test_invalid_civil_inputs();
test_edit_preserves_identity_and_metadata();
test_all_day_exclusive_end();
test_unicode_backspace_navigation_and_cancel();
test_rendering();
if (failures != 0) {
std::cerr << failures << " editor test(s) failed\n";
return EXIT_FAILURE;
}
std::cout << "editor tests passed\n";
return EXIT_SUCCESS;
}

448
tests/ics_tests.cpp Normal file
View File

@@ -0,0 +1,448 @@
#include "nocal/storage/ics_store.hpp"
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <stdexcept>
#include <string>
#include <vector>
#if !defined(_WIN32)
#include <sys/stat.h>
#endif
namespace {
using namespace std::chrono;
void require(bool condition, const char* message) {
if (!condition) {
throw std::runtime_error(message);
}
}
void write_fixture(const std::filesystem::path& path, const std::string& contents) {
std::ofstream output(path, std::ios::binary);
output << contents;
require(static_cast<bool>(output), "failed to write test fixture");
}
std::string read_fixture(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
require(static_cast<bool>(input), "failed to open test fixture");
return {std::istreambuf_iterator<char>{input}, {}};
}
void require_no_temporary_files(const std::filesystem::path& path) {
const std::string prefix = "." + path.filename().string() + ".tmp.";
for (const auto& entry : std::filesystem::directory_iterator(path.parent_path())) {
require(!entry.path().filename().string().starts_with(prefix),
"atomic save left a temporary file behind");
}
}
nocal::Event event_named(std::string uid, std::string title, int day_offset = 0) {
nocal::Event event;
event.uid = std::move(uid);
event.title = std::move(title);
event.start = sys_days{year{2026}/July/17} + days{day_offset} + 9h;
event.end = event.start + 1h;
return event;
}
void test_round_trip_and_folding(const std::filesystem::path& path) {
nocal::Event timed;
timed.uid = "meeting-1@example.test";
timed.title = "Planning, review; and \\ decisions";
timed.start = sys_days{year{2026}/July/17} + 9h + 30min + 15s;
timed.end = timed.start + 45min;
timed.location = "Nomarchy HQ";
timed.description = "First line\nA long UTF-8 line: café café café café café café café café café café café café";
timed.calendar_id = "work";
timed.color = "#89b4fa";
nocal::Event all_day;
all_day.uid = "holiday-1@example.test";
all_day.title = "Release day";
all_day.start = sys_days{year{2026}/July/20};
all_day.end = sys_days{year{2026}/July/22};
all_day.all_day = true;
const std::vector<nocal::Event> source{timed, all_day};
const auto saved_revision = nocal::storage::IcsStore::save(path, source);
const auto loaded = nocal::storage::IcsStore::load(path);
require(saved_revision.existed() && saved_revision.size() > 0,
"save did not return an existing non-empty revision");
require(loaded.revision.existed() && loaded.revision.size() == saved_revision.size(),
"load did not retain the exact saved revision");
require(loaded.warnings.empty(), "saved calendar produced load warnings");
require(loaded.safe_to_rewrite, "nocal-generated calendar was not safe to rewrite");
require(loaded.events.size() == 2, "round-trip event count differs");
const auto& loaded_timed = loaded.events[0];
require(loaded_timed.uid == timed.uid, "UID did not round-trip");
require(loaded_timed.title == timed.title, "escaped SUMMARY did not round-trip");
require(loaded_timed.start == timed.start && loaded_timed.end == timed.end,
"timed interval did not round-trip");
require(loaded_timed.description == timed.description, "DESCRIPTION did not round-trip");
require(loaded_timed.calendar_id == timed.calendar_id && loaded_timed.color == timed.color,
"nocal extension properties did not round-trip");
require(loaded.events[1].all_day, "all-day flag did not round-trip");
require(loaded.events[1].start == all_day.start && loaded.events[1].end == all_day.end,
"all-day interval did not round-trip");
std::ifstream input(path, std::ios::binary);
const std::string bytes{std::istreambuf_iterator<char>{input}, {}};
require(bytes.find("\r\n ") != std::string::npos, "long property was not folded");
require(bytes.find("Planning\\, review\\; and \\\\ decisions") != std::string::npos,
"TEXT escaping was not written");
std::size_t offset = 0;
while (offset < bytes.size()) {
const std::size_t end = bytes.find("\r\n", offset);
require(end != std::string::npos, "physical line is not CRLF terminated");
require(end - offset <= 75, "folded physical line exceeds 75 octets");
offset = end + 2;
}
}
void test_local_utc_and_recovery(const std::filesystem::path& path) {
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:local\r\n"
"DTSTART:20260717T101500\r\n"
"DTEND:20260717T111500\r\n"
"SUMMARY:Local time\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:utc\r\n"
"DTSTART:20260717T101500Z\r\n"
"DTEND:20260717T111500Z\r\n"
"DESCRIPTION:folded text begins here and continues over a deliberately long value \r\n"
" with the rest\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:one-day\r\n"
"DTSTART;VALUE=DATE:20260718\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:broken\r\n"
"DTSTART:not-a-date\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.events.size() == 3, "malformed VEVENT was not isolated");
require(!loaded.warnings.empty(), "malformed VEVENT was not diagnosed");
require(!loaded.safe_to_rewrite, "malformed source was considered safe to rewrite");
require(loaded.events[0].start == loaded.events[1].start,
"local and UTC DATE-TIME should agree while tests run in UTC");
require(loaded.events[2].all_day, "DATE DTSTART was not recognized");
require(loaded.events[2].end - loaded.events[2].start == 24h,
"implicit all-day DTEND should be the following day in UTC");
require(loaded.events[1].description.find("value with the rest") != std::string::npos,
"folded content line was not unfolded");
}
void test_missing_file(const std::filesystem::path& path) {
std::error_code ignored;
std::filesystem::remove(path, ignored);
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.events.empty() && loaded.warnings.empty(),
"missing calendar should load as empty without warnings");
require(loaded.safe_to_rewrite, "missing calendar should be safe to create");
require(!loaded.revision.existed() && loaded.revision.size() == 0,
"missing calendar produced an existing revision");
}
void test_nested_creation_and_permissions(const std::filesystem::path& root) {
const auto path = root / "new" / "nested" / "calendar.ics";
nocal::storage::IcsStore::save(path, {});
require(std::filesystem::is_regular_file(path), "save did not create a nested calendar path");
require(read_fixture(path).starts_with("BEGIN:VCALENDAR\r\n"),
"new calendar does not contain a VCALENDAR");
require_no_temporary_files(path);
#if !defined(_WIN32)
struct stat metadata {};
require(::stat(path.c_str(), &metadata) == 0, "could not stat saved calendar");
require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0,
"saved calendar is not readable and writable by its owner");
require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0,
"saved calendar grants group or other permissions");
#endif
}
void test_atomic_replacement(const std::filesystem::path& root) {
const auto path = root / "replace.ics";
write_fixture(path, "old calendar bytes");
nocal::Event replacement;
replacement.uid = "replacement@example.test";
replacement.title = "Replacement";
replacement.start = sys_days{year{2026}/July/23} + 10h;
replacement.end = replacement.start + 30min;
const std::vector<nocal::Event> events{replacement};
nocal::storage::IcsStore::save(path, events);
const std::string bytes = read_fixture(path);
require(bytes.find("old calendar bytes") == std::string::npos,
"save did not replace the existing calendar");
require(bytes.find("UID:replacement@example.test") != std::string::npos,
"replacement calendar bytes are incomplete");
require_no_temporary_files(path);
}
void test_revisions_reject_external_changes(const std::filesystem::path& root) {
const auto path = root / "revisions.ics";
const auto backup = nocal::storage::IcsStore::backup_path(path);
const std::vector<nocal::Event> original_events{
event_named("revision-1@example.test", "Original")};
const auto missing = nocal::storage::IcsStore::load(path);
const auto created = nocal::storage::IcsStore::save(path, original_events, missing.revision);
require(created.existed(), "created revision says the calendar does not exist");
require(!std::filesystem::exists(backup), "initial creation unexpectedly made a backup");
const auto loaded = nocal::storage::IcsStore::load(path);
const std::string original = read_fixture(path);
std::string externally_changed = original;
const auto title = externally_changed.find("SUMMARY:Original");
require(title != std::string::npos, "test fixture lacks the expected title");
externally_changed[title + std::string("SUMMARY:").size()] = 'X';
require(externally_changed.size() == original.size(), "external edit changed fixture size");
write_fixture(path, externally_changed);
write_fixture(backup, "existing backup must remain");
bool failed = false;
std::string message;
try {
const std::vector<nocal::Event> replacement{
event_named("revision-2@example.test", "Replacement")};
(void)nocal::storage::IcsStore::save(path, replacement, loaded.revision);
} catch (const std::runtime_error& error) {
failed = true;
message = error.what();
}
require(failed, "same-size external change was not rejected");
require(message.find("reload") != std::string::npos,
"external-change error did not tell the caller to reload");
require(read_fixture(path) == externally_changed,
"conflicting save changed the externally modified calendar");
require(read_fixture(backup) == "existing backup must remain",
"conflicting save changed the existing backup");
require_no_temporary_files(path);
}
void test_backup_replacement_failure_preserves_calendar(const std::filesystem::path& root) {
#if !defined(_WIN32)
const auto path = root / "backup-replacement-failure.ics";
const std::vector<nocal::Event> original_events{
event_named("survivor@example.test", "Must survive")};
const auto revision = nocal::storage::IcsStore::save(path, original_events);
const std::string before = read_fixture(path);
const auto backup = nocal::storage::IcsStore::backup_path(path);
std::filesystem::create_directory(backup);
write_fixture(backup / "sentinel", "backup destination must survive");
bool failed = false;
try {
const std::vector<nocal::Event> replacement{
event_named("replacement@example.test", "Replacement")};
(void)nocal::storage::IcsStore::save(path, replacement, revision);
} catch (const std::runtime_error&) {
failed = true;
}
require(failed, "save unexpectedly replaced a non-empty backup directory");
require(read_fixture(path) == before, "backup failure changed the calendar");
require(read_fixture(backup / "sentinel") == "backup destination must survive",
"backup failure damaged the existing backup destination");
require_no_temporary_files(path);
require_no_temporary_files(backup);
#else
(void)root;
#endif
}
void test_backup_rotation_permissions_and_restore(const std::filesystem::path& root) {
const auto path = root / "backup.ics";
const auto backup = nocal::storage::IcsStore::backup_path(path);
const std::vector<nocal::Event> first_events{event_named("first@example.test", "First")};
const auto first_revision = nocal::storage::IcsStore::save(path, first_events);
const std::string first_bytes = read_fixture(path);
require(!std::filesystem::exists(backup), "initial save created a backup");
const std::vector<nocal::Event> second_events{event_named("second@example.test", "Second", 1)};
const auto second_revision = nocal::storage::IcsStore::save(path, second_events, first_revision);
const std::string second_bytes = read_fixture(path);
require(read_fixture(backup) == first_bytes, "backup does not contain exact previous bytes");
#if !defined(_WIN32)
struct stat metadata {};
require(::stat(backup.c_str(), &metadata) == 0, "could not stat calendar backup");
require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0,
"calendar backup grants group or other permissions");
require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0,
"calendar backup is not owner-readable and owner-writable");
#endif
const std::vector<nocal::Event> third_events{event_named("third@example.test", "Third", 2)};
const auto third_revision = nocal::storage::IcsStore::save(path, third_events, second_revision);
require(read_fixture(backup) == second_bytes, "backup did not rotate to the previous revision");
const std::string backup_before_restore = read_fixture(backup);
const auto restored = nocal::storage::IcsStore::restore_backup(path, third_revision);
require(restored.existed() && restored.size() == backup_before_restore.size(),
"restore returned an incorrect revision");
require(read_fixture(path) == second_bytes, "restore did not install exact backup bytes");
require(read_fixture(backup) == backup_before_restore, "restore changed the backup");
std::string external = read_fixture(path);
external[0] = external[0] == 'B' ? 'X' : 'B';
write_fixture(path, external);
bool conflict = false;
try {
(void)nocal::storage::IcsStore::restore_backup(path, restored);
} catch (const std::runtime_error&) {
conflict = true;
}
require(conflict, "restore ignored an external calendar change");
require(read_fixture(path) == external, "conflicting restore changed the calendar");
require(read_fixture(backup) == backup_before_restore,
"conflicting restore changed the backup");
require_no_temporary_files(path);
}
void test_restore_requires_backup(const std::filesystem::path& root) {
const auto path = root / "missing-backup.ics";
const std::vector<nocal::Event> events{event_named("only@example.test", "Only")};
const auto revision = nocal::storage::IcsStore::save(path, events);
const std::string before = read_fixture(path);
bool failed = false;
try {
(void)nocal::storage::IcsStore::restore_backup(path, revision);
} catch (const std::runtime_error&) {
failed = true;
}
require(failed, "restore unexpectedly succeeded without a backup");
require(read_fixture(path) == before, "failed restore changed the calendar");
require(!std::filesystem::exists(nocal::storage::IcsStore::backup_path(path)),
"failed restore created a backup");
require_no_temporary_files(path);
}
void test_lock_failure_preserves_existing_bytes(const std::filesystem::path& root) {
#if !defined(_WIN32)
const auto path = root / "must-survive.ics";
const std::string original = "existing bytes must survive";
write_fixture(path, original);
const auto backup = nocal::storage::IcsStore::backup_path(path);
write_fixture(backup, "existing backup must survive");
std::filesystem::path lock_path = path;
lock_path += ".lock";
std::filesystem::create_directory(lock_path);
bool failed = false;
try {
nocal::storage::IcsStore::save(path, {});
} catch (const std::runtime_error&) {
failed = true;
}
require(failed, "save unexpectedly succeeded without acquiring its destination lock");
require(read_fixture(path) == original, "failed save modified the existing calendar");
require(read_fixture(backup) == "existing backup must survive",
"failed save modified the existing backup");
require_no_temporary_files(path);
std::filesystem::remove(lock_path);
#else
(void)root;
#endif
}
void test_rename_failure_cleans_temporary_file(const std::filesystem::path& root) {
#if !defined(_WIN32)
const auto path = root / "destination-is-a-directory.ics";
std::filesystem::create_directory(path);
const auto sentinel = path / "existing-data";
write_fixture(sentinel, "must remain");
bool failed = false;
try {
nocal::storage::IcsStore::save(path, {});
} catch (const std::runtime_error&) {
failed = true;
}
require(failed, "save unexpectedly replaced a non-empty directory");
require(read_fixture(sentinel) == "must remain", "failed replacement damaged existing data");
require_no_temporary_files(path);
#else
(void)root;
#endif
}
void test_unsupported_data_is_not_rewrite_safe(const std::filesystem::path& root) {
const auto path = root / "unsupported.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"BEGIN:VEVENT\r\n"
"UID:recurring\r\n"
"DTSTART;TZID=Europe/London:20260717T101500\r\n"
"DTEND;TZID=Europe/London:20260717T111500\r\n"
"SUMMARY;LANGUAGE=en:Recurring appointment\r\n"
"RRULE:FREQ=WEEKLY\r\n"
"ATTENDEE:mailto:person@example.test\r\n"
"BEGIN:VALARM\r\n"
"ACTION:DISPLAY\r\n"
"TRIGGER:-PT10M\r\n"
"END:VALARM\r\n"
"END:VEVENT\r\n"
"X-UNKNOWN-CALENDAR-PROPERTY:keep me\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite,
"unsupported recurrence, alarm, attendee, parameters, or properties were considered safe");
require(!loaded.warnings.empty(), "unsafe calendar did not explain why editing is disabled");
}
} // namespace
int main() {
#if !defined(_WIN32)
setenv("TZ", "UTC", 1);
tzset();
#endif
const auto root = std::filesystem::temp_directory_path() / "nocal-ics-tests";
const auto path = root / "calendar.ics";
try {
std::error_code ignored;
std::filesystem::remove_all(root, ignored);
std::filesystem::create_directories(root);
test_round_trip_and_folding(path);
test_local_utc_and_recovery(path);
test_missing_file(path);
test_nested_creation_and_permissions(root);
test_atomic_replacement(root);
test_revisions_reject_external_changes(root);
test_backup_replacement_failure_preserves_calendar(root);
test_backup_rotation_permissions_and_restore(root);
test_restore_requires_backup(root);
test_lock_failure_preserves_existing_bytes(root);
test_rename_failure_cleans_temporary_file(root);
test_unsupported_data_is_not_rewrite_safe(root);
std::filesystem::remove_all(root, ignored);
std::cout << "ics_tests: ok\n";
return 0;
} catch (const std::exception& error) {
std::error_code ignored;
std::filesystem::remove_all(root, ignored);
std::cerr << "ics_tests: " << error.what() << '\n';
return 1;
}
}

730
tests/tui_focus_tests.cpp Normal file
View File

@@ -0,0 +1,730 @@
#include "nocal/domain/date.hpp"
#include "nocal/tui/Screen.hpp"
#include "nocal/tui/TuiApp.hpp"
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace {
int failures = 0;
void check(const bool condition, const std::string_view message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
nocal::Event make_event(std::string uid, std::string title, const nocal::Date start_day,
const unsigned start_hour, const unsigned start_minute,
const nocal::Date end_day, const unsigned end_hour,
const unsigned end_minute, const bool all_day = false)
{
nocal::Event event;
event.uid = std::move(uid);
event.title = std::move(title);
event.start = nocal::make_local_time(start_day, start_hour, start_minute);
event.end = nocal::make_local_time(end_day, end_hour, end_minute);
event.all_day = all_day;
return event;
}
nocal::tui::ScreenState screen_for(const nocal::Date day, const int width = 100,
const int height = 32)
{
nocal::tui::ScreenState state;
state.visible_month = day.year() / day.month();
state.selected_day = day;
state.today = day;
state.width = width;
state.height = height;
state.ansi = false;
return state;
}
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 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;
}
void replace_editor_title(nocal::tui::TuiApp& app, const std::string_view title)
{
const auto* editor = app.editor();
check(editor != nullptr, "title replacement requires an active editor");
if (editor == nullptr) return;
const auto length = editor->field_value(nocal::tui::EditorField::title).size();
for (std::size_t index = 0; index < length; ++index) app.handle_input("\x7f");
app.handle_input(title);
}
void test_event_focus_cycles_in_display_order()
{
using namespace std::chrono;
const auto day = nocal::today();
const auto tomorrow = nocal::from_sys_days(nocal::to_sys_days(day) + days{1});
std::vector<nocal::Event> events;
events.push_back(make_event("late", "Late review", day, 14, 30, day, 15, 0));
events.push_back(make_event("all-day", "Release day", day, 0, 0, tomorrow, 0, 0, true));
events.push_back(make_event("early", "Morning review", day, 9, 15, day, 10, 0));
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "all-day"), "Tab initially focuses the all-day event shown first");
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "early"), "Tab advances to the earliest timed event");
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "late"), "Tab advances through timed events chronologically");
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "all-day"), "Tab wraps from the final event to the first");
app.dispatch(nocal::tui::Action::previous_event);
check(focus_is(app, "late"), "Shift-Tab moves backwards and wraps");
std::vector<nocal::Event> reverse_events = events;
nocal::tui::TuiApp reverse_app{reverse_events, -1, -1};
reverse_app.dispatch(nocal::tui::Action::previous_event);
check(focus_is(reverse_app, "late"),
"Shift-Tab with no focus starts at the final event of the day");
}
void test_open_close_and_day_movement()
{
using namespace std::chrono;
const auto day = nocal::today();
std::vector<nocal::Event> events{
make_event("reader", "Reader event", day, 11, 0, day, 12, 0),
};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::select);
check(focus_is(app, "reader"), "Enter focuses the first event when none is focused");
check(app.state().show_event_details, "Enter opens the focused event reader");
app.dispatch(nocal::tui::Action::back);
check(!app.state().show_event_details, "Escape closes the event reader");
check(focus_is(app, "reader"), "closing the reader keeps the event focused");
const auto selected = nocal::to_sys_days(app.state().selected_day);
app.dispatch(nocal::tui::Action::right);
check(nocal::to_sys_days(app.state().selected_day) == selected + days{1},
"day navigation still works after closing the reader");
check(!app.state().focused_event_id, "moving to another day clears event focus");
check(!app.state().show_event_details, "moving to another day leaves the reader closed");
}
void test_empty_day_is_safe()
{
std::vector<nocal::Event> events;
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::previous_event);
app.dispatch(nocal::tui::Action::select);
app.dispatch(nocal::tui::Action::back);
check(!app.state().focused_event_id, "event navigation on an empty day keeps focus empty");
check(!app.state().show_event_details, "Enter on an empty day does not open the reader");
}
void test_reader_navigation_and_synthetic_ids()
{
const auto day = nocal::today();
std::vector<nocal::Event> events;
events.push_back(make_event("duplicate", "First duplicate", day, 8, 0, day, 9, 0));
events.push_back(make_event("duplicate", "Second duplicate", day, 10, 0, day, 11, 0));
events.push_back(make_event("", "No UID", day, 12, 0, day, 13, 0));
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::select);
check(focus_is(app, "@nocal:0"), "duplicate UIDs receive a focusable source identity");
check(app.state().show_event_details, "synthetic identities resolve in the reader");
app.dispatch(nocal::tui::Action::down);
check(focus_is(app, "@nocal:1"), "reader Down advances to the next appointment");
app.dispatch(nocal::tui::Action::up);
check(focus_is(app, "@nocal:0"), "reader Up returns to the previous appointment");
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "@nocal:2"), "an empty UID remains focusable and readable");
const auto frame = nocal::tui::render_month(events, app.state());
check(frame.find("No UID") != std::string::npos,
"renderer shares the controller's synthetic identity scheme");
}
void test_key_decoding()
{
using nocal::tui::Action;
using nocal::tui::decode_key;
check(decode_key("\t") == Action::next_event, "Tab decodes as next event");
check(decode_key("\x1b[Z") == Action::previous_event,
"terminal Shift-Tab decodes as previous event");
check(decode_key("\r") == Action::select, "carriage-return Enter decodes as select");
check(decode_key("\n") == Action::select, "newline Enter decodes as select");
check(decode_key("\x1b") == Action::back, "a lone Escape decodes as back");
check(decode_key("\x1b[A") == Action::up, "arrow decoding remains intact");
check(decode_key("a") == Action::add_event, "a decodes as add appointment");
check(decode_key("e") == Action::edit_event, "e decodes as edit appointment");
check(decode_key("d") == Action::delete_event, "d decodes as delete appointment");
check(decode_key("u") == Action::undo, "u decodes as undo");
check(decode_key("\x12") == Action::redo, "Ctrl-R decodes as redo");
}
void test_crud_preconditions_and_add()
{
std::vector<nocal::Event> events;
int save_calls = 0;
std::vector<nocal::Event> saved;
nocal::tui::TuiApp app{
events,
[&](const std::span<const nocal::Event> values) {
++save_calls;
saved.assign(values.begin(), values.end());
},
-1,
-1,
};
app.dispatch(nocal::tui::Action::edit_event);
check(!app.editor_active(), "edit without a focused appointment does not open an editor");
check(app.state().notification_is_error,
"edit without focus explains its appointment precondition");
app.dispatch(nocal::tui::Action::delete_event);
check(!app.delete_confirmation_active(),
"delete without a focused appointment does not open confirmation");
check(app.state().notification_is_error,
"delete without focus explains its appointment precondition");
const auto selected_day = app.state().selected_day;
app.dispatch(nocal::tui::Action::add_event);
check(app.editor_active(), "add opens the event editor on an empty day");
check(app.editor() != nullptr &&
app.editor()->field_value(nocal::tui::EditorField::start_date) ==
nocal::format_date(selected_day),
"add initializes the editor from the selected date");
check(app.editor() != nullptr &&
app.editor()->render(60, 18, false).find("NEW APPOINTMENT") != std::string::npos,
"the active add editor can be rendered by the controller");
app.handle_input("Created locally");
app.handle_input("\x13");
check(!app.editor_active(), "a valid Ctrl-S submit closes the add editor");
check(events.size() == 1 && events.front().title == "Created locally",
"add commits the validated editor result to the local model");
check(save_calls == 1 && saved.size() == 1 && saved.front().title == "Created locally",
"add calls the persistence boundary with the mutated model");
check(!events.front().uid.empty() && focus_is(app, events.front().uid),
"a successful add focuses the new appointment");
check(app.state().notification == "Appointment added." &&
!app.state().notification_is_error,
"a successful add reports a non-error status");
}
void test_edit_preserves_uid_and_rolls_back_on_save_failure()
{
const auto day = nocal::today();
auto original = make_event("stable-uid", "Original title", day, 9, 0, day, 10, 0);
original.location = "Desk";
original.description = "Original notes";
original.calendar_id = "personal";
original.color = "blue";
std::vector<nocal::Event> events{original};
int save_calls = 0;
nocal::tui::TuiApp app{
events,
[&](std::span<const nocal::Event>) {
++save_calls;
throw std::runtime_error{"disk full"};
},
-1,
-1,
};
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::edit_event);
check(app.editor_active() && app.editor() != nullptr && app.editor()->editing(),
"edit opens a populated editing form for the focused appointment");
replace_editor_title(app, "Changed title");
app.handle_input("\x13");
check(save_calls == 1, "edit attempts persistence exactly once");
check(events.size() == 1 && same_event(events.front(), original),
"a throwing saver restores the exact event vector after edit");
check(focus_is(app, "stable-uid"), "edit rollback restores the original focus identity");
check(!app.editor_active(), "save failure returns to the month so its error is visible");
check(app.state().notification_is_error &&
app.state().notification.find("disk full") != std::string::npos,
"save failure is shown to the user with the persistence error");
int successful_saves = 0;
nocal::tui::TuiApp successful_app{
events,
[&](std::span<const nocal::Event>) { ++successful_saves; },
-1,
-1,
};
successful_app.dispatch(nocal::tui::Action::next_event);
successful_app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(successful_app, "Changed title");
successful_app.handle_input("\x13");
check(successful_saves == 1 && events.front().title == "Changed title",
"a successful edit commits and persists its changed fields");
check(events.front().uid == "stable-uid",
"editing preserves the appointment UID used by storage and focus");
}
void test_delete_cancel_confirm_and_rollback()
{
const auto day = nocal::today();
const auto target = make_event("delete-me", "Delete target", day, 9, 0, day, 10, 0);
const auto keep = make_event("keep-me", "Keep target", day, 11, 0, day, 12, 0);
std::vector<nocal::Event> events{target, keep};
int save_calls = 0;
nocal::tui::TuiApp app{
events,
[&](std::span<const nocal::Event>) { ++save_calls; },
-1,
-1,
};
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::delete_event);
check(app.delete_confirmation_active(), "delete requires an explicit confirmation step");
const auto confirmation = nocal::tui::render_month(events, app.state());
check(confirmation.find("Delete target") != std::string::npos &&
confirmation.find("y confirm") != std::string::npos,
"delete confirmation names the appointment and shows its keys");
app.handle_input("n");
check(!app.delete_confirmation_active() && events.size() == 2 && save_calls == 0,
"n cancels deletion without mutating or saving");
check(focus_is(app, "delete-me"), "cancelled deletion retains appointment focus");
app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("y");
check(events.size() == 1 && events.front().uid == "keep-me" && save_calls == 1,
"y deletes the focused appointment and persists once");
check(!app.state().focused_event_id &&
app.state().notification == "Appointment deleted.",
"successful deletion clears stale focus and reports success");
std::vector<nocal::Event> rollback_events{target};
nocal::tui::TuiApp rollback_app{
rollback_events,
[](std::span<const nocal::Event>) { throw std::runtime_error{"read only"}; },
-1,
-1,
};
rollback_app.dispatch(nocal::tui::Action::next_event);
rollback_app.dispatch(nocal::tui::Action::delete_event);
rollback_app.handle_input("y");
check(rollback_events.size() == 1 && same_event(rollback_events.front(), target),
"a throwing saver restores the exact vector after delete");
check(focus_is(rollback_app, "delete-me"),
"delete rollback restores the focused appointment identity");
check(rollback_app.state().notification_is_error,
"delete rollback displays a persistence error");
}
void test_crud_targets_synthetic_identities()
{
const auto day = nocal::today();
std::vector<nocal::Event> events{
make_event("duplicate", "First duplicate", day, 8, 0, day, 9, 0),
make_event("duplicate", "Second duplicate", day, 10, 0, day, 11, 0),
make_event("", "Empty UID", day, 12, 0, day, 13, 0),
};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "@nocal:0"), "the first duplicate starts with its synthetic identity");
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Edited first duplicate");
app.handle_input("\x13");
check(events[0].title == "Edited first duplicate" &&
events[1].title == "Second duplicate" && events[0].uid == "duplicate",
"edit resolves a synthetic identity to exactly the intended source event");
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "@nocal:2"), "empty UID remains targetable after editing a duplicate");
app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("y");
check(events.size() == 2 && events[0].title == "Edited first duplicate" &&
events[1].title == "Second duplicate",
"delete resolves an empty-UID synthetic identity to the intended source event");
}
void test_add_edit_delete_undo_redo()
{
std::vector<nocal::Event> events;
int save_calls = 0;
nocal::tui::TuiApp app{
events,
[&](std::span<const nocal::Event>) { ++save_calls; },
-1,
-1,
};
const auto original_day = app.state().selected_day;
app.dispatch(nocal::tui::Action::add_event);
app.handle_input("History event");
app.handle_input("\x13");
check(events.size() == 1, "history test creates an appointment");
const auto added = events.front();
check(focus_is(app, added.uid), "successful add establishes the post-mutation focus");
app.dispatch(nocal::tui::Action::undo);
check(events.empty() && !app.state().focused_event_id &&
app.state().selected_day == original_day,
"undo add restores the exact pre-add model and selection state");
check(app.state().notification == "Undid add appointment.",
"undo add names the reversed operation");
app.dispatch(nocal::tui::Action::redo);
check(events.size() == 1 && same_event(events.front(), added) && focus_is(app, added.uid),
"redo add restores the exact event metadata and post-add focus");
app.dispatch(nocal::tui::Action::select);
check(app.state().show_event_details, "reader is open before starting the edit");
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Edited through history");
app.handle_input("\x13");
const auto edited = events.front();
check(edited.title == "Edited through history", "history test commits an edit");
app.dispatch(nocal::tui::Action::undo);
check(events.size() == 1 && same_event(events.front(), added) && focus_is(app, added.uid) &&
app.state().show_event_details,
"undo edit restores metadata, focus, and the pre-editor reader state");
app.dispatch(nocal::tui::Action::redo);
check(events.size() == 1 && same_event(events.front(), edited) && focus_is(app, edited.uid) &&
!app.state().show_event_details,
"redo edit restores the exact edited model and post-editor view state");
app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("y");
check(events.empty(), "history test commits a deletion");
app.dispatch(nocal::tui::Action::undo);
check(events.size() == 1 && same_event(events.front(), edited) && focus_is(app, edited.uid),
"undo delete restores the exact deleted event and its focus");
app.dispatch(nocal::tui::Action::redo);
check(events.empty() && !app.state().focused_event_id,
"redo delete restores the empty model and cleared focus");
check(save_calls == 9,
"three mutations and their undo/redo operations each persist exactly once");
}
void test_history_persistence_failure_is_transactional()
{
const auto day = nocal::today();
const auto original = make_event("history-failure", "Original", day, 9, 0, day, 10, 0);
std::vector<nocal::Event> events{original};
bool fail = false;
int save_calls = 0;
nocal::tui::TuiApp app{
events,
[&](std::span<const nocal::Event>) {
++save_calls;
if (fail) throw std::runtime_error{"history disk failure"};
},
-1,
-1,
};
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Changed");
app.handle_input("\x13");
const auto changed = events.front();
fail = true;
app.dispatch(nocal::tui::Action::undo);
check(events.size() == 1 && same_event(events.front(), changed) &&
focus_is(app, "history-failure"),
"failed undo persistence leaves the current model and focus untouched");
check(app.state().notification_is_error &&
app.state().notification.find("history disk failure") != std::string::npos,
"failed undo reports the persistence error");
fail = false;
app.dispatch(nocal::tui::Action::undo);
check(events.size() == 1 && same_event(events.front(), original),
"undo remains available after a failed persistence attempt");
fail = true;
app.dispatch(nocal::tui::Action::redo);
check(events.size() == 1 && same_event(events.front(), original) &&
focus_is(app, "history-failure"),
"failed redo persistence leaves the restored model and focus untouched");
fail = false;
app.dispatch(nocal::tui::Action::redo);
check(events.size() == 1 && same_event(events.front(), changed),
"redo remains available after a failed persistence attempt");
check(save_calls == 5, "successful mutation plus four history attempts call the saver");
}
void test_new_mutation_invalidates_redo()
{
const auto day = nocal::today();
std::vector<nocal::Event> events{
make_event("redo-invalidation", "Original", day, 9, 0, day, 10, 0),
};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "First branch");
app.handle_input("\x13");
app.dispatch(nocal::tui::Action::undo);
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Second branch");
app.handle_input("\x13");
app.dispatch(nocal::tui::Action::redo);
check(events.front().title == "Second branch",
"a new successful mutation after undo invalidates the redo branch");
check(app.state().notification_is_error && app.state().notification == "Nothing to redo.",
"an invalidated redo branch gives contextual feedback");
}
void test_history_is_bounded_to_one_hundred_mutations()
{
const auto day = nocal::today();
std::vector<nocal::Event> events{
make_event("bounded-history", "Version 0", day, 9, 0, day, 10, 0),
};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
for (int version = 1; version <= 101; ++version) {
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Version " + std::to_string(version));
app.handle_input("\x13");
}
for (int count = 0; count < 100; ++count) app.dispatch(nocal::tui::Action::undo);
check(events.front().title == "Version 1",
"the bounded history retains the most recent one hundred mutations");
app.dispatch(nocal::tui::Action::undo);
check(events.front().title == "Version 1" &&
app.state().notification == "Nothing to undo.",
"the oldest mutation is discarded once the one-hundred entry bound is reached");
}
void test_history_is_unavailable_in_modal_states()
{
const auto day = nocal::today();
std::vector<nocal::Event> events{
make_event("modal-history", "Modal event", day, 9, 0, day, 10, 0),
};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::add_event);
app.dispatch(nocal::tui::Action::undo);
check(app.editor_active() && app.state().notification_is_error,
"undo is unavailable while the editor owns input");
app.dispatch(nocal::tui::Action::back);
app.dispatch(nocal::tui::Action::next_event);
auto footer_state = screen_for(day, 180, 32);
footer_state.focused_event_id = "modal-history";
const auto month_frame = nocal::tui::render_month(events, footer_state);
check(month_frame.find("u undo") != std::string::npos &&
month_frame.find("Ctrl-R redo") != std::string::npos,
"the month footer advertises both history shortcuts");
app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("u");
check(app.delete_confirmation_active() && app.state().notification_is_error &&
app.state().notification.find("deletion") != std::string::npos,
"history shortcuts are unavailable during delete confirmation");
const auto confirmation = nocal::tui::render_month(events, app.state());
check(confirmation.find("Finish or cancel deletion") != std::string::npos,
"delete confirmation renders its contextual history error");
app.handle_input("n");
}
void test_focused_line_rendering()
{
using namespace std::chrono;
const auto day = year{2026} / July / std::chrono::day{17};
auto state = screen_for(day, 112, 38);
state.focused_event_id = "focused";
const std::vector<nocal::tui::CalendarItem> items{
{.id = "plain",
.title = "Plain item",
.day = day,
.time_of_day = hours{8},
.all_day = false,
.end_time_of_day = hours{8} + minutes{30},
.start_day = day,
.end_day = day,
.location = {},
.description = {},
.detail_title = "Plain item",
.detail_all_day = false,
.detail_start_time_of_day = hours{8}},
{.id = "focused",
.title = "Focused item",
.day = day,
.time_of_day = hours{9} + minutes{15},
.all_day = false,
.end_time_of_day = hours{10},
.start_day = day,
.end_day = day,
.location = {},
.description = {},
.detail_title = "Focused item",
.detail_all_day = false,
.detail_start_time_of_day = hours{9} + minutes{15}},
};
const auto frame = nocal::tui::render_month(items, state);
check(frame.find(" 09:15 Focu") != std::string::npos,
"the focused appointment line has a visible cursor when ANSI is disabled");
check(frame.find(" 08:00 Plain") == std::string::npos,
"the focus cursor is only rendered on the focused appointment");
check(frame.find("\x1b[") == std::string::npos,
"focused line rendering respects disabled ANSI styling");
}
void test_focus_brings_hidden_event_into_view()
{
using namespace std::chrono;
const auto day = year{2026} / July / std::chrono::day{17};
auto state = screen_for(day, 100, 30);
state.focused_event_id = "last";
std::vector<nocal::tui::CalendarItem> items;
for (unsigned index = 0; index < 6; ++index) {
nocal::tui::CalendarItem item;
item.id = index == 5 ? "last" : "event-" + std::to_string(index);
item.title = index == 5 ? "Hidden focus target" : "Earlier event";
item.day = day;
item.time_of_day = hours{8 + index};
items.push_back(std::move(item));
}
const auto frame = nocal::tui::render_month(items, state);
check(frame.find(" 13:00") != std::string::npos,
"focused overflow event is windowed into the visible cell");
check(frame.find("hidden") != std::string::npos,
"dense cells retain an overflow indicator while focus is windowed");
}
void test_timed_event_details()
{
using namespace std::chrono;
const auto day = year{2026} / July / std::chrono::day{17};
auto event = make_event("detail", "Architecture review", day, 9, 15, day, 10, 45);
event.location = "Studio 4B";
event.description = "Review keyboard navigation and approve the terminal layout.";
const std::vector<nocal::Event> events{event};
auto state = screen_for(day, 80, 24);
state.focused_event_id = "detail";
state.show_event_details = true;
const auto frame = nocal::tui::render_month(events, state);
check(frame.find("APPOINTMENT") != std::string::npos, "the reader has a clear heading");
check(frame.find("Architecture review") != std::string::npos,
"the reader renders the full appointment title");
check(frame.find("Friday, 17 July 2026") != std::string::npos,
"the reader renders the full friendly date");
check(frame.find("09:15 10:45") != std::string::npos,
"the reader renders the complete local time range");
check(frame.find("Studio 4B") != std::string::npos,
"the reader renders the event location");
check(frame.find("Review keyboard navigation and approve the terminal layout.") !=
std::string::npos,
"the reader renders the event description");
check(frame.find("Esc back") != std::string::npos, "the reader footer explains how to close it");
}
void test_all_day_and_multiday_details()
{
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};
const auto after = year{2026} / July / std::chrono::day{19};
const std::vector<nocal::Event> all_day{
make_event("festival", "Terminal festival", first, 0, 0, after, 0, 0, true),
};
auto all_day_state = screen_for(middle, 80, 24);
all_day_state.focused_event_id = "festival";
all_day_state.show_event_details = true;
const auto all_day_frame = nocal::tui::render_month(all_day, all_day_state);
check(all_day_frame.find("Time All day") != std::string::npos,
"all-day events are described as all day");
check(all_day_frame.find("Ends Saturday, 18 July 2026") != std::string::npos,
"exclusive all-day ends are presented as the last occupied day");
const std::vector<nocal::Event> timed{
make_event("overnight", "Long migration", first, 20, 0, last, 11, 30),
};
auto timed_state = screen_for(middle, 80, 24);
timed_state.focused_event_id = "overnight";
timed_state.show_event_details = true;
const auto timed_frame = nocal::tui::render_month(timed, timed_state);
check(timed_frame.find("Thursday, 16 July 2026") != std::string::npos,
"a multi-day reader retains the event's actual start date");
check(timed_frame.find("Ends Saturday, 18 July 2026") != std::string::npos,
"a timed multi-day reader renders its end date");
check(timed_frame.find("20:00 11:30") != std::string::npos,
"a timed multi-day continuation retains its real times");
check(timed_frame.find("Time All day") == std::string::npos,
"a timed multi-day continuation is not mislabeled all day");
}
void test_narrow_detail_wrapping_without_ansi()
{
using namespace std::chrono;
const auto day = year{2026} / July / std::chrono::day{17};
auto event = make_event("wrap", "Wrap check", day, 13, 0, day, 14, 0);
event.description = "Alpha beta gamma delta epsilon zeta eta theta.";
const std::vector<nocal::Event> events{event};
auto state = screen_for(day, 32, 20);
state.focused_event_id = "wrap";
state.show_event_details = true;
const auto frame = nocal::tui::render_month(events, state);
check(frame.find("Alpha beta gamma delta epsilon zeta eta theta.") == std::string::npos,
"narrow readers wrap descriptions instead of overflowing a line");
check(frame.find("Alpha beta gamma delta") != std::string::npos,
"the first wrapped description line is retained");
check(frame.find("epsilon zeta eta theta.") != std::string::npos,
"the remaining wrapped description text is retained");
check(frame.find("\x1b[") == std::string::npos,
"narrow detail rendering emits no ANSI when styling is disabled");
}
} // namespace
int main()
{
test_event_focus_cycles_in_display_order();
test_open_close_and_day_movement();
test_empty_day_is_safe();
test_reader_navigation_and_synthetic_ids();
test_key_decoding();
test_crud_preconditions_and_add();
test_edit_preserves_uid_and_rolls_back_on_save_failure();
test_delete_cancel_confirm_and_rollback();
test_crud_targets_synthetic_identities();
test_add_edit_delete_undo_redo();
test_history_persistence_failure_is_transactional();
test_new_mutation_invalidates_redo();
test_history_is_bounded_to_one_hundred_mutations();
test_history_is_unavailable_in_modal_states();
test_focused_line_rendering();
test_focus_brings_hidden_event_into_view();
test_timed_event_details();
test_all_day_and_multiday_details();
test_narrow_detail_wrapping_without_ansi();
if (failures != 0) {
std::cerr << failures << " TUI focus test(s) failed\n";
return EXIT_FAILURE;
}
std::cout << "TUI focus tests passed\n";
return EXIT_SUCCESS;
}

115
tests/tui_tests.cpp Normal file
View File

@@ -0,0 +1,115 @@
#include "nocal/domain/date.hpp"
#include "nocal/tui/Screen.hpp"
#include "nocal/tui/TuiApp.hpp"
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <optional>
#include <string>
#include <vector>
namespace {
int failures = 0;
void check(bool condition, const char* message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
void test_full_month_render()
{
using namespace std::chrono;
const nocal::tui::ScreenState state{
.visible_month = year{2026} / July,
.selected_day = year{2026} / July / day{17},
.today = year{2026} / July / day{17},
.width = 100,
.height = 30,
.show_help = false,
.ansi = false,
.focused_event_id = std::nullopt,
.show_event_details = false,
.confirm_delete = false,
.notification = {},
.notification_is_error = false,
};
const std::vector<nocal::tui::CalendarItem> items{
{.id = "1", .title = "Design review", .day = year{2026} / July / day{17},
.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},
{.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},
};
const auto frame = nocal::tui::render_month(items, state);
check(frame.find("Design") != std::string::npos, "appointment appears in its day cell");
check(frame.find("Release") != std::string::npos, "all-day appointment appears in its day cell");
check(frame.find("July 2026") != std::string::npos, "month title is rendered");
check(std::count(frame.begin(), frame.end(), '\n') == 29,
"renderer fills the requested height deterministically");
check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled");
}
void test_compact_and_input()
{
using namespace std::chrono;
const nocal::tui::ScreenState state{
.visible_month = year{2026} / July,
.selected_day = year{2026} / July / day{17},
.today = year{2026} / July / day{17},
.width = 30,
.height = 12,
.show_help = false,
.ansi = false,
.focused_event_id = std::nullopt,
.show_event_details = false,
.confirm_delete = false,
.notification = {},
.notification_is_error = false,
};
const auto frame = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
check(frame.find("July 2026") != std::string::npos, "compact mode keeps month context");
check(nocal::tui::decode_key("\x1b[6~") == nocal::tui::Action::next_month,
"PageDown is decoded");
check(nocal::tui::decode_key("k") == nocal::tui::Action::up, "Vim movement is decoded");
}
void test_navigation()
{
using namespace std::chrono;
std::vector<nocal::Event> events;
nocal::tui::TuiApp app{events, -1, -1};
const auto initial = app.state().selected_day;
app.dispatch(nocal::tui::Action::right);
check(sys_days{app.state().selected_day} == sys_days{initial} + days{1},
"right moves by one civil day");
app.dispatch(nocal::tui::Action::down);
check(sys_days{app.state().selected_day} == sys_days{initial} + days{8},
"down moves by one week");
}
} // namespace
int main()
{
test_full_month_render();
test_compact_and_input();
test_navigation();
if (failures != 0) {
std::cerr << failures << " TUI test(s) failed\n";
return EXIT_FAILURE;
}
std::cout << "TUI tests passed\n";
return EXIT_SUCCESS;
}