#include "nocal/domain/date.hpp" #include "nocal/tui/EventEditor.hpp" #include #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"); } nocal::TimePoint zoned_time(const std::string_view zone_name, const nocal::Date date, const unsigned hour, const unsigned minute) { const auto* zone = std::chrono::locate_zone(zone_name); const auto local = std::chrono::local_days{date} + std::chrono::hours{hour} + std::chrono::minutes{minute}; return zone->to_sys(local, std::chrono::choose::earliest); } void test_zoned_edit_preserves_series_and_rejects_dst_gap() { using namespace std::chrono; nocal::Event event; event.uid = "zoned-series"; event.title = "New York standup"; event.time_basis = nocal::TimeBasis::zoned; event.time_zone = "America/New_York"; event.start = zoned_time(event.time_zone, nocal::Date{2026y / March / 7d}, 2, 30); event.end = zoned_time(event.time_zone, nocal::Date{2026y / March / 7d}, 3, 30); event.recurrence = nocal::RecurrenceRule{ .frequency = nocal::RecurrenceFrequency::daily, .interval = 1, .count = 4, .until = std::nullopt, .by_weekday = {}, .by_month_day = {}, .by_month = {}, }; event.recurrence_exceptions.push_back( zoned_time(event.time_zone, nocal::Date{2026y / March / 9d}, 2, 30)); nocal::tui::EventEditor editor{event}; check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-03-07" && editor.field_value(nocal::tui::EditorField::start_time) == "02:30", "a zoned editor displays civil values in the event source timezone"); const auto form = editor.render(80, 18, false); check(form.find("America/New_York") != std::string::npos, "a zoned editor explicitly labels its source timezone"); check(form.find("EDIT ENTIRE RECURRING SERIES") != std::string::npos, "the editor clearly states that edits affect the recurring series"); replace_field(editor, "Renamed standup"); check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit, "a valid zoned series edit submits"); check(editor.result().time_basis == nocal::TimeBasis::zoned && editor.result().time_zone == "America/New_York" && editor.result().recurrence == event.recurrence && editor.result().recurrence_exceptions == event.recurrence_exceptions, "editing preserves time basis, TZID, recurrence, and exceptions"); nocal::tui::EventEditor series_kind_editor{event}; focus(series_kind_editor, nocal::tui::EditorField::all_day); (void)series_kind_editor.handle_key(" "); check(series_kind_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active && series_kind_editor.focused_field() == nocal::tui::EditorField::all_day && series_kind_editor.error().find("recurring series") != std::string_view::npos, "a recurring series rejects timed/all-day conversion to preserve its exceptions"); auto one_off = event; one_off.recurrence.reset(); one_off.recurrence_exceptions.clear(); nocal::tui::EventEditor all_day_editor{one_off}; focus(all_day_editor, nocal::tui::EditorField::all_day); (void)all_day_editor.handle_key(" "); check(all_day_editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit && all_day_editor.result().all_day && all_day_editor.result().time_basis == nocal::TimeBasis::floating && all_day_editor.result().time_zone.empty(), "a one-off zoned event converted to all-day normalizes to floating DATE semantics"); nocal::tui::EventEditor gap_editor{event}; focus(gap_editor, nocal::tui::EditorField::start_date); replace_field(gap_editor, "2026-03-08"); check(gap_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active && gap_editor.error().find("cannot be represented") != std::string_view::npos, "a nonexistent source-zone civil time in the DST gap is rejected"); } } // 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(); test_zoned_edit_preserves_series_and_rejects_dst_gap(); if (failures != 0) { std::cerr << failures << " editor test(s) failed\n"; return EXIT_FAILURE; } std::cout << "editor tests passed\n"; return EXIT_SUCCESS; }