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.
1311 lines
60 KiB
C++
1311 lines
60 KiB
C++
#include "nocal/domain/date.hpp"
|
||
#include "nocal/tui/Screen.hpp"
|
||
#include "nocal/tui/TuiApp.hpp"
|
||
|
||
#include <algorithm>
|
||
#include <chrono>
|
||
#include <cstdlib>
|
||
#include <ctime>
|
||
#include <iostream>
|
||
#include <optional>
|
||
#include <span>
|
||
#include <stdexcept>
|
||
#include <string>
|
||
#include <string_view>
|
||
#include <utility>
|
||
#include <vector>
|
||
|
||
namespace {
|
||
|
||
int failures = 0;
|
||
|
||
void check(const bool condition, const std::string_view message)
|
||
{
|
||
if (!condition) {
|
||
++failures;
|
||
std::cerr << "FAIL: " << message << '\n';
|
||
}
|
||
}
|
||
|
||
nocal::Event make_event(std::string uid, std::string title, const nocal::Date start_day,
|
||
const unsigned start_hour, const unsigned start_minute,
|
||
const nocal::Date end_day, const unsigned end_hour,
|
||
const unsigned end_minute, const bool all_day = false)
|
||
{
|
||
nocal::Event event;
|
||
event.uid = std::move(uid);
|
||
event.title = std::move(title);
|
||
event.start = nocal::make_local_time(start_day, start_hour, start_minute);
|
||
event.end = nocal::make_local_time(end_day, end_hour, end_minute);
|
||
event.all_day = all_day;
|
||
return event;
|
||
}
|
||
|
||
std::string strip_ansi(const std::string& input)
|
||
{
|
||
std::string output;
|
||
for (std::size_t index = 0; index < input.size();) {
|
||
if (input[index] == '\x1b' && index + 1 < input.size() && input[index + 1] == '[') {
|
||
index += 2;
|
||
while (index < input.size() && input[index] != 'm') ++index;
|
||
if (index < input.size()) ++index;
|
||
} else {
|
||
output += input[index++];
|
||
}
|
||
}
|
||
return output;
|
||
}
|
||
|
||
nocal::tui::ScreenState screen_for(const nocal::Date day, const int width = 100,
|
||
const int height = 32)
|
||
{
|
||
nocal::tui::ScreenState state;
|
||
state.visible_month = day.year() / day.month();
|
||
state.selected_day = day;
|
||
state.today = day;
|
||
state.width = width;
|
||
state.height = height;
|
||
state.ansi = false;
|
||
return state;
|
||
}
|
||
|
||
bool focus_is(const nocal::tui::TuiApp& app, const std::string_view uid)
|
||
{
|
||
return app.state().focused_event_id && *app.state().focused_event_id == uid;
|
||
}
|
||
|
||
bool focus_starts_with(const nocal::tui::TuiApp& app, const std::string_view prefix)
|
||
{
|
||
return app.state().focused_event_id && app.state().focused_event_id->starts_with(prefix);
|
||
}
|
||
|
||
bool same_event(const nocal::Event& left, const nocal::Event& right)
|
||
{
|
||
return left.uid == right.uid && left.title == right.title && left.start == right.start &&
|
||
left.end == right.end && left.all_day == right.all_day &&
|
||
left.location == right.location && left.description == right.description &&
|
||
left.calendar_id == right.calendar_id && left.color == right.color &&
|
||
left.time_basis == right.time_basis && left.time_zone == right.time_zone &&
|
||
left.recurrence == right.recurrence &&
|
||
left.recurrence_additions == right.recurrence_additions &&
|
||
left.recurrence_exceptions == right.recurrence_exceptions;
|
||
}
|
||
|
||
void replace_editor_title(nocal::tui::TuiApp& app, const std::string_view title)
|
||
{
|
||
const auto* editor = app.editor();
|
||
check(editor != nullptr, "title replacement requires an active editor");
|
||
if (editor == nullptr) return;
|
||
const auto length = editor->field_value(nocal::tui::EditorField::title).size();
|
||
for (std::size_t index = 0; index < length; ++index) app.handle_input("\x7f");
|
||
app.handle_input(title);
|
||
}
|
||
|
||
void test_event_focus_cycles_in_display_order()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto day = nocal::today();
|
||
const auto tomorrow = nocal::from_sys_days(nocal::to_sys_days(day) + days{1});
|
||
std::vector<nocal::Event> events;
|
||
events.push_back(make_event("late", "Late review", day, 14, 30, day, 15, 0));
|
||
events.push_back(make_event("all-day", "Release day", day, 0, 0, tomorrow, 0, 0, true));
|
||
events.push_back(make_event("early", "Morning review", day, 9, 15, day, 10, 0));
|
||
|
||
nocal::tui::TuiApp app{events, -1, -1};
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_is(app, "all-day"), "Tab initially focuses the all-day event shown first");
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_is(app, "early"), "Tab advances to the earliest timed event");
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_is(app, "late"), "Tab advances through timed events chronologically");
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_is(app, "all-day"), "Tab wraps from the final event to the first");
|
||
|
||
app.dispatch(nocal::tui::Action::previous_event);
|
||
check(focus_is(app, "late"), "Shift-Tab moves backwards and wraps");
|
||
|
||
std::vector<nocal::Event> reverse_events = events;
|
||
nocal::tui::TuiApp reverse_app{reverse_events, -1, -1};
|
||
reverse_app.dispatch(nocal::tui::Action::previous_event);
|
||
check(focus_is(reverse_app, "late"),
|
||
"Shift-Tab with no focus starts at the final event of the day");
|
||
}
|
||
|
||
void test_open_close_and_day_movement()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto day = nocal::today();
|
||
std::vector<nocal::Event> events{
|
||
make_event("reader", "Reader event", day, 11, 0, day, 12, 0),
|
||
};
|
||
nocal::tui::TuiApp app{events, -1, -1};
|
||
|
||
app.dispatch(nocal::tui::Action::select);
|
||
check(focus_is(app, "reader"), "Enter focuses the first event when none is focused");
|
||
check(app.state().show_event_details, "Enter opens the focused event reader");
|
||
app.dispatch(nocal::tui::Action::back);
|
||
check(!app.state().show_event_details, "Escape closes the event reader");
|
||
check(focus_is(app, "reader"), "closing the reader keeps the event focused");
|
||
|
||
const auto selected = nocal::to_sys_days(app.state().selected_day);
|
||
app.dispatch(nocal::tui::Action::right);
|
||
check(nocal::to_sys_days(app.state().selected_day) == selected + days{1},
|
||
"day navigation still works after closing the reader");
|
||
check(!app.state().focused_event_id, "moving to another day clears event focus");
|
||
check(!app.state().show_event_details, "moving to another day leaves the reader closed");
|
||
}
|
||
|
||
void test_empty_day_is_safe()
|
||
{
|
||
std::vector<nocal::Event> events;
|
||
nocal::tui::TuiApp app{events, -1, -1};
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
app.dispatch(nocal::tui::Action::previous_event);
|
||
app.dispatch(nocal::tui::Action::select);
|
||
app.dispatch(nocal::tui::Action::back);
|
||
check(!app.state().focused_event_id, "event navigation on an empty day keeps focus empty");
|
||
check(!app.state().show_event_details, "Enter on an empty day does not open the reader");
|
||
}
|
||
|
||
void test_reader_navigation_and_synthetic_ids()
|
||
{
|
||
const auto day = nocal::today();
|
||
std::vector<nocal::Event> events;
|
||
events.push_back(make_event("duplicate", "First duplicate", day, 8, 0, day, 9, 0));
|
||
events.push_back(make_event("duplicate", "Second duplicate", day, 10, 0, day, 11, 0));
|
||
events.push_back(make_event("", "No UID", day, 12, 0, day, 13, 0));
|
||
nocal::tui::TuiApp app{events, -1, -1};
|
||
|
||
app.dispatch(nocal::tui::Action::select);
|
||
check(focus_is(app, "@nocal:0"), "duplicate UIDs receive a focusable source identity");
|
||
check(app.state().show_event_details, "synthetic identities resolve in the reader");
|
||
app.dispatch(nocal::tui::Action::down);
|
||
check(focus_is(app, "@nocal:1"), "reader Down advances to the next appointment");
|
||
app.dispatch(nocal::tui::Action::up);
|
||
check(focus_is(app, "@nocal:0"), "reader Up returns to the previous appointment");
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_is(app, "@nocal:2"), "an empty UID remains focusable and readable");
|
||
const auto frame = nocal::tui::render_month(events, app.state());
|
||
check(frame.find("No UID") != std::string::npos,
|
||
"renderer shares the controller's synthetic identity scheme");
|
||
}
|
||
|
||
void test_key_decoding()
|
||
{
|
||
using nocal::tui::Action;
|
||
using nocal::tui::decode_key;
|
||
check(decode_key("\t") == Action::next_event, "Tab decodes as next event");
|
||
check(decode_key("\x1b[Z") == Action::previous_event,
|
||
"terminal Shift-Tab decodes as previous event");
|
||
check(decode_key("\r") == Action::select, "carriage-return Enter decodes as select");
|
||
check(decode_key("\n") == Action::select, "newline Enter decodes as select");
|
||
check(decode_key("\x1b") == Action::back, "a lone Escape decodes as back");
|
||
check(decode_key("\x1b[A") == Action::up, "arrow decoding remains intact");
|
||
check(decode_key("a") == Action::add_event, "a decodes as add appointment");
|
||
check(decode_key("e") == Action::edit_event, "e decodes as edit appointment");
|
||
check(decode_key("d") == Action::delete_event, "d decodes as delete appointment");
|
||
check(decode_key("u") == Action::undo, "u decodes as undo");
|
||
check(decode_key("\x12") == Action::redo, "Ctrl-R decodes as redo");
|
||
check(decode_key("g") == Action::agenda, "g decodes as agenda");
|
||
}
|
||
|
||
void test_crud_preconditions_and_add()
|
||
{
|
||
std::vector<nocal::Event> events;
|
||
int save_calls = 0;
|
||
std::vector<nocal::Event> saved;
|
||
nocal::tui::TuiApp app{
|
||
events,
|
||
[&](const std::span<const nocal::Event> values) {
|
||
++save_calls;
|
||
saved.assign(values.begin(), values.end());
|
||
},
|
||
-1,
|
||
-1,
|
||
};
|
||
|
||
app.dispatch(nocal::tui::Action::edit_event);
|
||
check(!app.editor_active(), "edit without a focused appointment does not open an editor");
|
||
check(app.state().notification_is_error,
|
||
"edit without focus explains its appointment precondition");
|
||
app.dispatch(nocal::tui::Action::delete_event);
|
||
check(!app.delete_confirmation_active(),
|
||
"delete without a focused appointment does not open confirmation");
|
||
check(app.state().notification_is_error,
|
||
"delete without focus explains its appointment precondition");
|
||
|
||
const auto selected_day = app.state().selected_day;
|
||
app.dispatch(nocal::tui::Action::add_event);
|
||
check(app.editor_active(), "add opens the event editor on an empty day");
|
||
check(app.editor() != nullptr &&
|
||
app.editor()->field_value(nocal::tui::EditorField::start_date) ==
|
||
nocal::format_date(selected_day),
|
||
"add initializes the editor from the selected date");
|
||
check(app.editor() != nullptr &&
|
||
app.editor()->render(60, 18, false).find("NEW APPOINTMENT") != std::string::npos,
|
||
"the active add editor can be rendered by the controller");
|
||
app.handle_input("Created locally");
|
||
app.handle_input("\x13");
|
||
|
||
check(!app.editor_active(), "a valid Ctrl-S submit closes the add editor");
|
||
check(events.size() == 1 && events.front().title == "Created locally",
|
||
"add commits the validated editor result to the local model");
|
||
check(save_calls == 1 && saved.size() == 1 && saved.front().title == "Created locally",
|
||
"add calls the persistence boundary with the mutated model");
|
||
check(!events.front().uid.empty() && focus_is(app, events.front().uid),
|
||
"a successful add focuses the new appointment");
|
||
check(app.state().notification == "Appointment added." &&
|
||
!app.state().notification_is_error,
|
||
"a successful add reports a non-error status");
|
||
}
|
||
|
||
void test_edit_preserves_uid_and_rolls_back_on_save_failure()
|
||
{
|
||
const auto day = nocal::today();
|
||
auto original = make_event("stable-uid", "Original title", day, 9, 0, day, 10, 0);
|
||
original.location = "Desk";
|
||
original.description = "Original notes";
|
||
original.calendar_id = "personal";
|
||
original.color = "blue";
|
||
std::vector<nocal::Event> events{original};
|
||
int save_calls = 0;
|
||
nocal::tui::TuiApp app{
|
||
events,
|
||
[&](std::span<const nocal::Event>) {
|
||
++save_calls;
|
||
throw std::runtime_error{"disk full"};
|
||
},
|
||
-1,
|
||
-1,
|
||
};
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
app.dispatch(nocal::tui::Action::edit_event);
|
||
check(app.editor_active() && app.editor() != nullptr && app.editor()->editing(),
|
||
"edit opens a populated editing form for the focused appointment");
|
||
replace_editor_title(app, "Changed title");
|
||
app.handle_input("\x13");
|
||
|
||
check(save_calls == 1, "edit attempts persistence exactly once");
|
||
check(events.size() == 1 && same_event(events.front(), original),
|
||
"a throwing saver restores the exact event vector after edit");
|
||
check(focus_is(app, "stable-uid"), "edit rollback restores the original focus identity");
|
||
check(!app.editor_active(), "save failure returns to the month so its error is visible");
|
||
check(app.state().notification_is_error &&
|
||
app.state().notification.find("disk full") != std::string::npos,
|
||
"save failure is shown to the user with the persistence error");
|
||
|
||
int successful_saves = 0;
|
||
nocal::tui::TuiApp successful_app{
|
||
events,
|
||
[&](std::span<const nocal::Event>) { ++successful_saves; },
|
||
-1,
|
||
-1,
|
||
};
|
||
successful_app.dispatch(nocal::tui::Action::next_event);
|
||
successful_app.dispatch(nocal::tui::Action::edit_event);
|
||
replace_editor_title(successful_app, "Changed title");
|
||
successful_app.handle_input("\x13");
|
||
check(successful_saves == 1 && events.front().title == "Changed title",
|
||
"a successful edit commits and persists its changed fields");
|
||
check(events.front().uid == "stable-uid",
|
||
"editing preserves the appointment UID used by storage and focus");
|
||
}
|
||
|
||
void test_delete_cancel_confirm_and_rollback()
|
||
{
|
||
const auto day = nocal::today();
|
||
const auto target = make_event("delete-me", "Delete target", day, 9, 0, day, 10, 0);
|
||
const auto keep = make_event("keep-me", "Keep target", day, 11, 0, day, 12, 0);
|
||
std::vector<nocal::Event> events{target, keep};
|
||
int save_calls = 0;
|
||
nocal::tui::TuiApp app{
|
||
events,
|
||
[&](std::span<const nocal::Event>) { ++save_calls; },
|
||
-1,
|
||
-1,
|
||
};
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
app.dispatch(nocal::tui::Action::delete_event);
|
||
check(app.delete_confirmation_active(), "delete requires an explicit confirmation step");
|
||
const auto confirmation = strip_ansi(nocal::tui::render_month(events, app.state()));
|
||
check(confirmation.find("Delete target") != std::string::npos &&
|
||
confirmation.find("y confirm") != std::string::npos &&
|
||
confirmation.find("n cancel") != std::string::npos &&
|
||
confirmation.find("Esc cancel") != std::string::npos,
|
||
"delete confirmation names the appointment and shows its keys");
|
||
app.handle_input("n");
|
||
check(!app.delete_confirmation_active() && events.size() == 2 && save_calls == 0,
|
||
"n cancels deletion without mutating or saving");
|
||
check(focus_is(app, "delete-me"), "cancelled deletion retains appointment focus");
|
||
|
||
app.dispatch(nocal::tui::Action::delete_event);
|
||
app.handle_input("y");
|
||
check(events.size() == 1 && events.front().uid == "keep-me" && save_calls == 1,
|
||
"y deletes the focused appointment and persists once");
|
||
check(!app.state().focused_event_id &&
|
||
app.state().notification == "Appointment deleted.",
|
||
"successful deletion clears stale focus and reports success");
|
||
|
||
std::vector<nocal::Event> rollback_events{target};
|
||
nocal::tui::TuiApp rollback_app{
|
||
rollback_events,
|
||
[](std::span<const nocal::Event>) { throw std::runtime_error{"read only"}; },
|
||
-1,
|
||
-1,
|
||
};
|
||
rollback_app.dispatch(nocal::tui::Action::next_event);
|
||
rollback_app.dispatch(nocal::tui::Action::delete_event);
|
||
rollback_app.handle_input("y");
|
||
check(rollback_events.size() == 1 && same_event(rollback_events.front(), target),
|
||
"a throwing saver restores the exact vector after delete");
|
||
check(focus_is(rollback_app, "delete-me"),
|
||
"delete rollback restores the focused appointment identity");
|
||
check(rollback_app.state().notification_is_error,
|
||
"delete rollback displays a persistence error");
|
||
}
|
||
|
||
void test_crud_targets_synthetic_identities()
|
||
{
|
||
const auto day = nocal::today();
|
||
std::vector<nocal::Event> events{
|
||
make_event("duplicate", "First duplicate", day, 8, 0, day, 9, 0),
|
||
make_event("duplicate", "Second duplicate", day, 10, 0, day, 11, 0),
|
||
make_event("", "Empty UID", day, 12, 0, day, 13, 0),
|
||
};
|
||
nocal::tui::TuiApp app{events, -1, -1};
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_is(app, "@nocal:0"), "the first duplicate starts with its synthetic identity");
|
||
app.dispatch(nocal::tui::Action::edit_event);
|
||
replace_editor_title(app, "Edited first duplicate");
|
||
app.handle_input("\x13");
|
||
check(events[0].title == "Edited first duplicate" &&
|
||
events[1].title == "Second duplicate" && events[0].uid == "duplicate",
|
||
"edit resolves a synthetic identity to exactly the intended source event");
|
||
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_is(app, "@nocal:2"), "empty UID remains targetable after editing a duplicate");
|
||
app.dispatch(nocal::tui::Action::delete_event);
|
||
app.handle_input("y");
|
||
check(events.size() == 2 && events[0].title == "Edited first duplicate" &&
|
||
events[1].title == "Second duplicate",
|
||
"delete resolves an empty-UID synthetic identity to the intended source event");
|
||
}
|
||
|
||
void test_add_edit_delete_undo_redo()
|
||
{
|
||
std::vector<nocal::Event> events;
|
||
int save_calls = 0;
|
||
nocal::tui::TuiApp app{
|
||
events,
|
||
[&](std::span<const nocal::Event>) { ++save_calls; },
|
||
-1,
|
||
-1,
|
||
};
|
||
const auto original_day = app.state().selected_day;
|
||
|
||
app.dispatch(nocal::tui::Action::add_event);
|
||
app.handle_input("History event");
|
||
app.handle_input("\x13");
|
||
check(events.size() == 1, "history test creates an appointment");
|
||
const auto added = events.front();
|
||
check(focus_is(app, added.uid), "successful add establishes the post-mutation focus");
|
||
|
||
app.dispatch(nocal::tui::Action::undo);
|
||
check(events.empty() && !app.state().focused_event_id &&
|
||
app.state().selected_day == original_day,
|
||
"undo add restores the exact pre-add model and selection state");
|
||
check(app.state().notification == "Undid add appointment.",
|
||
"undo add names the reversed operation");
|
||
app.dispatch(nocal::tui::Action::redo);
|
||
check(events.size() == 1 && same_event(events.front(), added) && focus_is(app, added.uid),
|
||
"redo add restores the exact event metadata and post-add focus");
|
||
|
||
app.dispatch(nocal::tui::Action::select);
|
||
check(app.state().show_event_details, "reader is open before starting the edit");
|
||
app.dispatch(nocal::tui::Action::edit_event);
|
||
replace_editor_title(app, "Edited through history");
|
||
app.handle_input("\x13");
|
||
const auto edited = events.front();
|
||
check(edited.title == "Edited through history", "history test commits an edit");
|
||
app.dispatch(nocal::tui::Action::undo);
|
||
check(events.size() == 1 && same_event(events.front(), added) && focus_is(app, added.uid) &&
|
||
app.state().show_event_details,
|
||
"undo edit restores metadata, focus, and the pre-editor reader state");
|
||
app.dispatch(nocal::tui::Action::redo);
|
||
check(events.size() == 1 && same_event(events.front(), edited) && focus_is(app, edited.uid) &&
|
||
!app.state().show_event_details,
|
||
"redo edit restores the exact edited model and post-editor view state");
|
||
|
||
app.dispatch(nocal::tui::Action::delete_event);
|
||
app.handle_input("y");
|
||
check(events.empty(), "history test commits a deletion");
|
||
app.dispatch(nocal::tui::Action::undo);
|
||
check(events.size() == 1 && same_event(events.front(), edited) && focus_is(app, edited.uid),
|
||
"undo delete restores the exact deleted event and its focus");
|
||
app.dispatch(nocal::tui::Action::redo);
|
||
check(events.empty() && !app.state().focused_event_id,
|
||
"redo delete restores the empty model and cleared focus");
|
||
check(save_calls == 9,
|
||
"three mutations and their undo/redo operations each persist exactly once");
|
||
}
|
||
|
||
void test_history_persistence_failure_is_transactional()
|
||
{
|
||
const auto day = nocal::today();
|
||
const auto original = make_event("history-failure", "Original", day, 9, 0, day, 10, 0);
|
||
std::vector<nocal::Event> events{original};
|
||
bool fail = false;
|
||
int save_calls = 0;
|
||
nocal::tui::TuiApp app{
|
||
events,
|
||
[&](std::span<const nocal::Event>) {
|
||
++save_calls;
|
||
if (fail) throw std::runtime_error{"history disk failure"};
|
||
},
|
||
-1,
|
||
-1,
|
||
};
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
app.dispatch(nocal::tui::Action::edit_event);
|
||
replace_editor_title(app, "Changed");
|
||
app.handle_input("\x13");
|
||
const auto changed = events.front();
|
||
|
||
fail = true;
|
||
app.dispatch(nocal::tui::Action::undo);
|
||
check(events.size() == 1 && same_event(events.front(), changed) &&
|
||
focus_is(app, "history-failure"),
|
||
"failed undo persistence leaves the current model and focus untouched");
|
||
check(app.state().notification_is_error &&
|
||
app.state().notification.find("history disk failure") != std::string::npos,
|
||
"failed undo reports the persistence error");
|
||
|
||
fail = false;
|
||
app.dispatch(nocal::tui::Action::undo);
|
||
check(events.size() == 1 && same_event(events.front(), original),
|
||
"undo remains available after a failed persistence attempt");
|
||
|
||
fail = true;
|
||
app.dispatch(nocal::tui::Action::redo);
|
||
check(events.size() == 1 && same_event(events.front(), original) &&
|
||
focus_is(app, "history-failure"),
|
||
"failed redo persistence leaves the restored model and focus untouched");
|
||
fail = false;
|
||
app.dispatch(nocal::tui::Action::redo);
|
||
check(events.size() == 1 && same_event(events.front(), changed),
|
||
"redo remains available after a failed persistence attempt");
|
||
check(save_calls == 5, "successful mutation plus four history attempts call the saver");
|
||
}
|
||
|
||
void test_new_mutation_invalidates_redo()
|
||
{
|
||
const auto day = nocal::today();
|
||
std::vector<nocal::Event> events{
|
||
make_event("redo-invalidation", "Original", day, 9, 0, day, 10, 0),
|
||
};
|
||
nocal::tui::TuiApp app{events, -1, -1};
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
app.dispatch(nocal::tui::Action::edit_event);
|
||
replace_editor_title(app, "First branch");
|
||
app.handle_input("\x13");
|
||
app.dispatch(nocal::tui::Action::undo);
|
||
|
||
app.dispatch(nocal::tui::Action::edit_event);
|
||
replace_editor_title(app, "Second branch");
|
||
app.handle_input("\x13");
|
||
app.dispatch(nocal::tui::Action::redo);
|
||
check(events.front().title == "Second branch",
|
||
"a new successful mutation after undo invalidates the redo branch");
|
||
check(app.state().notification_is_error && app.state().notification == "Nothing to redo.",
|
||
"an invalidated redo branch gives contextual feedback");
|
||
}
|
||
|
||
void test_history_is_bounded_to_one_hundred_mutations()
|
||
{
|
||
const auto day = nocal::today();
|
||
std::vector<nocal::Event> events{
|
||
make_event("bounded-history", "Version 0", day, 9, 0, day, 10, 0),
|
||
};
|
||
nocal::tui::TuiApp app{events, -1, -1};
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
for (int version = 1; version <= 101; ++version) {
|
||
app.dispatch(nocal::tui::Action::edit_event);
|
||
replace_editor_title(app, "Version " + std::to_string(version));
|
||
app.handle_input("\x13");
|
||
}
|
||
for (int count = 0; count < 100; ++count) app.dispatch(nocal::tui::Action::undo);
|
||
check(events.front().title == "Version 1",
|
||
"the bounded history retains the most recent one hundred mutations");
|
||
app.dispatch(nocal::tui::Action::undo);
|
||
check(events.front().title == "Version 1" &&
|
||
app.state().notification == "Nothing to undo.",
|
||
"the oldest mutation is discarded once the one-hundred entry bound is reached");
|
||
}
|
||
|
||
void test_history_is_unavailable_in_modal_states()
|
||
{
|
||
const auto day = nocal::today();
|
||
std::vector<nocal::Event> events{
|
||
make_event("modal-history", "Modal event", day, 9, 0, day, 10, 0),
|
||
};
|
||
nocal::tui::TuiApp app{events, -1, -1};
|
||
app.dispatch(nocal::tui::Action::add_event);
|
||
app.dispatch(nocal::tui::Action::undo);
|
||
check(app.editor_active() && app.state().notification_is_error,
|
||
"undo is unavailable while the editor owns input");
|
||
app.dispatch(nocal::tui::Action::back);
|
||
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
auto footer_state = screen_for(day, 180, 32);
|
||
footer_state.focused_event_id = "modal-history";
|
||
const auto month_frame = nocal::tui::render_month(events, footer_state);
|
||
check(month_frame.find("u undo") != std::string::npos &&
|
||
month_frame.find("Enter read") != std::string::npos &&
|
||
month_frame.find("Tab next") != std::string::npos,
|
||
"the month footer shows focused hints");
|
||
app.dispatch(nocal::tui::Action::delete_event);
|
||
app.handle_input("u");
|
||
check(app.delete_confirmation_active() && app.state().notification_is_error &&
|
||
app.state().notification.find("deletion") != std::string::npos,
|
||
"history shortcuts are unavailable during delete confirmation");
|
||
const auto confirmation = nocal::tui::render_month(events, app.state());
|
||
check(confirmation.find("Finish or cancel deletion") != std::string::npos,
|
||
"delete confirmation renders its contextual history error");
|
||
app.handle_input("n");
|
||
}
|
||
|
||
void test_focused_line_rendering()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto day = year{2026} / July / std::chrono::day{17};
|
||
auto state = screen_for(day, 112, 38);
|
||
state.focused_event_id = "focused";
|
||
const std::vector<nocal::tui::CalendarItem> items{
|
||
{.id = "plain",
|
||
.title = "Plain item",
|
||
.day = day,
|
||
.time_of_day = hours{8},
|
||
.all_day = false,
|
||
.end_time_of_day = hours{8} + minutes{30},
|
||
.start_day = day,
|
||
.end_day = day,
|
||
.location = {},
|
||
.description = {},
|
||
.detail_title = "Plain item",
|
||
.detail_all_day = false,
|
||
.detail_start_time_of_day = hours{8},
|
||
.segment = nocal::tui::ItemSegment::single,
|
||
.recurring = false,
|
||
.recurrence_summary = {},
|
||
.source_time_zone = {}},
|
||
{.id = "focused",
|
||
.title = "Focused item",
|
||
.day = day,
|
||
.time_of_day = hours{9} + minutes{15},
|
||
.all_day = false,
|
||
.end_time_of_day = hours{10},
|
||
.start_day = day,
|
||
.end_day = day,
|
||
.location = {},
|
||
.description = {},
|
||
.detail_title = "Focused item",
|
||
.detail_all_day = false,
|
||
.detail_start_time_of_day = hours{9} + minutes{15},
|
||
.segment = nocal::tui::ItemSegment::single,
|
||
.recurring = false,
|
||
.recurrence_summary = {},
|
||
.source_time_zone = {}},
|
||
};
|
||
|
||
const auto frame = nocal::tui::render_month(items, state);
|
||
check(frame.find("› 09:15 Focu") != std::string::npos,
|
||
"the focused appointment line has a visible cursor when ANSI is disabled");
|
||
check(frame.find("› 08:00 Plain") == std::string::npos,
|
||
"the focus cursor is only rendered on the focused appointment");
|
||
check(frame.find("\x1b[") == std::string::npos,
|
||
"focused line rendering respects disabled ANSI styling");
|
||
}
|
||
|
||
void test_focus_brings_hidden_event_into_view()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto day = year{2026} / July / std::chrono::day{17};
|
||
auto state = screen_for(day, 100, 30);
|
||
state.focused_event_id = "last";
|
||
std::vector<nocal::tui::CalendarItem> items;
|
||
for (unsigned index = 0; index < 6; ++index) {
|
||
nocal::tui::CalendarItem item;
|
||
item.id = index == 5 ? "last" : "event-" + std::to_string(index);
|
||
item.title = index == 5 ? "Hidden focus target" : "Earlier event";
|
||
item.day = day;
|
||
item.time_of_day = hours{8 + index};
|
||
items.push_back(std::move(item));
|
||
}
|
||
const auto frame = nocal::tui::render_month(items, state);
|
||
check(frame.find("› 13:00") != std::string::npos,
|
||
"focused overflow event is windowed into the visible cell");
|
||
check(frame.find("hidden") != std::string::npos,
|
||
"dense cells retain an overflow indicator while focus is windowed");
|
||
}
|
||
|
||
void test_timed_event_details()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto day = year{2026} / July / std::chrono::day{17};
|
||
auto event = make_event("detail", "Architecture review", day, 9, 15, day, 10, 45);
|
||
event.location = "Studio 4B";
|
||
event.description = "Review keyboard navigation and approve the terminal layout.";
|
||
const std::vector<nocal::Event> events{event};
|
||
auto state = screen_for(day, 80, 24);
|
||
state.focused_event_id = "detail";
|
||
state.show_event_details = true;
|
||
|
||
const auto frame = nocal::tui::render_month(events, state);
|
||
check(frame.find("APPOINTMENT") != std::string::npos, "the reader has a clear heading");
|
||
check(frame.find("Architecture review") != std::string::npos,
|
||
"the reader renders the full appointment title");
|
||
check(frame.find("Friday, 17 July 2026") != std::string::npos,
|
||
"the reader renders the full friendly date");
|
||
check(frame.find("09:15 – 10:45") != std::string::npos,
|
||
"the reader renders the complete local time range");
|
||
check(frame.find("Studio 4B") != std::string::npos,
|
||
"the reader renders the event location");
|
||
check(frame.find("Review keyboard navigation and approve the terminal layout.") !=
|
||
std::string::npos,
|
||
"the reader renders the event description");
|
||
check(frame.find("Esc back") != std::string::npos, "the reader footer explains how to close it");
|
||
// Verify the new chip grammar: hints left, ordinal right
|
||
check(frame.find("Tab") != std::string::npos, "reader footer contains Tab hint");
|
||
check(frame.find("del") != std::string::npos, "reader footer contains del hint");
|
||
check(frame.find("1/") != std::string::npos, "reader footer contains ordinal");
|
||
}
|
||
|
||
void test_all_day_and_multiday_details()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto first = year{2026} / July / std::chrono::day{16};
|
||
const auto middle = year{2026} / July / std::chrono::day{17};
|
||
const auto last = year{2026} / July / std::chrono::day{18};
|
||
const auto after = year{2026} / July / std::chrono::day{19};
|
||
|
||
const std::vector<nocal::Event> all_day{
|
||
make_event("festival", "Terminal festival", first, 0, 0, after, 0, 0, true),
|
||
};
|
||
auto all_day_state = screen_for(middle, 80, 24);
|
||
all_day_state.focused_event_id = "festival";
|
||
all_day_state.show_event_details = true;
|
||
const auto all_day_frame = nocal::tui::render_month(all_day, all_day_state);
|
||
check(all_day_frame.find("Time All day") != std::string::npos,
|
||
"all-day events are described as all day");
|
||
check(all_day_frame.find("Ends Saturday, 18 July 2026") != std::string::npos,
|
||
"exclusive all-day ends are presented as the last occupied day");
|
||
|
||
const std::vector<nocal::Event> timed{
|
||
make_event("overnight", "Long migration", first, 20, 0, last, 11, 30),
|
||
};
|
||
auto timed_state = screen_for(middle, 80, 24);
|
||
timed_state.focused_event_id = "overnight";
|
||
timed_state.show_event_details = true;
|
||
const auto timed_frame = nocal::tui::render_month(timed, timed_state);
|
||
check(timed_frame.find("Thursday, 16 July 2026") != std::string::npos,
|
||
"a multi-day reader retains the event's actual start date");
|
||
check(timed_frame.find("Ends Saturday, 18 July 2026") != std::string::npos,
|
||
"a timed multi-day reader renders its end date");
|
||
check(timed_frame.find("20:00 – 11:30") != std::string::npos,
|
||
"a timed multi-day continuation retains its real times");
|
||
check(timed_frame.find("Time All day") == std::string::npos,
|
||
"a timed multi-day continuation is not mislabeled all day");
|
||
}
|
||
|
||
void test_narrow_detail_wrapping_without_ansi()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto day = year{2026} / July / std::chrono::day{17};
|
||
auto event = make_event("wrap", "Wrap check", day, 13, 0, day, 14, 0);
|
||
event.description = "Alpha beta gamma delta epsilon zeta eta theta.";
|
||
const std::vector<nocal::Event> events{event};
|
||
auto state = screen_for(day, 32, 20);
|
||
state.focused_event_id = "wrap";
|
||
state.show_event_details = true;
|
||
|
||
const auto frame = nocal::tui::render_month(events, state);
|
||
check(frame.find("Alpha beta gamma delta epsilon zeta eta theta.") == std::string::npos,
|
||
"narrow readers wrap descriptions instead of overflowing a line");
|
||
check(frame.find("Alpha beta gamma delta") != std::string::npos,
|
||
"the first wrapped description line is retained");
|
||
check(frame.find("epsilon zeta eta theta.") != std::string::npos,
|
||
"the remaining wrapped description text is retained");
|
||
check(frame.find("\x1b[") == std::string::npos,
|
||
"narrow detail rendering emits no ANSI when styling is disabled");
|
||
}
|
||
|
||
void test_multiday_segment_labels_and_series_messaging()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto first = year{2026} / July / std::chrono::day{16};
|
||
const auto middle = year{2026} / July / std::chrono::day{17};
|
||
const auto last = year{2026} / July / std::chrono::day{18};
|
||
std::vector<nocal::tui::CalendarItem> items{
|
||
{.id = "series@occ:1", .title = "Migration", .day = first,
|
||
.time_of_day = std::nullopt, .all_day = true,
|
||
.end_time_of_day = std::nullopt, .start_day = first, .end_day = last,
|
||
.location = {}, .description = {}, .detail_title = "Migration",
|
||
.detail_all_day = true, .detail_start_time_of_day = std::nullopt,
|
||
.segment = nocal::tui::ItemSegment::start,
|
||
.recurring = true, .recurrence_summary = "Every week",
|
||
.source_time_zone = "Europe/London"},
|
||
{.id = "series@occ:1", .title = "Migration", .day = middle,
|
||
.time_of_day = std::nullopt, .all_day = true,
|
||
.end_time_of_day = std::nullopt, .start_day = first, .end_day = last,
|
||
.location = {}, .description = {}, .detail_title = "Migration",
|
||
.detail_all_day = true, .detail_start_time_of_day = std::nullopt,
|
||
.segment = nocal::tui::ItemSegment::continuation,
|
||
.recurring = true, .recurrence_summary = "Every week",
|
||
.source_time_zone = "Europe/London"},
|
||
{.id = "series@occ:1", .title = "Migration", .day = last,
|
||
.time_of_day = std::nullopt, .all_day = true,
|
||
.end_time_of_day = std::nullopt, .start_day = first, .end_day = last,
|
||
.location = {}, .description = {}, .detail_title = "Migration",
|
||
.detail_all_day = true, .detail_start_time_of_day = std::nullopt,
|
||
.segment = nocal::tui::ItemSegment::end,
|
||
.recurring = true, .recurrence_summary = "Every week",
|
||
.source_time_zone = "Europe/London"},
|
||
};
|
||
auto state = screen_for(middle, 112, 38);
|
||
state.focused_event_id = "series@occ:1";
|
||
const auto month = nocal::tui::render_month(items, state);
|
||
check(month.find("│ Migration") != std::string::npos,
|
||
"a middle multi-day segment has a visible continuation marker without ANSI");
|
||
|
||
state.show_event_details = true;
|
||
const auto reader = nocal::tui::render_month(items, state);
|
||
check(reader.find("RECURRING APPOINTMENT") != std::string::npos &&
|
||
reader.find("Every week") != std::string::npos &&
|
||
reader.find("Europe/London") != std::string::npos &&
|
||
reader.find("every occurrence") != std::string::npos,
|
||
"the reader identifies recurrence, source timezone, and whole-series edits");
|
||
|
||
state.show_event_details = false;
|
||
state.confirm_delete = true;
|
||
const auto confirmation = nocal::tui::render_month(items, state);
|
||
check(confirmation.find("entire recurring series") != std::string::npos,
|
||
"recurring delete confirmation explicitly targets the whole series");
|
||
}
|
||
|
||
void test_recurring_occurrence_navigation_and_whole_series_crud()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto selected = nocal::today();
|
||
const auto previous = nocal::from_sys_days(nocal::to_sys_days(selected) - days{1});
|
||
auto series = make_event("duplicate", "Daily series", previous, 9, 0, previous, 10, 0);
|
||
series.recurrence = nocal::RecurrenceRule{
|
||
.frequency = nocal::RecurrenceFrequency::daily, .interval = 1, .count = 4,
|
||
.until = std::nullopt, .by_weekday = {}, .by_month_day = {}, .by_month = {}};
|
||
auto duplicate = make_event("duplicate", "Duplicate UID", selected, 11, 0, selected, 12, 0);
|
||
std::vector<nocal::Event> events{series, duplicate};
|
||
nocal::tui::TuiApp app{events, -1, -1};
|
||
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_starts_with(app, "@nocal:0@occ:"),
|
||
"a recurring duplicate-UID occurrence has a stable source-and-start identity");
|
||
const auto series_focus = app.state().focused_event_id;
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_is(app, "@nocal:1"),
|
||
"Tab navigates from a recurring instance to a duplicate-UID one-off event");
|
||
app.dispatch(nocal::tui::Action::previous_event);
|
||
check(app.state().focused_event_id == series_focus,
|
||
"occurrence navigation returns to the exact recurring instance");
|
||
|
||
app.dispatch(nocal::tui::Action::edit_event);
|
||
replace_editor_title(app, "Edited entire series");
|
||
app.handle_input("\x13");
|
||
check(events.front().title == "Edited entire series" && events.front().recurrence.has_value() &&
|
||
app.state().notification == "Recurring series updated." &&
|
||
focus_starts_with(app, "@nocal:0@occ:"),
|
||
"editing an occurrence updates its source series and retains coherent focus");
|
||
app.dispatch(nocal::tui::Action::undo);
|
||
check(events.front().title == "Daily series" && events.front().recurrence == series.recurrence,
|
||
"undo restores the complete recurring series metadata");
|
||
app.dispatch(nocal::tui::Action::redo);
|
||
check(events.front().title == "Edited entire series" && events.front().recurrence.has_value(),
|
||
"redo reapplies the whole-series edit");
|
||
|
||
app.dispatch(nocal::tui::Action::delete_event);
|
||
app.handle_input("y");
|
||
check(events.size() == 1 && events.front().title == "Duplicate UID" &&
|
||
app.state().notification == "Recurring series deleted.",
|
||
"deleting a recurring occurrence removes only its entire source series");
|
||
app.dispatch(nocal::tui::Action::undo);
|
||
check(events.size() == 2 && events.front().recurrence.has_value(),
|
||
"undo restores a deleted recurring series");
|
||
}
|
||
|
||
void test_rdate_only_occurrence_identity_and_whole_series_crud()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto selected = nocal::today();
|
||
const auto previous = nocal::from_sys_days(nocal::to_sys_days(selected) - days{1});
|
||
auto series = make_event("rdate-only", "Extra date", previous, 9, 0,
|
||
previous, 10, 0);
|
||
series.recurrence_additions = {
|
||
nocal::make_local_time(selected, 9, 0),
|
||
};
|
||
std::vector<nocal::Event> events{series};
|
||
nocal::tui::TuiApp app{events, -1, -1};
|
||
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_starts_with(app, "rdate-only@occ:"),
|
||
"an RDATE-only occurrence has a source-and-start focus identity");
|
||
app.dispatch(nocal::tui::Action::edit_event);
|
||
replace_editor_title(app, "Edited extra-date series");
|
||
app.handle_input("\x13");
|
||
check(events.front().title == "Edited extra-date series" &&
|
||
events.front().recurrence_additions == series.recurrence_additions &&
|
||
app.state().notification == "Recurring series updated.",
|
||
"editing an RDATE-only occurrence updates and preserves the whole series");
|
||
|
||
app.dispatch(nocal::tui::Action::delete_event);
|
||
app.handle_input("y");
|
||
check(events.empty() && app.state().notification == "Recurring series deleted.",
|
||
"deleting an RDATE-only occurrence removes its entire source series");
|
||
}
|
||
|
||
void test_recurrence_grid_expansion_and_source_zone_display()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto first = year{2026} / July / std::chrono::day{17};
|
||
auto recurring = make_event("grid-series", "Grid repeat", first, 9, 0, first, 10, 0);
|
||
recurring.recurrence = nocal::RecurrenceRule{
|
||
.frequency = nocal::RecurrenceFrequency::daily, .interval = 1, .count = 3,
|
||
.until = std::nullopt, .by_weekday = {}, .by_month_day = {}, .by_month = {}};
|
||
std::vector<nocal::Event> events{recurring};
|
||
auto state = screen_for(first, 140, 38);
|
||
const auto frame = nocal::tui::render_month(events, state);
|
||
std::size_t repeats = 0;
|
||
for (std::size_t at = 0; (at = frame.find("Grid repeat", at)) != std::string::npos;
|
||
at += std::string_view{"Grid repeat"}.size()) {
|
||
++repeats;
|
||
}
|
||
check(repeats == 3, "the month adapter expands recurring instances only into visible days");
|
||
auto compact_state = screen_for(first, 32, 12);
|
||
compact_state.ansi = false;
|
||
const auto compact = nocal::tui::render_month(events, compact_state);
|
||
check(compact.find("\x1b[") == std::string::npos &&
|
||
std::count(compact.begin(), compact.end(), '\n') == 11,
|
||
"recurrence rendering preserves compact NO_COLOR frame geometry");
|
||
|
||
const char* prior_tz_value = std::getenv("TZ");
|
||
const std::optional<std::string> prior_tz = prior_tz_value
|
||
? std::optional{std::string{prior_tz_value}}
|
||
: std::nullopt;
|
||
(void)::setenv("TZ", "UTC0", 1);
|
||
::tzset();
|
||
const auto* zone = std::chrono::locate_zone("America/New_York");
|
||
nocal::Event zoned;
|
||
zoned.uid = "zoned-reader";
|
||
zoned.title = "Zoned meeting";
|
||
zoned.time_basis = nocal::TimeBasis::zoned;
|
||
zoned.time_zone = "America/New_York";
|
||
zoned.start = zone->to_sys(local_days{first} + hours{9}, choose::earliest);
|
||
zoned.end = zone->to_sys(local_days{first} + hours{10}, choose::earliest);
|
||
std::vector<nocal::Event> zoned_events{zoned};
|
||
auto zoned_state = screen_for(first, 100, 28);
|
||
const auto occurrences = nocal::occurrences_on_day(zoned_events, first);
|
||
check(!occurrences.empty(), "the zoned test event occurs on the user's displayed day");
|
||
if (!occurrences.empty()) {
|
||
zoned_state.focused_event_id =
|
||
nocal::tui::occurrence_focus_id(zoned_events, occurrences.front());
|
||
zoned_state.show_event_details = true;
|
||
const auto reader = nocal::tui::render_month(zoned_events, zoned_state);
|
||
check(reader.find("America/New_York") != std::string::npos &&
|
||
reader.find("13:00 – 14:00") != std::string::npos &&
|
||
reader.find("09:00 – 10:00") == std::string::npos,
|
||
"the reader names the source TZID while displaying instants in the user zone");
|
||
}
|
||
if (prior_tz) (void)::setenv("TZ", prior_tz->c_str(), 1);
|
||
else (void)::unsetenv("TZ");
|
||
::tzset();
|
||
}
|
||
|
||
void test_search_prompt_results_and_occurrence_jump()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto first_day = nocal::today();
|
||
const auto second_day = nocal::from_sys_days(nocal::to_sys_days(first_day) + days{1});
|
||
const auto third_day = nocal::from_sys_days(nocal::to_sys_days(first_day) + days{2});
|
||
const auto fourth_day = nocal::from_sys_days(nocal::to_sys_days(first_day) + days{3});
|
||
auto recurring = make_event("search-series", "Quarter Sync", first_day, 9, 0,
|
||
first_day, 10, 0);
|
||
recurring.description = "Planning notes";
|
||
recurring.recurrence = nocal::RecurrenceRule{
|
||
.frequency = nocal::RecurrenceFrequency::daily,
|
||
.interval = 1,
|
||
.count = 3,
|
||
.until = std::nullopt,
|
||
.by_weekday = {},
|
||
.by_month_day = {},
|
||
.by_month = {},
|
||
};
|
||
auto other = make_event("other", "Unrelated", second_day, 12, 0,
|
||
second_day, 13, 0);
|
||
other.location = "Quarter room";
|
||
auto holiday = make_event("holiday", "Quarter holiday", third_day, 0, 0,
|
||
fourth_day, 0, 0, true);
|
||
std::vector<nocal::Event> events{recurring, other, holiday};
|
||
int saves = 0;
|
||
nocal::tui::TuiApp app{
|
||
events, [&](std::span<const nocal::Event>) { ++saves; }, -1, -1};
|
||
|
||
app.handle_input("/");
|
||
check(app.search_active() && app.state().search_prompt,
|
||
"slash opens the search prompt");
|
||
app.handle_input("QUARTER SYNX");
|
||
app.handle_input("\x7f");
|
||
app.handle_input("C");
|
||
check(app.state().search_query == "QUARTER SYNC",
|
||
"search query accepts text and backspace editing");
|
||
app.handle_input("\r");
|
||
check(app.state().show_search_results &&
|
||
app.state().search_results.size() == 3,
|
||
"search expands every matching recurrence in the bounded window");
|
||
check(app.state().search_results.front().day == first_day &&
|
||
app.state().search_results.back().day == third_day,
|
||
"search results retain chronological occurrence dates");
|
||
|
||
app.dispatch(nocal::tui::Action::down);
|
||
app.dispatch(nocal::tui::Action::down);
|
||
app.dispatch(nocal::tui::Action::select);
|
||
check(!app.search_active() && app.state().selected_day == third_day &&
|
||
app.state().visible_month == third_day.year() / third_day.month() &&
|
||
focus_starts_with(app, "search-series@occ:"),
|
||
"choosing a recurring result jumps to and focuses that exact instance");
|
||
check(saves == 0, "search and result navigation never cross the persistence boundary");
|
||
|
||
const auto selected_after_jump = app.state().selected_day;
|
||
app.handle_input("/");
|
||
app.handle_input("\x1b");
|
||
check(!app.search_active() && app.state().selected_day == selected_after_jump,
|
||
"cancelling search preserves the calendar selection");
|
||
|
||
app.handle_input("/");
|
||
app.handle_input("quarter");
|
||
app.handle_input("\r");
|
||
check(app.state().search_results.size() == 5 &&
|
||
app.state().search_results[0].day == first_day &&
|
||
app.state().search_results[1].day == second_day &&
|
||
app.state().search_results[2].day == second_day &&
|
||
app.state().search_results[3].title == "Quarter holiday" &&
|
||
app.state().search_results[4].title == "Quarter Sync",
|
||
"search results sort by date with all-day events first within each day");
|
||
app.dispatch(nocal::tui::Action::back);
|
||
|
||
app.handle_input("/");
|
||
app.handle_input("missing");
|
||
app.handle_input("\r");
|
||
check(app.state().show_search_results && app.state().search_results.empty(),
|
||
"a query with no matches produces a safe empty results view");
|
||
app.dispatch(nocal::tui::Action::back);
|
||
check(!app.search_active(), "Esc/back closes empty search results");
|
||
}
|
||
|
||
void test_calendar_visibility_filters_without_partial_saves()
|
||
{
|
||
const auto day = nocal::today();
|
||
auto default_event = make_event("default", "D", day, 8, 0,
|
||
day, 9, 0);
|
||
auto personal_event = make_event("personal", "P", day, 9, 0,
|
||
day, 10, 0);
|
||
personal_event.calendar_id = "personal";
|
||
auto work_event = make_event("work", "W", day, 10, 0,
|
||
day, 11, 0);
|
||
work_event.calendar_id = "work";
|
||
std::vector<nocal::Event> events{default_event, personal_event, work_event};
|
||
int save_calls = 0;
|
||
std::vector<nocal::Event> saved;
|
||
nocal::tui::TuiApp app{
|
||
events,
|
||
[&](const std::span<const nocal::Event> values) {
|
||
++save_calls;
|
||
saved.assign(values.begin(), values.end());
|
||
},
|
||
-1,
|
||
-1,
|
||
};
|
||
|
||
check(app.calendars().size() == 3 && app.calendars()[0].id.empty() &&
|
||
app.calendars()[0].name == "Default" &&
|
||
app.calendars()[1].id == "personal" && app.calendars()[2].id == "work",
|
||
"controller derives a stable default-first calendar list from event metadata");
|
||
app.dispatch(nocal::tui::Action::calendars);
|
||
check(app.calendar_picker_active(), "c opens the calendar picker");
|
||
app.dispatch(nocal::tui::Action::down);
|
||
app.dispatch(nocal::tui::Action::down);
|
||
app.dispatch(nocal::tui::Action::select);
|
||
check(!app.calendars()[2].visible && save_calls == 0,
|
||
"toggling calendar visibility never crosses the persistence boundary");
|
||
app.dispatch(nocal::tui::Action::back);
|
||
|
||
auto render_state = app.state();
|
||
render_state.width = 120;
|
||
render_state.height = 36;
|
||
auto frame = nocal::tui::render_month(events, app.calendars(), render_state);
|
||
check(frame.find("08:00 D") != std::string::npos &&
|
||
frame.find("09:00 P") != std::string::npos &&
|
||
frame.find("10:00 W") == std::string::npos,
|
||
"month rendering excludes only events from hidden calendars");
|
||
|
||
app.handle_input("/");
|
||
app.handle_input("work");
|
||
app.handle_input("\r");
|
||
check(app.state().search_results.empty(),
|
||
"search shares the month view's calendar visibility contract");
|
||
app.dispatch(nocal::tui::Action::back);
|
||
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_is(app, "default"), "focus starts on the first visible calendar event");
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_is(app, "personal"), "focus advances to another visible calendar");
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_is(app, "default"), "focus traversal skips hidden-calendar events");
|
||
|
||
app.dispatch(nocal::tui::Action::delete_event);
|
||
app.handle_input("y");
|
||
check(save_calls == 1 && saved.size() == 2 &&
|
||
std::any_of(saved.begin(), saved.end(), [](const nocal::Event& event) {
|
||
return event.uid == "work" && event.calendar_id == "work";
|
||
}),
|
||
"mutations save the full model, including events in hidden calendars");
|
||
|
||
app.dispatch(nocal::tui::Action::calendars);
|
||
app.dispatch(nocal::tui::Action::select);
|
||
check(app.calendars()[2].visible && save_calls == 1,
|
||
"calendar picker retains its selection and can reveal a calendar without saving");
|
||
app.dispatch(nocal::tui::Action::back);
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
app.dispatch(nocal::tui::Action::next_event);
|
||
check(focus_is(app, "work"), "revealed calendar events become focusable again");
|
||
app.dispatch(nocal::tui::Action::calendars);
|
||
app.dispatch(nocal::tui::Action::select);
|
||
check(!app.state().focused_event_id,
|
||
"hiding the focused event's calendar clears stale appointment focus");
|
||
check(save_calls == 1, "all visibility-only interactions remain unsaved session state");
|
||
}
|
||
|
||
void test_agenda_window_order_visibility_and_bounds()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto first_day = nocal::today();
|
||
const auto second_day = nocal::from_sys_days(nocal::to_sys_days(first_day) + days{1});
|
||
const auto after_window = nocal::from_sys_days(nocal::to_sys_days(first_day) + days{42});
|
||
|
||
auto series = make_event("agenda-series", "Daily standup", first_day, 9, 0,
|
||
first_day, 9, 30);
|
||
series.recurrence = nocal::RecurrenceRule{
|
||
.frequency = nocal::RecurrenceFrequency::daily,
|
||
.interval = 1,
|
||
.count = 100,
|
||
.until = std::nullopt,
|
||
.by_weekday = {},
|
||
.by_month_day = {},
|
||
.by_month = {},
|
||
};
|
||
auto hidden = make_event("hidden-agenda", "Hidden work", first_day, 7, 0,
|
||
first_day, 8, 0);
|
||
hidden.calendar_id = "work";
|
||
std::vector<nocal::Event> events{
|
||
make_event("timed", "Morning review", first_day, 8, 0, first_day, 8, 30),
|
||
make_event("all-day", "Release day", first_day, 0, 0, second_day, 0, 0, true),
|
||
series,
|
||
hidden,
|
||
make_event("outside", "Outside window", after_window, 6, 0,
|
||
after_window, 7, 0),
|
||
};
|
||
int save_calls = 0;
|
||
nocal::tui::TuiApp app{
|
||
events, [&](std::span<const nocal::Event>) { ++save_calls; }, -1, -1};
|
||
|
||
app.dispatch(nocal::tui::Action::calendars);
|
||
app.dispatch(nocal::tui::Action::down);
|
||
app.dispatch(nocal::tui::Action::select);
|
||
app.dispatch(nocal::tui::Action::back);
|
||
app.handle_input("g");
|
||
|
||
check(app.agenda_active() && app.state().agenda_start_day == first_day,
|
||
"g opens a 42-day agenda anchored at the selected day");
|
||
check(app.state().agenda_items.size() == 44,
|
||
"agenda recurrence expansion is bounded to its 42-day window");
|
||
if (app.state().agenda_items.size() == 44) {
|
||
check(app.state().agenda_items[0].focus_id == "all-day" &&
|
||
app.state().agenda_items[1].focus_id == "timed" &&
|
||
app.state().agenda_items[2].focus_id.starts_with("agenda-series@occ:"),
|
||
"agenda retains domain occurrence order with all-day items first");
|
||
check(app.state().agenda_items.back().day ==
|
||
nocal::from_sys_days(nocal::to_sys_days(first_day) + days{41}),
|
||
"agenda includes the final day inside the half-open 42-day window");
|
||
}
|
||
check(std::none_of(app.state().agenda_items.begin(), app.state().agenda_items.end(),
|
||
[&](const nocal::tui::AgendaItem& item) {
|
||
return item.focus_id == "hidden-agenda" ||
|
||
item.focus_id == "outside" ||
|
||
nocal::to_sys_days(item.day) >= nocal::to_sys_days(after_window);
|
||
}),
|
||
"agenda excludes hidden calendars and occurrences outside its window");
|
||
check(save_calls == 0, "opening and filtering agenda never saves events");
|
||
}
|
||
|
||
void test_agenda_navigation_shift_choice_and_close()
|
||
{
|
||
using namespace std::chrono;
|
||
const auto first_day = nocal::today();
|
||
auto series = make_event("choose-series", "Choose exact occurrence", first_day, 9, 0,
|
||
first_day, 10, 0);
|
||
series.recurrence = nocal::RecurrenceRule{
|
||
.frequency = nocal::RecurrenceFrequency::daily,
|
||
.interval = 1,
|
||
.count = 100,
|
||
.until = std::nullopt,
|
||
.by_weekday = {},
|
||
.by_month_day = {},
|
||
.by_month = {},
|
||
};
|
||
std::vector<nocal::Event> events{series};
|
||
int save_calls = 0;
|
||
nocal::tui::TuiApp app{
|
||
events, [&](std::span<const nocal::Event>) { ++save_calls; }, -1, -1};
|
||
const auto month_day = app.state().selected_day;
|
||
const auto month = app.state().visible_month;
|
||
|
||
app.dispatch(nocal::tui::Action::agenda);
|
||
app.handle_input("k");
|
||
check(app.state().agenda_index == 0,
|
||
"agenda navigation clamps at the first item instead of wrapping");
|
||
app.handle_input("j");
|
||
check(app.state().agenda_index == 1, "j advances agenda selection");
|
||
for (std::size_t index = 0; index < app.state().agenda_items.size() + 2; ++index) {
|
||
app.dispatch(nocal::tui::Action::down);
|
||
}
|
||
check(app.state().agenda_index + 1 == app.state().agenda_items.size(),
|
||
"agenda navigation clamps at the final item instead of wrapping");
|
||
|
||
app.handle_input("n");
|
||
check(app.state().agenda_start_day ==
|
||
nocal::from_sys_days(nocal::to_sys_days(first_day) + days{42}) &&
|
||
app.state().agenda_index == 0 && !app.state().agenda_items.empty() &&
|
||
app.state().agenda_items.front().day == app.state().agenda_start_day,
|
||
"n shifts exactly 42 days and refreshes agenda from its new start");
|
||
app.handle_input("\x1b[5~");
|
||
check(app.state().agenda_start_day == first_day && app.state().agenda_index == 0,
|
||
"Page Up shifts agenda exactly 42 days back and resets selection safely");
|
||
|
||
app.dispatch(nocal::tui::Action::down);
|
||
const auto chosen = app.state().agenda_items[app.state().agenda_index];
|
||
app.dispatch(nocal::tui::Action::select);
|
||
check(!app.agenda_active() && app.state().selected_day == chosen.day &&
|
||
app.state().visible_month == chosen.day.year() / chosen.day.month() &&
|
||
app.state().focused_event_id == std::optional{chosen.focus_id},
|
||
"Enter returns to month and focuses the exact recurring occurrence");
|
||
|
||
const auto chosen_day = app.state().selected_day;
|
||
const auto chosen_month = app.state().visible_month;
|
||
app.dispatch(nocal::tui::Action::agenda);
|
||
app.dispatch(nocal::tui::Action::next_month);
|
||
app.dispatch(nocal::tui::Action::agenda);
|
||
check(!app.agenda_active() && app.state().selected_day == chosen_day &&
|
||
app.state().visible_month == chosen_month,
|
||
"g closes agenda without changing the prior month selection");
|
||
app.dispatch(nocal::tui::Action::agenda);
|
||
app.dispatch(nocal::tui::Action::previous_month);
|
||
app.dispatch(nocal::tui::Action::back);
|
||
check(!app.agenda_active() && app.state().selected_day == chosen_day &&
|
||
app.state().visible_month == chosen_month,
|
||
"Escape closes agenda without applying its shifted window to month selection");
|
||
check(month_day == first_day && month == first_day.year() / first_day.month(),
|
||
"agenda navigation fixture starts on the controller's month selection");
|
||
check(save_calls == 0, "all agenda navigation and selection remains read-only");
|
||
|
||
std::vector<nocal::Event> empty_events;
|
||
nocal::tui::TuiApp empty{empty_events, -1, -1};
|
||
empty.dispatch(nocal::tui::Action::agenda);
|
||
empty.dispatch(nocal::tui::Action::up);
|
||
empty.dispatch(nocal::tui::Action::down);
|
||
empty.dispatch(nocal::tui::Action::select);
|
||
check(empty.agenda_active() && empty.state().agenda_items.empty() &&
|
||
empty.state().agenda_index == 0,
|
||
"empty agenda navigation and Enter are safe no-ops");
|
||
}
|
||
|
||
void test_agenda_search_and_calendar_overlays()
|
||
{
|
||
const auto day = nocal::today();
|
||
auto personal = make_event("agenda-personal", "Personal", day, 8, 0, day, 9, 0);
|
||
personal.calendar_id = "personal";
|
||
std::vector<nocal::Event> events{
|
||
make_event("agenda-default", "Default", day, 7, 0, day, 8, 0), personal};
|
||
int save_calls = 0;
|
||
nocal::tui::TuiApp app{
|
||
events, [&](std::span<const nocal::Event>) { ++save_calls; }, -1, -1};
|
||
|
||
app.dispatch(nocal::tui::Action::agenda);
|
||
app.handle_input("/");
|
||
check(app.agenda_active() && app.search_active(),
|
||
"search prompt can overlay an active agenda");
|
||
app.handle_input("\x1b");
|
||
check(app.agenda_active() && !app.search_active(),
|
||
"Escape closes search and returns to the underlying agenda");
|
||
|
||
app.handle_input("c");
|
||
check(app.agenda_active() && app.calendar_picker_active(),
|
||
"calendar picker can overlay an active agenda");
|
||
app.dispatch(nocal::tui::Action::down);
|
||
app.dispatch(nocal::tui::Action::select);
|
||
check(!app.calendars()[1].visible && app.state().agenda_items.size() == 1 &&
|
||
app.state().agenda_items.front().focus_id == "agenda-default",
|
||
"hiding a calendar immediately refreshes the agenda beneath the picker");
|
||
app.dispatch(nocal::tui::Action::back);
|
||
check(app.agenda_active() && !app.calendar_picker_active(),
|
||
"closing the picker returns to the agenda");
|
||
check(save_calls == 0, "agenda overlays and visibility changes never save events");
|
||
}
|
||
|
||
} // namespace
|
||
|
||
int main()
|
||
{
|
||
test_event_focus_cycles_in_display_order();
|
||
test_open_close_and_day_movement();
|
||
test_empty_day_is_safe();
|
||
test_reader_navigation_and_synthetic_ids();
|
||
test_key_decoding();
|
||
test_crud_preconditions_and_add();
|
||
test_edit_preserves_uid_and_rolls_back_on_save_failure();
|
||
test_delete_cancel_confirm_and_rollback();
|
||
test_crud_targets_synthetic_identities();
|
||
test_add_edit_delete_undo_redo();
|
||
test_history_persistence_failure_is_transactional();
|
||
test_new_mutation_invalidates_redo();
|
||
test_history_is_bounded_to_one_hundred_mutations();
|
||
test_history_is_unavailable_in_modal_states();
|
||
test_focused_line_rendering();
|
||
test_focus_brings_hidden_event_into_view();
|
||
test_timed_event_details();
|
||
test_all_day_and_multiday_details();
|
||
test_narrow_detail_wrapping_without_ansi();
|
||
test_multiday_segment_labels_and_series_messaging();
|
||
test_recurring_occurrence_navigation_and_whole_series_crud();
|
||
test_rdate_only_occurrence_identity_and_whole_series_crud();
|
||
test_recurrence_grid_expansion_and_source_zone_display();
|
||
test_search_prompt_results_and_occurrence_jump();
|
||
test_calendar_visibility_filters_without_partial_saves();
|
||
test_agenda_window_order_visibility_and_bounds();
|
||
test_agenda_navigation_shift_choice_and_close();
|
||
test_agenda_search_and_calendar_overlays();
|
||
if (failures != 0) {
|
||
std::cerr << failures << " TUI focus test(s) failed\n";
|
||
return EXIT_FAILURE;
|
||
}
|
||
std::cout << "TUI focus tests passed\n";
|
||
return EXIT_SUCCESS;
|
||
}
|