Files
nocal/tests/tui_focus_tests.cpp
Bernardo Magri cfca6ab6d0 feat: support safe recurrence additions
Parse, persist, expand, and round-trip compatible RDATE values while preserving whole-series TUI behavior and conservative rewrite guards.

Verified: normal, warning-as-error, and ASan/UBSan builds pass all 9 suites; storage failure paths preserve calendar and backup bytes; live PTY restores terminal state.
2026-07-18 10:14:53 +01:00

1289 lines
59 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 <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;
}
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 = nocal::tui::render_month(events, app.state());
check(confirmation.find("Delete target") != std::string::npos &&
confirmation.find("y confirm") != 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("Ctrl-R redo") != std::string::npos,
"the month footer advertises both history shortcuts");
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");
}
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;
}