#include "nocal/domain/date.hpp" #include "nocal/tui/Screen.hpp" #include "nocal/tui/TuiApp.hpp" #include #include #include #include #include #include #include #include #include #include #include #include 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_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 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 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 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 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 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"); } void test_crud_preconditions_and_add() { std::vector events; int save_calls = 0; std::vector saved; nocal::tui::TuiApp app{ events, [&](const std::span 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 events{original}; int save_calls = 0; nocal::tui::TuiApp app{ events, [&](std::span) { ++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) { ++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 events{target, keep}; int save_calls = 0; nocal::tui::TuiApp app{ events, [&](std::span) { ++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 rollback_events{target}; nocal::tui::TuiApp rollback_app{ rollback_events, [](std::span) { 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 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 events; int save_calls = 0; nocal::tui::TuiApp app{ events, [&](std::span) { ++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 events{original}; bool fail = false; int save_calls = 0; nocal::tui::TuiApp app{ events, [&](std::span) { ++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 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 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 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 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 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 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 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 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 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 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 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_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 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 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 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(); } } // 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_recurrence_grid_expansion_and_source_zone_display(); 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; }