#include "nocal/domain/date.hpp" #include "nocal/tui/EventEditor.hpp" #include #include #include #include #include #include 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(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; }