feat: add occurrence-aware appointment search
This commit is contained in:
@@ -155,6 +155,34 @@ void test_event_queries()
|
||||
"invalid backwards event never overlaps");
|
||||
}
|
||||
|
||||
void test_event_search()
|
||||
{
|
||||
nocal::Event event;
|
||||
event.title = "Quarterly Planning";
|
||||
event.description = "Review the launch checklist";
|
||||
event.location = "North Conference Room";
|
||||
event.calendar_id = "TEAM-OPERATIONS";
|
||||
|
||||
check(nocal::event_matches_query(event, "quarterly"),
|
||||
"event search matches the title case-insensitively");
|
||||
check(nocal::event_matches_query(event, "launch check"),
|
||||
"event search matches a description substring");
|
||||
check(nocal::event_matches_query(event, "conference"),
|
||||
"event search matches a location substring");
|
||||
check(nocal::event_matches_query(event, "team-operations"),
|
||||
"event search matches the calendar ID case-insensitively");
|
||||
check(!nocal::event_matches_query(event, ""),
|
||||
"an empty event search query matches nothing");
|
||||
check(!nocal::event_matches_query(event, "budget"),
|
||||
"event search rejects text absent from every searchable field");
|
||||
|
||||
event.title = "Caf\xC3\xA9 REVIEW";
|
||||
check(nocal::event_matches_query(event, "Caf\xC3\xA9 review"),
|
||||
"event search preserves non-ASCII bytes while folding ASCII");
|
||||
check(!nocal::event_matches_query(event, "CAF\xC3\x89"),
|
||||
"event search does not locale-fold non-ASCII bytes");
|
||||
}
|
||||
|
||||
void test_daily_dst_recurrence()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
@@ -378,6 +406,7 @@ int main()
|
||||
test_dates();
|
||||
test_month_layout();
|
||||
test_event_queries();
|
||||
test_event_search();
|
||||
test_daily_dst_recurrence();
|
||||
test_floating_dst_and_until();
|
||||
test_weekly_count_and_exdates();
|
||||
|
||||
@@ -875,6 +875,87 @@ void test_recurrence_grid_expansion_and_source_zone_display()
|
||||
::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");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
@@ -901,6 +982,7 @@ int main()
|
||||
test_multiday_segment_labels_and_series_messaging();
|
||||
test_recurring_occurrence_navigation_and_whole_series_crud();
|
||||
test_recurrence_grid_expansion_and_source_zone_display();
|
||||
test_search_prompt_results_and_occurrence_jump();
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " TUI focus test(s) failed\n";
|
||||
return EXIT_FAILURE;
|
||||
|
||||
@@ -36,6 +36,11 @@ void test_full_month_render()
|
||||
.focused_event_id = std::nullopt,
|
||||
.show_event_details = false,
|
||||
.confirm_delete = false,
|
||||
.search_prompt = false,
|
||||
.show_search_results = false,
|
||||
.search_query = {},
|
||||
.search_results = {},
|
||||
.search_result_index = 0,
|
||||
.notification = {},
|
||||
.notification_is_error = false,
|
||||
};
|
||||
@@ -79,6 +84,11 @@ void test_compact_and_input()
|
||||
.focused_event_id = std::nullopt,
|
||||
.show_event_details = false,
|
||||
.confirm_delete = false,
|
||||
.search_prompt = false,
|
||||
.show_search_results = false,
|
||||
.search_query = {},
|
||||
.search_results = {},
|
||||
.search_result_index = 0,
|
||||
.notification = {},
|
||||
.notification_is_error = false,
|
||||
};
|
||||
@@ -103,6 +113,49 @@ void test_navigation()
|
||||
"down moves by one week");
|
||||
}
|
||||
|
||||
void test_search_frames()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
auto state = nocal::tui::ScreenState{};
|
||||
state.visible_month = year{2026} / July;
|
||||
state.selected_day = year{2026} / July / day{17};
|
||||
state.today = state.selected_day;
|
||||
state.width = 72;
|
||||
state.height = 12;
|
||||
state.ansi = false;
|
||||
state.search_prompt = true;
|
||||
state.search_query = "team";
|
||||
auto frame = nocal::tui::render_month(
|
||||
std::span<const nocal::tui::CalendarItem>{}, state);
|
||||
check(frame.find("SEARCH APPOINTMENTS") != std::string::npos &&
|
||||
frame.find("/ team_") != std::string::npos,
|
||||
"search prompt renders its query and context");
|
||||
check(std::count(frame.begin(), frame.end(), '\n') == 11,
|
||||
"search prompt fills the requested height");
|
||||
|
||||
state.search_prompt = false;
|
||||
state.show_search_results = true;
|
||||
state.search_results = {
|
||||
{.focus_id = "one", .title = "Team planning",
|
||||
.day = year{2026} / July / day{18}, .time_of_day = hours{9},
|
||||
.all_day = false, .location = "Studio", .recurring = false},
|
||||
{.focus_id = "two", .title = "Team holiday",
|
||||
.day = year{2026} / July / day{19}, .time_of_day = std::nullopt,
|
||||
.all_day = true, .location = {}, .recurring = true},
|
||||
};
|
||||
state.search_result_index = 1;
|
||||
frame = nocal::tui::render_month(
|
||||
std::span<const nocal::tui::CalendarItem>{}, state);
|
||||
check(frame.find("2 matches") != std::string::npos &&
|
||||
frame.find("2026-07-18 09:00") != std::string::npos &&
|
||||
frame.find("Team holiday") != std::string::npos,
|
||||
"search results render counts, dates, times, and titles");
|
||||
check(frame.find("\x1b[") == std::string::npos,
|
||||
"search remains readable with ANSI disabled");
|
||||
check(nocal::tui::decode_key("/") == nocal::tui::Action::search,
|
||||
"slash opens appointment search");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
@@ -110,6 +163,7 @@ int main()
|
||||
test_full_month_render();
|
||||
test_compact_and_input();
|
||||
test_navigation();
|
||||
test_search_frames();
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " TUI test(s) failed\n";
|
||||
return EXIT_FAILURE;
|
||||
|
||||
Reference in New Issue
Block a user