Files
nocal/tests/tui_tests.cpp
Bernardo Magri 989332ef9a feat: make month week start configurable
Add a Monday-default --week-start option for every weekday and carry it through domain layout, full and compact rendering, event query windows, print mode, and the interactive TUI.

Preserve byte-compatible Monday output and cover rotated headers, spill dates, invalid input, sanitizer behavior, and live terminal restoration.
2026-07-18 09:58:27 +01:00

456 lines
18 KiB
C++
Raw 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;
}
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");
}
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 = 64;
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/Esc 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);
check(frame.find("Holiday") == std::string::npos &&
frame.find("2026-07-12 14:30 local ↻ Release — Studio") != std::string::npos,
"agenda scrolls chronologically to keep a late selection 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
int main()
{
test_full_month_render();
test_compact_and_input();
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;
}