Phase C4 executor 의 마지막 `fix(test): clippy + fmt fixes` commit 이 test file 부분만 fmt 적용. workspace 전체 fmt 누락 발견 → cargo fmt --all 적용. 모든 import alphabetical reorder + line wrapping 정합. 추가 untracked artifact 동시 commit: - docs/superpowers/specs/2026-05-28-v0.20-ingest-log-spec.md (491 line, ACCEPT) - docs/superpowers/plans/2026-05-28-v0.20-ingest-log-plan.md (616 line, ACCEPT) workspace test: 1370 passed / 0 failed / 50 ignored, ingest_log_smoke green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
746 lines
25 KiB
Rust
746 lines
25 KiB
Rust
//! Unit + snapshot tests for the Search pane (P9-2).
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use kebab_config::Config;
|
|
use kebab_core::{
|
|
ChunkId, ChunkerVersion, Citation, DocumentId, EmbeddingModelId, IndexVersion, RetrievalDetail,
|
|
SearchHit, SearchMode, WorkspacePath,
|
|
};
|
|
use kebab_tui::{
|
|
App, KeyOutcome, Mode, Pane, SearchState, SearchWorkerMessage, build_jump_command,
|
|
handle_key_search, poll_search_worker, render_search, search_debounce_due,
|
|
};
|
|
use ratatui::Terminal;
|
|
use ratatui::backend::TestBackend;
|
|
use ratatui::layout::Rect;
|
|
use std::path::Path;
|
|
|
|
fn fresh_app() -> App {
|
|
let mut config = Config::defaults();
|
|
config.storage.data_dir = "/tmp/kebab-tui-search-tests-noop".to_string();
|
|
config.workspace.root = "/tmp/kebab-tui-search-tests-noop/workspace".to_string();
|
|
let mut app = App::new(config).expect("App::new");
|
|
app.focus = Pane::Search;
|
|
// p9-fb-12 follow-up: mirror the run loop's auto-flip — Search
|
|
// pane auto-Insert. Tests that exercise Normal-mode navigation
|
|
// (j/k move selection, i / g pre-pass) set Mode::Normal
|
|
// explicitly.
|
|
app.mode = kebab_tui::Mode::auto_for(Pane::Search);
|
|
app.search = Some(SearchState::default());
|
|
app
|
|
}
|
|
|
|
fn make_hit(rank: u32, path: &str, snippet: &str, citation: Citation) -> SearchHit {
|
|
SearchHit {
|
|
rank,
|
|
chunk_id: ChunkId(format!("{rank:0<32}")),
|
|
doc_id: DocumentId(format!("{:0<32}", rank * 2)),
|
|
doc_path: WorkspacePath::new(path.into()).unwrap(),
|
|
heading_path: vec!["Section".into(), "Sub".into()],
|
|
section_label: Some("Sub".into()),
|
|
snippet: snippet.into(),
|
|
citation,
|
|
retrieval: RetrievalDetail {
|
|
method: SearchMode::Hybrid,
|
|
fusion_score: 0.9,
|
|
lexical_score: Some(0.8),
|
|
vector_score: Some(0.95),
|
|
lexical_rank: Some(rank),
|
|
vector_rank: Some(rank),
|
|
},
|
|
index_version: IndexVersion("v1".into()),
|
|
embedding_model: Some(EmbeddingModelId("multilingual-e5-small".into())),
|
|
chunker_version: ChunkerVersion("md-heading-v1".into()),
|
|
// fb-32: TUI search test fixtures pinned to UNIX_EPOCH + stale=false;
|
|
// staleness rendering covered in dedicated tests (Task 11).
|
|
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
stale: false,
|
|
score_kind: kebab_core::ScoreKind::Rrf,
|
|
repo: None,
|
|
code_lang: None,
|
|
}
|
|
}
|
|
|
|
fn line_citation(path: &str, line: u32) -> Citation {
|
|
Citation::Line {
|
|
path: WorkspacePath::new(path.into()).unwrap(),
|
|
start: line,
|
|
end: line,
|
|
section: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn esc_returns_to_library() {
|
|
let mut app = fresh_app();
|
|
let outcome = handle_key_search(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
|
|
}
|
|
|
|
#[test]
|
|
fn typing_appends_to_input_and_marks_dirty() {
|
|
let mut app = fresh_app();
|
|
for ch in "hello".chars() {
|
|
handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
|
|
);
|
|
}
|
|
let s = app.search.as_ref().unwrap();
|
|
assert_eq!(s.input.as_str(), "hello");
|
|
assert!(s.input_dirty_at.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn backspace_removes_last_char() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.input.push_str("abc");
|
|
}
|
|
handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "ab");
|
|
assert_eq!(app.search.as_ref().unwrap().input.cursor_col(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn tab_cycles_mode_lex_vec_hybrid() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.mode = SearchMode::Lexical;
|
|
}
|
|
let press_tab = |app: &mut App| {
|
|
handle_key_search(app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
};
|
|
press_tab(&mut app);
|
|
assert_eq!(app.search.as_ref().unwrap().mode, SearchMode::Vector);
|
|
press_tab(&mut app);
|
|
assert_eq!(app.search.as_ref().unwrap().mode, SearchMode::Hybrid);
|
|
press_tab(&mut app);
|
|
assert_eq!(app.search.as_ref().unwrap().mode, SearchMode::Lexical);
|
|
}
|
|
|
|
#[test]
|
|
fn enter_with_query_emits_refresh() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.input.push_str("rust");
|
|
}
|
|
let outcome = handle_key_search(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(outcome, KeyOutcome::Refresh);
|
|
}
|
|
|
|
#[test]
|
|
fn enter_with_empty_query_is_continue() {
|
|
let mut app = fresh_app();
|
|
let outcome = handle_key_search(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(outcome, KeyOutcome::Continue);
|
|
}
|
|
|
|
#[test]
|
|
fn j_k_move_selection_within_bounds() {
|
|
let mut app = fresh_app();
|
|
// p9-fb-12 follow-up: j/k navigate only in Normal mode. Search
|
|
// pane auto-Insert via fresh_app, flip to Normal explicitly to
|
|
// exercise the navigation branch.
|
|
app.mode = kebab_tui::Mode::Normal;
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.hits = vec![
|
|
make_hit(1, "a.md", "snip a\nline2", line_citation("a.md", 1)),
|
|
make_hit(2, "b.md", "snip b\nline2", line_citation("b.md", 5)),
|
|
make_hit(3, "c.md", "snip c\nline2", line_citation("c.md", 7)),
|
|
];
|
|
s.selected_hit = 0;
|
|
}
|
|
handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.search.as_ref().unwrap().selected_hit, 1);
|
|
handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.search.as_ref().unwrap().selected_hit, 2);
|
|
// Bounds clamp.
|
|
handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.search.as_ref().unwrap().selected_hit, 2);
|
|
handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.search.as_ref().unwrap().selected_hit, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn build_jump_command_line_uses_plus_n_for_vim() {
|
|
let citation = line_citation("notes/foo.md", 42);
|
|
let (program, args) = build_jump_command(&citation, "vim", Path::new("/tmp/workspace"));
|
|
assert_eq!(program, "vim");
|
|
assert_eq!(
|
|
args,
|
|
vec!["+42".to_string(), "/tmp/workspace/notes/foo.md".into()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_jump_command_line_uses_g_flag_for_code() {
|
|
let citation = line_citation("notes/foo.md", 42);
|
|
let (program, args) = build_jump_command(&citation, "code", Path::new("/tmp/workspace"));
|
|
assert_eq!(program, "code");
|
|
assert_eq!(
|
|
args,
|
|
vec!["-g".to_string(), "/tmp/workspace/notes/foo.md:42".into()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_jump_command_passes_through_editor_args() {
|
|
let citation = line_citation("a.md", 7);
|
|
let (program, args) = build_jump_command(&citation, "nvim -p", Path::new("/ws"));
|
|
assert_eq!(program, "nvim");
|
|
// Leading `-p` from $EDITOR env preserved before the +N path arg.
|
|
assert!(args[0] == "-p", "leading editor arg preserved: {args:?}");
|
|
assert!(args.contains(&"+7".to_string()));
|
|
assert!(args.contains(&"/ws/a.md".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn render_search_with_hits_shows_input_and_path() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.input.push_str("rust traits");
|
|
s.mode = SearchMode::Hybrid;
|
|
s.hits = vec![
|
|
make_hit(
|
|
1,
|
|
"notes/rust.md",
|
|
"trait dispatch\nis dynamic",
|
|
line_citation("notes/rust.md", 12),
|
|
),
|
|
make_hit(
|
|
2,
|
|
"notes/dyn.md",
|
|
"dynamic dispatch\nvtable",
|
|
line_citation("notes/dyn.md", 3),
|
|
),
|
|
];
|
|
s.selected_hit = 0;
|
|
}
|
|
let backend = TestBackend::new(80, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| {
|
|
let area = Rect::new(0, 0, 80, 24);
|
|
render_search(f, area, &app);
|
|
})
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer().clone();
|
|
let rendered: String = (0..buffer.area.height)
|
|
.map(|y| {
|
|
(0..buffer.area.width)
|
|
.map(|x| buffer[(x, y)].symbol())
|
|
.collect::<String>()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
rendered.contains("hybrid"),
|
|
"mode badge rendered: {rendered}"
|
|
);
|
|
assert!(rendered.contains("rust traits"), "input text rendered");
|
|
assert!(
|
|
rendered.contains("notes/rust.md"),
|
|
"first hit path rendered"
|
|
);
|
|
assert!(
|
|
rendered.contains("notes/dyn.md"),
|
|
"second hit path rendered"
|
|
);
|
|
}
|
|
|
|
/// p9-fb-32: Search pane prefixes the rank/score header line with a
|
|
/// Warning-styled `[STALE] ` Span when `hit.stale == true`. Pin the
|
|
/// text-level signal (color is exercised via the cell scan below).
|
|
#[test]
|
|
fn search_pane_shows_stale_badge_for_old_doc() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.input.push_str("rust");
|
|
s.mode = SearchMode::Hybrid;
|
|
let mut stale_hit = make_hit(
|
|
1,
|
|
"notes/old.md",
|
|
"ancient trait dispatch\nstill relevant",
|
|
line_citation("notes/old.md", 7),
|
|
);
|
|
// Synthesize an indexed_at well past any threshold; combined
|
|
// with `stale: true` this matches the post-process output of
|
|
// `kebab_app::mark_stale_in_place`.
|
|
stale_hit.indexed_at = time::OffsetDateTime::UNIX_EPOCH;
|
|
stale_hit.stale = true;
|
|
let fresh_hit = make_hit(
|
|
2,
|
|
"notes/new.md",
|
|
"modern dispatch\nvtable",
|
|
line_citation("notes/new.md", 3),
|
|
);
|
|
s.hits = vec![stale_hit, fresh_hit];
|
|
s.selected_hit = 0;
|
|
}
|
|
let backend = TestBackend::new(80, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| {
|
|
let area = Rect::new(0, 0, 80, 24);
|
|
render_search(f, area, &app);
|
|
})
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer().clone();
|
|
let rendered: String = (0..buffer.area.height)
|
|
.map(|y| {
|
|
(0..buffer.area.width)
|
|
.map(|x| buffer[(x, y)].symbol())
|
|
.collect::<String>()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
rendered.contains("[STALE]"),
|
|
"[STALE] badge must render as text on stale hit: {rendered}"
|
|
);
|
|
// The badge appears on the same line that begins with rank `1.`
|
|
// — the stale hit. The fresh `notes/new.md` row must NOT carry
|
|
// the badge.
|
|
let stale_line = rendered
|
|
.lines()
|
|
.find(|l| l.contains("notes/old.md"))
|
|
.expect("stale hit's header line must render");
|
|
assert!(
|
|
stale_line.contains("[STALE]"),
|
|
"stale row must carry [STALE] badge: {stale_line}"
|
|
);
|
|
let fresh_line = rendered
|
|
.lines()
|
|
.find(|l| l.contains("notes/new.md"))
|
|
.expect("fresh hit's header line must render");
|
|
assert!(
|
|
!fresh_line.contains("[STALE]"),
|
|
"fresh row must NOT carry [STALE] badge: {fresh_line}"
|
|
);
|
|
// Color side: the `[` of `[STALE]` must be Yellow (Warning role,
|
|
// dark palette default).
|
|
let mut stale_yellow_found = false;
|
|
for y in 0..buffer.area.height {
|
|
for x in 0..buffer.area.width {
|
|
let cell = &buffer[(x, y)];
|
|
if cell.symbol() == "[" {
|
|
// The cell to the right should be 'S' if this is the
|
|
// start of `[STALE]` — narrow check to avoid the
|
|
// rank/score `[` cells (there shouldn't be any there).
|
|
if x + 1 < buffer.area.width && buffer[(x + 1, y)].symbol() == "S" {
|
|
if let ratatui::style::Color::Yellow = cell.fg {
|
|
stale_yellow_found = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
stale_yellow_found,
|
|
"[STALE] badge must be rendered with Yellow (Warning role) fg"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_state_renders_without_panic() {
|
|
let app = fresh_app();
|
|
let backend = TestBackend::new(80, 20);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| {
|
|
let area = Rect::new(0, 0, 80, 20);
|
|
render_search(f, area, &app);
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
/// p9-fb-12 follow-up: in Insert mode, plain `j` types into input
|
|
/// (does NOT move selection). Replaces the pre-fb-12 heuristic
|
|
/// "is_typing_mod" with mode-authoritative dispatch.
|
|
#[test]
|
|
fn j_in_insert_types_does_not_move_selection() {
|
|
let mut app = fresh_app();
|
|
// Insert is auto for Search, but explicit for clarity.
|
|
app.mode = kebab_tui::Mode::Insert;
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.hits = vec![
|
|
make_hit(1, "a.md", "snip", line_citation("a.md", 1)),
|
|
make_hit(2, "b.md", "snip", line_citation("b.md", 1)),
|
|
];
|
|
s.selected_hit = 0;
|
|
}
|
|
handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
|
);
|
|
let s = app.search.as_ref().unwrap();
|
|
assert_eq!(s.input.as_str(), "j", "j must type in Insert mode");
|
|
assert_eq!(s.selected_hit, 0, "selection must NOT move in Insert");
|
|
}
|
|
|
|
/// p9-fb-12 follow-up: in Normal mode, plain Char other than j/k/i/g
|
|
/// is a no-op (no typing in Normal). Pin so a future char binding
|
|
/// addition has to think about Normal-mode behavior.
|
|
#[test]
|
|
fn arbitrary_char_in_normal_mode_is_noop() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Normal;
|
|
handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE),
|
|
);
|
|
let s = app.search.as_ref().unwrap();
|
|
assert_eq!(s.input.as_str(), "", "Normal-mode Char must NOT type");
|
|
}
|
|
|
|
#[test]
|
|
fn shift_j_stays_in_input_does_not_move_selection() {
|
|
// R1 fix: SHIFT-J / SHIFT-K must reach the typing branch so
|
|
// queries like \"JSON\" / \"PostgreSQL\" don't get \"J\" eaten as
|
|
// a selection move.
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.hits = vec![
|
|
make_hit(1, "a.md", "snip\nl2", line_citation("a.md", 1)),
|
|
make_hit(2, "b.md", "snip\nl2", line_citation("b.md", 1)),
|
|
];
|
|
s.selected_hit = 0;
|
|
}
|
|
handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('J'), KeyModifiers::SHIFT),
|
|
);
|
|
let s = app.search.as_ref().unwrap();
|
|
assert_eq!(s.selected_hit, 0, "selection must NOT move on SHIFT-J");
|
|
assert_eq!(s.input.as_str(), "J", "SHIFT-J must reach the input buffer");
|
|
}
|
|
|
|
#[test]
|
|
fn shift_g_does_not_trigger_editor_jump() {
|
|
// R1 fix: capital G must not invoke jump_to_citation. Keep it
|
|
// as plain typing so \"Go\" / \"Greetings\" search queries work.
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.hits = vec![make_hit(1, "a.md", "snip\nl2", line_citation("a.md", 1))];
|
|
}
|
|
let outcome = handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
|
|
);
|
|
assert_eq!(outcome, KeyOutcome::Continue);
|
|
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "G");
|
|
}
|
|
|
|
/// p9-fb-09 — `g` on a hit enqueues an `EditorRequest` on `App.pending_editor`
|
|
/// rather than spawning the child synchronously. The run loop services the
|
|
/// queue with the `TuiTerminal` handle in scope so the post-resume
|
|
/// `terminal.clear()` can land (preventing the corrupted-redraw bug).
|
|
#[test]
|
|
fn g_key_enqueues_pending_editor_request() {
|
|
let mut app = fresh_app();
|
|
// p9-fb-12 follow-up: `g` (editor jump) is a Normal-mode command;
|
|
// in Insert mode it types as 'g'. Flip explicitly.
|
|
app.mode = kebab_tui::Mode::Normal;
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.hits = vec![make_hit(
|
|
1,
|
|
"notes/x.md",
|
|
"snippet",
|
|
line_citation("notes/x.md", 42),
|
|
)];
|
|
s.selected_hit = 0;
|
|
}
|
|
assert!(app.pending_editor().is_none(), "queue starts empty");
|
|
let outcome = handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(outcome, KeyOutcome::Continue);
|
|
let req = app
|
|
.pending_editor()
|
|
.expect("g on a hit must enqueue an EditorRequest");
|
|
match &req.citation {
|
|
Citation::Line { path, start, .. } => {
|
|
assert_eq!(path.0, "notes/x.md");
|
|
assert_eq!(*start, 42);
|
|
}
|
|
other => panic!("unexpected citation variant: {other:?}"),
|
|
}
|
|
// editor_env reads $EDITOR — fall back to "vi" for tests.
|
|
assert!(!req.editor_env.is_empty(), "editor_env must be populated");
|
|
}
|
|
|
|
/// p9-fb-09 — `g` with no hits is a no-op; the queue stays empty.
|
|
#[test]
|
|
fn g_key_with_no_hits_does_not_enqueue() {
|
|
let mut app = fresh_app();
|
|
// Search slot present, hits empty.
|
|
let _outcome = handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
|
|
);
|
|
assert!(
|
|
app.pending_editor().is_none(),
|
|
"g with no hits must not enqueue"
|
|
);
|
|
}
|
|
|
|
// ── p9-fb-08: async search worker + generation counter ────────────
|
|
|
|
/// `poll_search_worker` applies a fresh result (matching generation)
|
|
/// to `state.search.hits` and clears `searching`.
|
|
#[test]
|
|
fn poll_worker_applies_fresh_result_to_hits() {
|
|
let mut app = fresh_app();
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.generation = 5;
|
|
s.searching = true;
|
|
s.worker_rx = Some(rx);
|
|
}
|
|
let hit = make_hit(1, "a.md", "snip", line_citation("a.md", 1));
|
|
tx.send(SearchWorkerMessage::Done {
|
|
generation: 5,
|
|
result: Ok(vec![hit]),
|
|
})
|
|
.unwrap();
|
|
poll_search_worker(&mut app);
|
|
let s = app.search.as_ref().unwrap();
|
|
assert_eq!(s.hits.len(), 1, "fresh result populates hits");
|
|
assert!(!s.searching, "searching cleared");
|
|
assert!(s.worker_rx.is_none(), "rx drained");
|
|
}
|
|
|
|
/// p9-fb-08 — a stale result (generation mismatch) is silently
|
|
/// dropped. `searching` remains true since a newer worker is
|
|
/// (presumed) still in flight.
|
|
#[test]
|
|
fn poll_worker_drops_stale_result() {
|
|
let mut app = fresh_app();
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.generation = 7;
|
|
s.searching = true;
|
|
s.worker_rx = Some(rx);
|
|
}
|
|
let hit = make_hit(1, "stale.md", "snip", line_citation("stale.md", 1));
|
|
// generation 3 < current 7 → stale.
|
|
tx.send(SearchWorkerMessage::Done {
|
|
generation: 3,
|
|
result: Ok(vec![hit]),
|
|
})
|
|
.unwrap();
|
|
poll_search_worker(&mut app);
|
|
let s = app.search.as_ref().unwrap();
|
|
assert!(s.hits.is_empty(), "stale result must not populate hits");
|
|
assert!(
|
|
s.searching,
|
|
"searching stays true so newer worker can resolve it"
|
|
);
|
|
assert!(
|
|
s.worker_rx.is_none(),
|
|
"stale message still drains the rx slot — worker is one-shot"
|
|
);
|
|
}
|
|
|
|
/// p9-fb-08 — `poll_search_worker` is a no-op when no worker is in
|
|
/// flight (no rx). Common case on every tick the user isn't typing.
|
|
#[test]
|
|
fn poll_worker_noop_when_no_rx() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.hits = vec![make_hit(1, "x.md", "snip", line_citation("x.md", 1))];
|
|
}
|
|
poll_search_worker(&mut app);
|
|
let s = app.search.as_ref().unwrap();
|
|
assert_eq!(s.hits.len(), 1, "existing hits preserved");
|
|
assert!(s.worker_rx.is_none());
|
|
}
|
|
|
|
/// Helper for the debounce_due tests — build a state with the four
|
|
/// fields the test cares about set, others default.
|
|
#[allow(clippy::field_reassign_with_default)]
|
|
fn search_state_with(
|
|
input: &str,
|
|
mode: SearchMode,
|
|
searching: bool,
|
|
last_query: Option<(String, SearchMode)>,
|
|
) -> SearchState {
|
|
let mut s = SearchState::default();
|
|
s.input.push_str(input);
|
|
s.mode = mode;
|
|
s.searching = searching;
|
|
s.last_query = last_query;
|
|
s.input_dirty_at = Some(time::OffsetDateTime::now_utc() - time::Duration::seconds(1));
|
|
s
|
|
}
|
|
|
|
/// p9-fb-08 — `debounce_due` skips when an in-flight worker is
|
|
/// already running for the same `(input, mode)` pair. Without this
|
|
/// guard, a "phantom keystroke" (re-typing the same chars) would
|
|
/// pile up workers and burn CPU.
|
|
#[test]
|
|
fn debounce_due_skips_when_in_flight_for_same_query() {
|
|
let s = search_state_with(
|
|
"hello",
|
|
SearchMode::Hybrid,
|
|
true,
|
|
Some(("hello".into(), SearchMode::Hybrid)),
|
|
);
|
|
assert!(
|
|
!search_debounce_due(&s),
|
|
"in-flight worker for same query → debounce must skip"
|
|
);
|
|
}
|
|
|
|
/// p9-fb-08 — `debounce_due` still fires when a different query is
|
|
/// in flight (user typed past the in-flight one). The new spawn
|
|
/// makes the prior result stale (handled by `poll_worker`).
|
|
#[test]
|
|
fn debounce_due_fires_when_in_flight_for_different_query() {
|
|
let s = search_state_with(
|
|
"hello world",
|
|
SearchMode::Hybrid,
|
|
true,
|
|
Some(("hello".into(), SearchMode::Hybrid)),
|
|
);
|
|
assert!(
|
|
search_debounce_due(&s),
|
|
"in-flight worker for old query → new query still spawns"
|
|
);
|
|
}
|
|
|
|
/// p9-fb-08 — disconnected channel (worker panicked) clears the rx
|
|
/// + searching flag so the next debounce tick can re-fire cleanly.
|
|
#[test]
|
|
fn poll_worker_handles_disconnected_channel() {
|
|
let mut app = fresh_app();
|
|
let (tx, rx) = std::sync::mpsc::channel::<SearchWorkerMessage>();
|
|
{
|
|
let s = app.search.as_mut().unwrap();
|
|
s.searching = true;
|
|
s.worker_rx = Some(rx);
|
|
}
|
|
drop(tx); // simulate worker panic before send
|
|
poll_search_worker(&mut app);
|
|
let s = app.search.as_ref().unwrap();
|
|
assert!(!s.searching, "searching cleared on disconnect");
|
|
assert!(s.worker_rx.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn no_search_state_returns_to_library() {
|
|
let mut config = Config::defaults();
|
|
config.storage.data_dir = "/tmp/kebab-tui-search-tests-noop".into();
|
|
let mut app = App::new(config).unwrap();
|
|
app.focus = Pane::Search;
|
|
// search slot intentionally None.
|
|
let outcome = handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
|
|
}
|
|
|
|
/// p9-fb-10: typing Hangul into Search input advances cursor by 2
|
|
/// per char and round-trips through the buffer correctly.
|
|
#[test]
|
|
fn hangul_typing_in_search_input_advances_cursor_by_two_per_char() {
|
|
let mut app = fresh_app();
|
|
// Switch to search and ensure Insert mode so chars type.
|
|
app.focus = Pane::Search;
|
|
app.mode = kebab_tui::Mode::auto_for(Pane::Search);
|
|
for ch in "한글".chars() {
|
|
handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
|
|
);
|
|
}
|
|
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "한글");
|
|
assert_eq!(app.search.as_ref().unwrap().input.cursor_col(), 4);
|
|
// Backspace pops the trailing Hangul char and rewinds 2 cols.
|
|
handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "한");
|
|
assert_eq!(app.search.as_ref().unwrap().input.cursor_col(), 2);
|
|
}
|
|
|
|
/// p9-fb-21: chunk-inspect was rebound from `i` to `o` so `i`
|
|
/// could become the universal Normal→Insert toggle. Pin the new
|
|
/// `o` key — Normal mode + at least one hit + selected → SwitchPane(Inspect).
|
|
#[test]
|
|
fn o_in_normal_with_hits_enters_inspect() {
|
|
let mut app = fresh_app();
|
|
app.focus = Pane::Search;
|
|
app.mode = Mode::Normal;
|
|
let s = app.search.as_mut().unwrap();
|
|
s.hits = vec![make_hit(1, "a.md", "snippet", line_citation("a.md", 1))];
|
|
s.selected_hit = 0;
|
|
let outcome = kebab_tui::handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Inspect));
|
|
}
|
|
|
|
/// p9-fb-21: `o` with empty hits is a no-op (Continue) — do not
|
|
/// enter Inspect with no target.
|
|
#[test]
|
|
fn o_in_normal_with_empty_hits_is_continue() {
|
|
let mut app = fresh_app();
|
|
app.focus = Pane::Search;
|
|
app.mode = Mode::Normal;
|
|
let outcome = kebab_tui::handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(outcome, KeyOutcome::Continue);
|
|
}
|
|
|
|
/// p9-fb-21: in Insert mode, `o` types as a regular char (the
|
|
/// chunk-inspect intercept only fires in Normal). Pin so a future
|
|
/// regression that drops the `is_normal` guard would fail this.
|
|
#[test]
|
|
fn o_in_insert_types_into_input() {
|
|
let mut app = fresh_app();
|
|
app.focus = Pane::Search;
|
|
app.mode = Mode::Insert;
|
|
let outcome = kebab_tui::handle_key_search(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(outcome, KeyOutcome::Continue);
|
|
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "o");
|
|
}
|