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.
309 lines
12 KiB
C++
309 lines
12 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 <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;
|
||
}
|