Files
kebab/crates/kebab-tui/tests/status_bar.rs
altair823 58ac62d53a feat(search): provenance 출처 필터 — [[workspace.sources]] 멀티소스 + --source/--source-type
혼합 출처 KB(위키+jira 등)에서 색인은 전부 하되 질의 시 출처로 좁히는 provenance
레버. 전역 trust 곱셈가중(weighted-RRF)은 A/B 에서 반증(θ=0.85 만으로 incident MRR
0.918→0.340 절벽, 점수 압축) — 필터가 see-saw 없는 올바른 레버.

- config [[workspace.sources]] (각 id/root/exclude/trust_level/source_type);
  단일 root 는 implicit `default` source 로 정규화. validate: id 유일·비어있지 않음.
- config schema v3→v4 (step_3_to_4, root→[[workspace.sources]] id=default 미러, 멱등)
- V014 documents.source_id 컬럼+인덱스 (additive, DEFAULT 'default', 재색인 0)
- Metadata.source_id + BodyHints trust precedence(frontmatter > source 기본값 > Primary)
- ingest: --root 미지정 시 resolved_sources() 순회 + doc 마다 source_id/trust stamp
- 검색 SearchFilters.source_type/source_id → lexical + vector 두 site (IN, OR)
- CLI kebab search --source <id> / --source-type <type> (repeatable/comma-sep)

도그푸딩(620 doc, jira400+wiki220): --source wiki 로 개념 질의 MRR 0.780→0.810,
--source jira 로 incident 0.918→0.975. trust precedence 실측(jira=secondary 기본값).

version bump 0.28.0 → 0.29.0 (신규 CLI flag + config 키 + V014 migration → minor).
follow-up: MCP search 필터 미노출 · kebab list source_id 미표시 · RAG provenance 라벨.

자세한 내용: tasks/HOTFIXES.md (2026-06-21), docs/release-notes/v0.29.0-draft.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
2026-06-21 08:35:19 +00:00

191 lines
5.6 KiB
Rust

//! p9-fb-24: integration tests for the always-visible status bar.
use kebab_config::Config;
use kebab_tui::{App, Pane};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
fn fresh_app(focus: Pane) -> App {
let mut config = Config::defaults();
config.storage.data_dir = "/tmp/kebab-tui-status-bar-tests-noop".to_string();
config.workspace.root = Some("/tmp/kebab-tui-status-bar-tests-noop/workspace".to_string());
let mut app = App::new(config).expect("App::new");
app.focus = focus;
app
}
fn render_to_string(app: &App, width: u16) -> String {
let backend = TestBackend::new(width, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| kebab_tui::render_status_bar(f, Rect::new(0, 0, width, 1), app))
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn status_bar_shows_kebab_version_first() {
let app = fresh_app(Pane::Library);
let rendered = render_to_string(&app, 100);
let expected = format!("kebab v{}", env!("CARGO_PKG_VERSION"));
assert!(
rendered.contains(&expected),
"version not in status bar: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_pane_label() {
for (focus, expected) in [
(Pane::Library, "Library"),
(Pane::Search, "Search"),
(Pane::Ask, "Ask"),
(Pane::Inspect, "Inspect"),
(Pane::Jobs, "Jobs"),
] {
let app = fresh_app(focus);
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains(expected),
"pane label '{expected}' not visible for focus={focus:?}: rendered=\n{rendered}"
);
}
}
#[test]
fn status_bar_shows_doc_count() {
let app = fresh_app(Pane::Library);
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("0 docs"),
"doc count missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_idle_when_no_dynamic_state() {
let app = fresh_app(Pane::Library);
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("idle"),
"idle marker missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_streaming_when_ask_streaming() {
let mut app = fresh_app(Pane::Ask);
app.ask = Some(kebab_tui::AskState {
streaming: true,
..Default::default()
});
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("streaming…"),
"streaming marker missing: rendered=\n{rendered}"
);
assert!(
!rendered.contains("idle"),
"idle should not appear when streaming: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_searching_when_search_worker_active() {
let mut app = fresh_app(Pane::Search);
app.search = Some(kebab_tui::SearchState {
searching: true,
..Default::default()
});
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("searching…"),
"searching marker missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_ask_conv_id_when_in_ask_with_context() {
let mut app = fresh_app(Pane::Ask);
app.ask = Some(kebab_tui::AskState {
conversation_id: Some("conv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5".to_string()),
current_question: Some("test?".to_string()),
..Default::default()
});
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("conv_a3f9b2c1…"),
"8-hex prefix conv id missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_omits_conv_id_when_ask_has_no_context() {
let mut app = fresh_app(Pane::Ask);
app.ask = Some(kebab_tui::AskState::default());
let rendered = render_to_string(&app, 100);
assert!(
!rendered.contains("conv_"),
"conv id should not appear without context: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_omits_conv_id_outside_ask() {
let mut app = fresh_app(Pane::Library);
app.ask = Some(kebab_tui::AskState {
conversation_id: Some("conv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5".to_string()),
current_question: Some("test?".to_string()),
..Default::default()
});
let rendered = render_to_string(&app, 100);
assert!(
!rendered.contains("conv_"),
"conv id leaked outside Ask pane: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_ingest_progress_in_dynamic_slot() {
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
let mut app = fresh_app(Pane::Library);
let (_tx, rx) = std::sync::mpsc::channel();
app.ingest_state = Some(kebab_tui::IngestState {
rx,
counts: kebab_app::AggregateCounts {
scanned: 40,
..Default::default()
},
current_path: Some("notes/foo.md".to_string()),
current_idx: 12,
started_at: std::time::Instant::now(),
terminal_at: None,
aborted: false,
thread: None,
cancel: Arc::new(AtomicBool::new(false)),
});
let rendered = render_to_string(&app, 200);
assert!(
rendered.contains("12/40"),
"ingest progress fragment missing: rendered=\n{rendered}"
);
assert!(
rendered.contains("30%"),
"ingest percentage missing: rendered=\n{rendered}"
);
assert!(
!rendered.contains("idle"),
"idle should not appear during ingest: rendered=\n{rendered}"
);
}