Files
nocal/tests/tui_tests.cpp
Bernardo Magri 6d9e7b0e3e feat(tui): redesign chrome with unified key hints and help overlay
Introduce one key-hint grammar across every view: bold key, dim label,
priority-ordered hints that drop from the end instead of ellipsizing.
The month footer becomes a single split status bar with a friendly
selected date, the '?' mega-line becomes a real keyboard shortcut
panel, and the editor, agenda, search, picker, detail, and delete
frames share the same chips. Notifications carry check/warning marks.
Adds a live PTY smoke test covering view interaction and terminal
state restoration on q and Ctrl-C exits.
2026-07-20 22:51:28 +01:00

572 lines
24 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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 <string_view>
#include <utility>
#include <vector>
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<std::string_view>& 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::size_t>(std::count_if(line.begin(), line.end(), [](const char c) {
return (static_cast<unsigned char>(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<std::size_t>(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<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,
.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<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");
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<nocal::Event> 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<nocal::tui::CalendarItem> 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<const nocal::tui::CalendarItem>{}, default_state);
const auto explicit_frame = nocal::tui::render_month(
std::span<const nocal::tui::CalendarItem>{}, 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<const nocal::tui::CalendarItem>{}, 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<nocal::Calendar> 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<const nocal::Event>{}, 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<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");
}
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<const nocal::tui::CalendarItem>{}, 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<const nocal::tui::CalendarItem>{}, 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<const nocal::tui::CalendarItem>{}, 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<const nocal::tui::CalendarItem>{}, 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<const nocal::tui::CalendarItem>{}, 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<const nocal::tui::CalendarItem>{}, 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<const nocal::tui::CalendarItem>{}, 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<const nocal::tui::CalendarItem>{}, 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<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,
.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;
}