Files
kebab/crates/kebab-app/tests/common/mod.rs
altair823 7b3057fcd6 chore: PR #238 회차 2 리뷰 반영 — 폴백의 ? 회귀 + 렌더 경로 테스트
2회차 리뷰가 1회차 지적 일곱 건 모두 해결을 확인했고(HIGH 수정은
pdfium-render 내부 로직 정독 + 세로·가로 양쪽 실측으로 정당성 검증)
머지 가능으로 결론냈다. 남은 둘을 반영한다.

1) 페이지 폴백의 `?` 가 페이지 스킵을 문서 전체 중단으로 격상시켰다 (MEDIUM)

   1회차에서 넣은 페이지 단위 DCTDecode 폴백이 `dct(&pdf_doc)?` 였다.
   `extract_dctdecode_page_image` 는 페이지 딕셔너리를 못 읽으면 Err 를
   내므로, **렌더 실패 + DCT 추출 에러 = PDF 한 건 전체 OCR 중단**이다.
   두 실패는 상관관계도 있다 — 렌더가 깨지는 PDF 가 곧 lopdf 딕셔너리도
   이상한 PDF다.

   이 PR 이 직접 만든 회귀다. 이전에는 렌더러가 열린 이상 렌더 실패가
   절대 치명적일 수 없었다. 게다가 성격이 이 PR 이 잡으려던 "조용한 손실"
   과 정확히 같은 계열이다 — 페이지 하나 때문에 문서 전체를 잃는다.
   같은 파일 주석이 스스로 "the per-page loop is resilient by design" 이라
   적어 둔 규율을 한 줄이 깨고 있었다. `.ok().flatten()` 으로 고쳤다.

2) 분기가 가장 많이 늘어난 파일에 검증이 가장 적었다 (MEDIUM)

   1회차의 교훈이 "렌더가 실패하지 않으니 도그푸딩도 통과해 버렸다" 였는데,
   2회차에서 새로 만든 세 분기((Some,_) / (None,true) / (None,false)) 에
   테스트가 하나도 없었다. 추가한 2건은 전부 렌더 기하 테스트였다.

   `crates/kebab-app/tests/pdf_ocr_apply.rs` 에 세 건을 넣었다. OCR 엔진은
   기존 MockOcrEngine 이라 네트워크도 모델도 필요 없고, 래스터화와 그것을
   고르는 분기만 탄다.

   - `a_ccitt_page_reaches_the_ocr_engine_once_a_renderer_is_configured`
     — 이 이슈의 핵심. 바로 위 `f7_ccittfax_skipped_with_warning` 이 같은
     픽스처가 렌더러 없이 skip 됨을 고정하고 있으니, 둘이 짝으로 "렌더러가
     차이를 만든다" 를 증명한다. 렌더 경로를 우회시키면 실패하는 것을
     확인했다.
   - `a_dctdecode_page_still_works_with_a_renderer_configured` — 렌더러가
     기존 커버리지를 잃으면 구멍을 옮긴 것에 불과하다.
   - `a_pdf_the_renderer_cannot_open_falls_back_instead_of_blaming_config`

3) 잔가지 (LOW)

   - `long_edge_for_dpi` 의 `pub use` 가 죽었다. kebab-app 이 캡 계산을
     넘긴 뒤로 크레이트 밖 호출자가 없다. 비공개로 내렸다.
   - HOTFIXES 의 CER 표가 8+8+6=22 쪽인데 문장은 "8 페이지 전부" 였다.
   - 같은 절의 "수정 전" 이 "72 DPI 수정 전" 으로도 읽혔다. 그 해석이면
     거짓이라(초안도 렌더는 했다) "이 PR 이전에는" 으로 바꿨다.
   - README 의 max_pixels 상향 안내에 엔진 하드캡이 빠졌다 —
     ollama-vision 이 256~4096 으로 다시 조인다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-17 03:12:38 +09:00

180 lines
6.6 KiB
Rust

