feat: initialize Nocal terminal calendar
This commit is contained in:
83
tests/cli_recovery_test.sh
Normal file
83
tests/cli_recovery_test.sh
Normal 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
134
tests/domain_tests.cpp
Normal 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
234
tests/editor_tests.cpp
Normal 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
448
tests/ics_tests.cpp
Normal 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
730
tests/tui_focus_tests.cpp
Normal 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
115
tests/tui_tests.cpp
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user