#include "nocal/domain/date.hpp" #include "nocal/tui/Screen.hpp" #include "nocal/tui/TuiApp.hpp" #include #include #include #include #include #include #include #include #include namespace { int failures = 0; void check(bool condition, const char* message) { if (!condition) { ++failures; std::cerr << "FAIL: " << message << '\n'; } } std::string_view frame_line(const std::string& frame, std::size_t requested) { std::size_t start = 0; for (std::size_t line = 0; line < requested; ++line) { const auto newline = frame.find('\n', start); if (newline == std::string::npos) return {}; start = newline + 1; } const auto newline = frame.find('\n', start); return std::string_view{frame}.substr( start, newline == std::string::npos ? frame.size() - start : newline - start); } bool ordered(std::string_view line, const std::vector& labels) { std::size_t position = 0; for (const auto label : labels) { position = line.find(label, position); if (position == std::string_view::npos) return false; position += label.size(); } return true; } // Codepoint count; every glyph used in these frames is one cell wide. std::size_t cell_width(std::string_view line) { return static_cast(std::count_if(line.begin(), line.end(), [](const char c) { return (static_cast(c) & 0xc0U) != 0x80U; })); } bool frame_lines_exact_width(const std::string& frame, const int width) { std::size_t start = 0; while (start <= frame.size()) { const auto end = frame.find('\n', start); const auto line = std::string_view{frame}.substr( start, end == std::string::npos ? frame.size() - start : end - start); if (cell_width(line) != static_cast(width)) return false; if (end == std::string::npos) break; start = end + 1; } return true; } nocal::tui::ScreenState base_state(const int width, const int height) { using namespace std::chrono; auto state = nocal::tui::ScreenState{}; state.visible_month = year{2026} / July; state.selected_day = year{2026} / July / day{17}; state.today = year{2026} / July / day{17}; state.width = width; state.height = height; state.ansi = false; state.week_start = Monday; return state; } nocal::Event all_day_event(std::string uid, std::string title, nocal::Date start, nocal::Date exclusive_end) { nocal::Event event; event.uid = std::move(uid); event.title = std::move(title); event.start = nocal::make_local_time(start); event.end = nocal::make_local_time(exclusive_end); event.all_day = true; event.time_basis = nocal::TimeBasis::floating; return event; } 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, .search_prompt = false, .show_search_results = false, .search_query = {}, .search_results = {}, .search_result_index = 0, .notification = {}, .notification_is_error = false, .show_calendar_picker = false, .calendar_index = 0, .show_agenda = false, .agenda_start_day = year{1970} / January / day{1}, .agenda_items = {}, .agenda_index = 0, .week_start = Monday, }; const std::vector 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, .segment = nocal::tui::ItemSegment::single, .recurring = false, .recurrence_summary = {}, .source_time_zone = {}}, {.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, .segment = nocal::tui::ItemSegment::single, .recurring = false, .recurrence_summary = {}, .source_time_zone = {}}, }; 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(frame.find("g agenda") != std::string::npos, "month footer advertises the agenda view"); 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"); check(frame_lines_exact_width(frame, 100), "month frame lines fill the exact width"); check(frame.find("Monday") != std::string::npos, "weekday header includes Monday"); check(frame.find("Tuesday") != std::string::npos, "weekday header includes Tuesday"); } 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, .search_prompt = false, .show_search_results = false, .search_query = {}, .search_results = {}, .search_result_index = 0, .notification = {}, .notification_is_error = false, .show_calendar_picker = false, .calendar_index = 0, .show_agenda = false, .agenda_start_day = year{1970} / January / day{1}, .agenda_items = {}, .agenda_index = 0, .week_start = Monday, }; const auto frame = nocal::tui::render_month(std::span{}, 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"); check(nocal::tui::decode_key("c") == nocal::tui::Action::calendars, "c opens the calendar picker"); } void test_sunday_start_full_render_and_domain_window() { using namespace std::chrono; auto state = nocal::tui::ScreenState{}; state.visible_month = year{2026} / July; state.selected_day = year{2026} / July / day{1}; state.today = year{2026} / July / day{17}; state.width = 140; state.height = 34; state.ansi = false; state.week_start = Sunday; const std::vector events{ all_day_event("leading", "Leading Sunday", year{2026} / June / day{28}, year{2026} / June / day{29}), all_day_event("trailing", "Trailing Saturday", year{2026} / August / day{8}, year{2026} / August / day{9}), all_day_event("outside", "Outside Saturday", year{2026} / June / day{27}, year{2026} / June / day{28}), }; const auto frame = nocal::tui::render_month(events, state); const auto header = frame_line(frame, 1); check(ordered(header, {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}), "Sunday start rotates the full weekday header"); check(frame_line(frame, 3).starts_with("│ 28"), "Sunday start places the June 28 spill day in the first cell"); check(frame.find("Leading Sunday") != std::string::npos && frame.find("Trailing") != std::string::npos, "domain adapter includes events at both ends of the configured 42-day grid"); check(frame.find("Outside Saturday") == std::string::npos, "domain adapter excludes an event before the configured grid"); check(frame.find("\x1b[") == std::string::npos, "Sunday-start full rendering supports ANSI-off output"); check(std::count(frame.begin(), frame.end(), '\n') == 33, "Sunday-start full rendering preserves exact requested height"); } void test_wednesday_start_compact_render() { using namespace std::chrono; auto state = nocal::tui::ScreenState{}; state.visible_month = year{2026} / July; state.selected_day = year{2026} / July / day{1}; state.today = year{2026} / July / day{17}; state.width = 30; state.height = 12; state.ansi = false; state.week_start = Wednesday; const std::vector items{ {.id = "trailing", .title = "Last spill", .day = year{2026} / August / day{11}, .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, .segment = nocal::tui::ItemSegment::single, .recurring = false, .recurrence_summary = {}, .source_time_zone = {}}, }; const auto frame = nocal::tui::render_month(items, state); check(ordered(frame_line(frame, 1), {"We", "Th", "Fr", "Sa", "Su", "Mo", "Tu"}), "Wednesday start rotates the compact weekday header"); check(frame_line(frame, 2).starts_with(" 1 "), "Wednesday start places July 1 in the first compact cell"); check(frame_line(frame, 7).find("11•") != std::string_view::npos, "compact rendering marks an event in the final spill cell"); check(frame.find("\x1b[") == std::string::npos, "Wednesday-start compact rendering supports ANSI-off output"); check(std::count(frame.begin(), frame.end(), '\n') == 11, "Wednesday-start compact rendering preserves exact requested height"); } void test_monday_default_and_invalid_week_start_fallback() { using namespace std::chrono; auto default_state = nocal::tui::ScreenState{}; default_state.visible_month = year{2026} / July; default_state.selected_day = year{2026} / July / day{17}; default_state.today = default_state.selected_day; default_state.width = 72; default_state.height = 20; default_state.ansi = false; auto explicit_monday = default_state; explicit_monday.week_start = Monday; const auto default_frame = nocal::tui::render_month( std::span{}, default_state); const auto explicit_frame = nocal::tui::render_month( std::span{}, explicit_monday); check(default_frame == explicit_frame, "default week start remains byte-compatible with explicit Monday"); check(ordered(frame_line(default_frame, 1), {"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"}), "default full header remains Monday-first"); auto invalid = default_state; invalid.week_start = weekday{8}; const auto invalid_frame = nocal::tui::render_month( std::span{}, invalid); check(invalid_frame == default_frame, "invalid week-start state safely falls back to Monday"); } void test_calendar_picker_frame() { using namespace std::chrono; auto state = nocal::tui::ScreenState{}; state.visible_month = year{2026} / July; state.selected_day = year{2026} / July / day{17}; state.today = state.selected_day; state.width = 52; state.height = 10; state.ansi = false; state.show_calendar_picker = true; state.calendar_index = 1; const std::vector calendars{ {.id = "", .name = "Default", .color = {}, .visible = true, .read_only = false}, {.id = "work", .name = "Work", .color = {}, .visible = false, .read_only = false}, }; const auto frame = nocal::tui::render_month( std::span{}, calendars, state); check(frame.find("CALENDARS") != std::string::npos && frame.find("1 of 2 visible") != std::string::npos && frame.find("[x] Default") != std::string::npos && frame.find("[ ] Work") != std::string::npos, "calendar picker renders visibility and selection context"); check(std::count(frame.begin(), frame.end(), '\n') == 9, "calendar picker fills the requested height"); check(frame.find("\x1b[") == std::string::npos, "calendar picker remains readable without ANSI styling"); } void test_navigation() { using namespace std::chrono; std::vector 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"); } void test_search_frames() { using namespace std::chrono; auto state = nocal::tui::ScreenState{}; state.visible_month = year{2026} / July; state.selected_day = year{2026} / July / day{17}; state.today = state.selected_day; state.width = 72; state.height = 12; state.ansi = false; state.search_prompt = true; state.search_query = "team"; auto frame = nocal::tui::render_month( std::span{}, state); check(frame.find("SEARCH APPOINTMENTS") != std::string::npos && frame.find("/ team_") != std::string::npos, "search prompt renders its query and context"); check(std::count(frame.begin(), frame.end(), '\n') == 11, "search prompt fills the requested height"); state.search_prompt = false; state.show_search_results = true; state.search_results = { {.focus_id = "one", .title = "Team planning", .day = year{2026} / July / day{18}, .time_of_day = hours{9}, .all_day = false, .location = "Studio", .recurring = false}, {.focus_id = "two", .title = "Team holiday", .day = year{2026} / July / day{19}, .time_of_day = std::nullopt, .all_day = true, .location = {}, .recurring = true}, }; state.search_result_index = 1; frame = nocal::tui::render_month( std::span{}, state); check(frame.find("2 matches") != std::string::npos && frame.find("2026-07-18 09:00") != std::string::npos && frame.find("Team holiday") != std::string::npos, "search results render counts, dates, times, and titles"); check(frame.find("\x1b[") == std::string::npos, "search remains readable with ANSI disabled"); check(nocal::tui::decode_key("/") == nocal::tui::Action::search, "slash opens appointment search"); } void test_agenda_empty_frame() { using namespace std::chrono; auto state = nocal::tui::ScreenState{}; state.width = 72; state.height = 9; state.ansi = false; state.show_agenda = true; state.agenda_start_day = year{2026} / July / day{6}; const auto frame = nocal::tui::render_month( std::span{}, state); check(frame.find("AGENDA") != std::string::npos && frame.find("2026-07-06 – 2026-08-16 (inclusive)") != std::string::npos, "empty agenda renders its inclusive 42-day range"); check(frame.find("No appointments in this 6-week window.") != std::string::npos, "empty agenda explains that its window has no appointments"); check(frame.find("g close") != std::string::npos, "agenda footer explains how to close the view"); check(std::count(frame.begin(), frame.end(), '\n') == 8, "empty agenda fills the requested height"); check(frame.find("\x1b[") == std::string::npos, "agenda remains readable with ANSI disabled"); } void test_agenda_selection_and_scrolling() { using namespace std::chrono; auto state = nocal::tui::ScreenState{}; state.width = 88; state.height = 7; state.ansi = true; state.show_agenda = true; state.agenda_start_day = year{2026} / July / day{6}; state.agenda_items = { {.focus_id = "late", .title = "Release", .day = year{2026} / July / day{12}, .time_of_day = hours{14} + minutes{30}, .all_day = false, .location = "Studio", .calendar_id = "work", .recurring = true}, {.focus_id = "early", .title = "Holiday", .day = year{2026} / July / day{7}, .time_of_day = std::nullopt, .all_day = true, .location = {}, .calendar_id = "home", .recurring = false}, {.focus_id = "middle", .title = "Planning", .day = year{2026} / July / day{9}, .time_of_day = hours{9}, .all_day = false, .location = {}, .calendar_id = "work", .recurring = false}, {.focus_id = "selected", .title = "Retrospective", .day = year{2026} / July / day{13}, .time_of_day = hours{16}, .all_day = false, .location = "Room 2", .calendar_id = "work", .recurring = false}, {.focus_id = "last", .title = "Dinner", .day = year{2026} / July / day{14}, .time_of_day = hours{19}, .all_day = false, .location = {}, .calendar_id = "home", .recurring = false}, }; state.agenda_index = 3; const auto frame = nocal::tui::render_month( std::span{}, state); // With 1-row footer, row_capacity = 4, showing indices 0-3 check(frame.find("Holiday") != std::string::npos && frame.find("2026-07-12 14:30 local ↻ Release — Studio") != std::string::npos, "agenda shows items with the selected item visible"); check(frame.find("› 2026-07-13 16:00 local Retrospective — Room 2") != std::string::npos, "agenda marks the selected row and includes optional location"); check(frame.find("\x1b[7;1m") != std::string::npos, "ANSI agenda gives the selected row visible focus"); check(std::count(frame.begin(), frame.end(), '\n') == 6, "scrolling agenda fills the requested height"); } void test_agenda_input_and_search_precedence() { auto state = nocal::tui::ScreenState{}; state.width = 50; state.height = 6; state.ansi = false; state.show_agenda = true; state.search_prompt = true; const auto frame = nocal::tui::render_month( std::span{}, state); check(frame.find("SEARCH APPOINTMENTS") != std::string::npos && frame.find("AGENDA") == std::string::npos, "search rendering takes precedence over agenda rendering"); check(nocal::tui::decode_key("g") == nocal::tui::Action::agenda, "g toggles the agenda view"); } } // namespace void test_help_frame() { using namespace std::chrono; auto state = base_state(80, 24); state.show_help = true; const auto frame = nocal::tui::render_month(std::span{}, state); check(frame.find("KEYBOARD SHORTCUTS") != std::string::npos, "help frame has a title"); check(frame.find("NAVIGATE") != std::string::npos && frame.find("APPOINTMENTS") != std::string::npos && frame.find("VIEWS") != std::string::npos && frame.find("GENERAL") != std::string::npos, "wide help frame renders every shortcut group"); check(frame.find("Redo last change") != std::string::npos && frame.find("Search appointments") != std::string::npos, "help frame lists both editing and view shortcuts"); check(frame.find("? close") != std::string::npos && frame.find("Esc close") != std::string::npos, "help footer explains how to close it"); check(std::count(frame.begin(), frame.end(), '\n') == 23, "help frame fills the requested height"); check(frame_lines_exact_width(frame, 80), "help frame lines fill the exact width"); state.width = 60; const auto narrow = nocal::tui::render_month(std::span{}, state); check(narrow.find("NAVIGATE") != std::string::npos && narrow.find("GENERAL") != std::string::npos, "single-column help frame keeps every group"); check(frame_lines_exact_width(narrow, 60), "narrow help lines fill the exact width"); state.ansi = true; const auto styled = nocal::tui::render_month(std::span{}, state); check(styled.find("\x1b[1m") != std::string::npos, "help keys are bold when ANSI is on"); } void test_status_bar_chrome() { using namespace std::chrono; const std::vector 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, .segment = nocal::tui::ItemSegment::single, .recurring = false, .recurrence_summary = {}, .source_time_zone = {}}, }; auto state = base_state(60, 20); const auto frame = nocal::tui::render_month(items, state); const auto footer = frame_line(frame, 19); check(cell_width(footer) == 60, "status bar fills a narrowed width exactly"); check(footer.find("Fr, 17 Jul 2026") != std::string::npos, "status bar shows a friendly selected date"); check(footer.find("Tab focus") != std::string::npos && footer.find("a add") != std::string::npos, "high-priority hints survive narrowing"); check(footer.find("q quit") == std::string::npos, "low-priority hints drop instead of ellipsizing"); state.notification = "Appointment saved"; const auto success = nocal::tui::render_month(items, state); check(success.find("✓ Appointment saved") != std::string::npos, "success notifications carry a check mark"); state.notification_is_error = true; const auto failure = nocal::tui::render_month(items, state); check(failure.find("! Appointment saved") != std::string::npos, "error notifications carry a warning mark"); state = base_state(30, 12); const auto compact = nocal::tui::render_month(items, state); check(frame_lines_exact_width(compact, 30), "compact frame hint lines never overflow their width"); } int main() { test_full_month_render(); test_compact_and_input(); test_help_frame(); test_status_bar_chrome(); test_sunday_start_full_render_and_domain_window(); test_wednesday_start_compact_render(); test_monday_default_and_invalid_week_start_fallback(); test_navigation(); test_search_frames(); test_calendar_picker_frame(); test_agenda_empty_frame(); test_agenda_selection_and_scrolling(); test_agenda_input_and_search_precedence(); if (failures != 0) { std::cerr << failures << " TUI test(s) failed\n"; return EXIT_FAILURE; } std::cout << "TUI tests passed\n"; return EXIT_SUCCESS; }