//! Shared test scaffolding for `kb-app` integration tests.
//!
//! Each test gets a fresh `TempDir` and a `Config` whose storage paths
//! all point inside it, so the user's real `data_dir` / `model_dir`
//! is never touched. The fixture workspace at
//! `tests/fixtures/workspace/` is *copied* into the temp dir for each
//! test so a write-side ingest can't trip on a read-only fixture
//! tree. The default lane (no `--ignored`) opts out of embeddings via
//! `provider = "none"` so AVX is not required.
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use kebab_config::Config;
use tempfile::TempDir;
/// Test environment: owns a `TempDir` and exposes a `Config` whose
/// storage paths live inside it.
pub struct TestEnv {
pub temp: TempDir,
pub workspace_root: PathBuf,
pub config: Config,
}
impl TestEnv {
/// Build an env with embeddings disabled (lexical-only). Default
/// lane — no AVX, no fastembed download.
pub fn lexical_only() -> Self {
let env = Self::new_inner();
let mut e = env;
e.config.models.embedding.provider = "none".to_string();
e.config.models.embedding.dimensions = 0;
e
}
/// Build an env with the default fastembed embedding provider.
/// Used by AVX-gated `#[ignore]` tests.
pub fn with_embeddings() -> Self {
Self::new_inner()
}
fn new_inner() -> Self {
let temp = tempfile::tempdir().expect("tempdir");
let workspace_root = temp.path().join("workspace");
copy_fixture_workspace(&workspace_root);
let data_dir = temp.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let model_dir = temp.path().join("models");
std::fs::create_dir_all(&model_dir).unwrap();
let mut config = Config::defaults();
config.workspace.root = Some(workspace_root.to_string_lossy().into_owned());
// Drop the ".obsidian" / "node_modules" excludes — they bring
// in nothing useful for fixtures and just hide debugging.
config.workspace.exclude.clear();
config.storage.data_dir = data_dir.to_string_lossy().into_owned();
// Pin model_dir to the TempDir so a future fastembed-touching
// test can't accidentally write to the user's `~/.local/share`.
config.storage.model_dir = model_dir.to_string_lossy().into_owned();
// Drop in a small chunk policy so the fixture's small files
// emit at least a couple of chunks even with overlap_tokens
// honored.
config.ingest.chunking.target_tokens = 80;
config.ingest.chunking.overlap_tokens = 20;
Self {
temp,
workspace_root,
config,
}
}
pub fn scope(&self) -> kebab_core::SourceScope {
kebab_core::SourceScope {
root: self.workspace_root.clone(),
exclude: self.config.workspace.exclude.clone(),
..Default::default()
}
}
/// p9-fb-34 alias — tests added in fb-34 invoke `TestEnv::new()`
/// per the plan; route to the existing lexical-only constructor
/// so the lane stays AVX-free without churning all the existing
/// callers.
pub fn new() -> Self {
Self::lexical_only()
}
/// p9-fb-34: open a fresh `App` against this env's config. Used
/// by integration tests that need to call `App::search_with_opts`
/// directly. Caller can invoke this multiple times to simulate
/// re-opening the binary after a corpus revision bump.
pub fn app(&self) -> kebab_app::App {
kebab_app::App::open_with_config(self.config.clone()).expect("App::open_with_config")
}
}
/// p9-fb-34: write `content` into the env's workspace at
/// `relative_path`, then run a full ingest so the document is
/// searchable. Mirrors the convenience helpers used by other
/// `TestEnv`-driven crates.
pub fn ingest_md(env: &TestEnv, relative_path: &str, content: &str) {
let path = env.workspace_root.join(relative_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent dirs");
}
std::fs::write(&path, content).expect("write workspace file");
kebab_app::ingest_with_config(
env.config.clone(),
env.scope(),
kebab_app::IngestOpts {
summary_only: true,
..Default::default()
},
)
.expect("ingest_with_config");
}
/// Test helper: build a `SearchQuery` for lexical mode at k=10. Used
/// by every kebab-app integration test that calls
/// `kebab_app::search_with_config`. Centralized here so a future
/// `SearchQuery` field bump only edits one site.
pub fn lexical_query(text: &str) -> kebab_core::SearchQuery {
kebab_core::SearchQuery {
text: text.to_string(),
mode: kebab_core::SearchMode::Lexical,
k: 10,
filters: kebab_core::SearchFilters::default(),
}
}
/// p9-fb-32: rewrite `documents.updated_at` for one workspace path
/// to `now - days_ago` (RFC3339 UTC). Used by staleness integration
/// tests to simulate aged-out docs without faking system time. Caller
/// is responsible for ingesting the doc *before* calling this — the
/// row must already exist.
pub fn backdate_document_updated_at(env: &TestEnv, workspace_path: &str, days_ago: i64) {
let backdated = (time::OffsetDateTime::now_utc() - time::Duration::days(days_ago))
.format(&time::format_description::well_known::Rfc3339)
.expect("format backdated updated_at");
let db_path = PathBuf::from(&env.config.storage.data_dir).join("kebab.sqlite");
let conn = rusqlite::Connection::open(&db_path).expect("open kebab.sqlite");
let updated = conn
.execute(
"UPDATE documents SET updated_at = ?1 WHERE workspace_path = ?2",
rusqlite::params![backdated, workspace_path],
)
.expect("UPDATE documents.updated_at");
assert_eq!(
updated, 1,
"backdate_document_updated_at: expected to update exactly 1 row for {workspace_path}, got {updated}"
);
}
fn copy_fixture_workspace(dest: &Path) {
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("workspace");
copy_dir_recursive(&src, dest);
}
fn copy_dir_recursive(src: &Path, dest: &Path) {
std::fs::create_dir_all(dest).unwrap();
for entry in std::fs::read_dir(src).expect("read fixture dir") {
let entry = entry.unwrap();
let path = entry.path();
let target = dest.join(entry.file_name());
if path.is_dir() {
copy_dir_recursive(&path, &target);
} else {
std::fs::copy(&path, &target).expect("copy fixture file");
}
}
}
pub mod mock_ocr;