feat: add calendar visibility picker

Derive session calendars from event metadata and apply one visibility contract to month rendering, appointment focus, and search without filtering persistence writes.

Add keyboard picker coverage, full-model save regression tests, documentation, warning-clean builds, sanitizer verification, and live PTY terminal restoration checks.
This commit is contained in:
2026-07-18 09:01:27 +01:00
parent 2774087d14
commit 88d370bf99
14 changed files with 416 additions and 12 deletions

View File

@@ -956,6 +956,89 @@ void test_search_prompt_results_and_occurrence_jump()
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");
}
} // namespace
int main()
@@ -983,6 +1066,7 @@ int main()
test_recurring_occurrence_navigation_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();
if (failures != 0) {
std::cerr << failures << " TUI focus test(s) failed\n";
return EXIT_FAILURE;