Files
nocal/tests/tui_tests.cpp
Bernardo Magri 8b6cf2e877 feat: add bounded agenda view
Add a read-only 42-day occurrence agenda with keyboard navigation, six-week paging, exact return-to-grid focus, search and calendar overlays, and bounded recurrence expansion.

Keep agenda state outside persistence, document month-only mutations, and cover empty windows, filtering, overlays, navigation, rendering, and terminal restoration.
2026-07-18 09:19:01 +01:00

309 lines
12 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 <vector>
namespace {
int failures = 0;
void check(bool condition, const char* message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
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,
};
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,
};
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_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_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;
}