Files
kebab/crates/kebab-app/tests/search_lexical.rs
altair823 8dfeb007e0 chore(search): PR #219 회차 1 리뷰 반영 — 잔존 dead-cache 주석/help text 정리
리뷰어 지적 6건 + 전체-트리 grep 으로 추가 발견한 동일 부류 4건:
- search --trace help text(CLI main.rs + MCP search tool) "Bypasses cache"
  → "always a fresh retriever run" (없는 캐시 광고 제거)
- corpus_revision 의 실제 소비자는 incremental ingest 가 아니라 cursor
  pagination(stale_cursor) 임을 반영 — HANDOFF 노트 사실 정정 + ingest.rs
  bump 주석/warn 로그 + corpus_revision/search_lexical 테스트 주석 재서술
  (검증: cursor.rs + app.rs:327/333/498 가 유일 소비자, ingest 는 bump 만)
- bulk.rs "LRU cache amortize" → embedder cold-start 만 amortize
- app.rs lexical_index_version "search cache 무효화" 제거
- app-facade README 의 search cache bullet 을 "(제거됨 — 이력)" 표시

잔여 dead-cache 개념 참조 0건(active tree), clippy -D warnings 클린.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
2026-06-26 17:25:03 +00:00

131 lines
5.6 KiB
Rust

//! Lexical search integration tests. The vector / hybrid lanes are
//! AVX-gated and live in `search_vector.rs` (`#[ignore]`).
mod common;
use common::TestEnv;
#[test]
fn lexical_search_returns_hits_after_ingest() {
let env = TestEnv::lexical_only();
kebab_app::ingest_with_config(env.config.clone(), env.scope(), kebab_app::IngestOpts { summary_only: true, ..Default::default() }).unwrap();
// "Ownership" appears as a heading + paragraph in intro.md and
// matches FTS5 default tokenizer easily.
let hits =
kebab_app::search_with_config(env.config.clone(), common::lexical_query("ownership"))
.unwrap();
assert!(!hits.is_empty(), "expected ≥1 hit for 'ownership'");
for h in &hits {
// Lexical retriever sets embedding_model=None per spec.
assert!(
h.embedding_model.is_none(),
"lexical-mode hit must have None embedding_model: {h:?}"
);
assert_eq!(
h.retrieval.method,
kebab_core::SearchMode::Lexical,
"method label should be Lexical"
);
}
}
#[test]
fn lexical_search_empty_query_returns_empty() {
let env = TestEnv::lexical_only();
kebab_app::ingest_with_config(env.config.clone(), env.scope(), kebab_app::IngestOpts { summary_only: true, ..Default::default() }).unwrap();
let hits =
kebab_app::search_with_config(env.config.clone(), common::lexical_query(" ")).unwrap();
assert!(hits.is_empty(), "blank query must short-circuit empty");
}
/// p9-fb-19 — `App::search` returns the same hit list for a repeated
/// query (cache hit doesn't corrupt the result). Both calls share an
/// `App` instance so the cache is in scope.
#[test]
fn cached_search_returns_same_hits_on_repeat() {
let env = TestEnv::lexical_only();
kebab_app::ingest_with_config(env.config.clone(), env.scope(), kebab_app::IngestOpts { summary_only: true, ..Default::default() }).unwrap();
let app = kebab_app::App::open_with_config(env.config.clone()).unwrap();
let first = app.search(common::lexical_query("ownership")).unwrap();
assert!(!first.is_empty(), "first call must return ≥1 hit");
let second = app.search(common::lexical_query("ownership")).unwrap();
assert_eq!(
first.len(),
second.len(),
"cached call must yield identical hit count"
);
for (a, b) in first.iter().zip(second.iter()) {
assert_eq!(a.chunk_id, b.chunk_id, "chunk_ids must align");
assert_eq!(a.rank, b.rank, "ranks must align");
}
}
/// p9-fb-19 — query normalization (NFKC + trim + lowercase) collapses
/// `"Ownership"` / `"OWNERSHIP"` / `" ownership "` into one cache
/// entry. Verified by ensuring all three forms return the same hits.
#[test]
fn cache_key_normalization_treats_case_and_whitespace_as_equivalent() {
let env = TestEnv::lexical_only();
kebab_app::ingest_with_config(env.config.clone(), env.scope(), kebab_app::IngestOpts { summary_only: true, ..Default::default() }).unwrap();
let app = kebab_app::App::open_with_config(env.config.clone()).unwrap();
let plain = app.search(common::lexical_query("ownership")).unwrap();
let upper = app.search(common::lexical_query("OWNERSHIP")).unwrap();
let padded = app.search(common::lexical_query(" Ownership ")).unwrap();
assert_eq!(plain.len(), upper.len());
assert_eq!(plain.len(), padded.len());
// chunk_ids are deterministic — same query class, same set.
let plain_ids: Vec<_> = plain.iter().map(|h| h.chunk_id.0.clone()).collect();
let upper_ids: Vec<_> = upper.iter().map(|h| h.chunk_id.0.clone()).collect();
assert_eq!(plain_ids, upper_ids);
}
/// p9-fb-19 — first ingest with commits bumps `corpus_revision` from
/// 0 to ≥1. Verified by reading the persisted kv via a fresh
/// SqliteStore handle (the field on `App` is `pub(crate)`).
#[test]
fn first_ingest_bumps_corpus_revision() {
let env = TestEnv::lexical_only();
let store_before = kebab_store_sqlite::SqliteStore::open(&env.config.storage).unwrap();
store_before.run_migrations().unwrap();
// V004 seeds 0; V009 + V010 + V011 migrations each bump by 1 to
// invalidate outstanding pagination cursors (spec §5.2). Baseline before ingest = 3.
// (V012 derivation_cache + V013 drop-chunk-aliases are structural/additive
// — neither bumps corpus_revision.)
let baseline = store_before.corpus_revision();
assert_eq!(baseline, 3, "fresh store post-V011 baseline = 3");
let report = kebab_app::ingest_with_config(env.config.clone(), env.scope(), kebab_app::IngestOpts { summary_only: true, ..Default::default() }).unwrap();
assert!(
report.new + report.updated > 0,
"first ingest must commit ≥1 doc"
);
let store_after = kebab_store_sqlite::SqliteStore::open(&env.config.storage).unwrap();
assert!(
store_after.corpus_revision() > baseline,
"ingest commit must bump corpus_revision past baseline {baseline} (got {})",
store_after.corpus_revision(),
);
}
#[test]
fn vector_mode_with_provider_none_errors_clearly() {
let env = TestEnv::lexical_only();
kebab_app::ingest_with_config(env.config.clone(), env.scope(), kebab_app::IngestOpts { summary_only: true, ..Default::default() }).unwrap();
let q = kebab_core::SearchQuery {
text: "ownership".to_string(),
mode: kebab_core::SearchMode::Vector,
k: 10,
filters: kebab_core::SearchFilters::default(),
};
let err = kebab_app::search_with_config(env.config.clone(), q).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("embeddings disabled") || msg.contains("disabled"),
"error must mention embeddings disabled: {msg}"
);
}