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.
572 lines
24 KiB
C++
572 lines
24 KiB
C++
#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;
|
||
}
|