Surface-only PR (no behavior wiring — that's PR-9c-2):
- kebab-core: RefusalReason::NliVerificationFailed + NliModelUnavailable (serde rename_all="snake_case", wire = identical strings).
- kebab-core: Answer.verification: Option<VerificationSummary> field (additive minor wire — pre-v0.18 reader 무영향).
- kebab-core: VerificationSummary { nli_score: f32, nli_threshold: f32, nli_passed: bool } struct + lib.rs 재-export.
- kebab-config: NliCfg { model, provider } + ModelsCfg.nli (default Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7).
- kebab-config: RagCfg.nli_threshold: f32 (default 0.0 = disabled, spec §2.6 single gate).
- kebab-config: env override KEBAB_MODELS_NLI_MODEL/PROVIDER + KEBAB_RAG_NLI_THRESHOLD (parse 실패 시 tracing::warn + default 유지).
- kebab-rag: RagPipeline.verifier: Option<Arc<dyn NliVerifier>> field + with_verifier builder (모두 #[allow(dead_code)] — PR-9c-2 의 step 8.5 hook 가 활성화 시 제거). RagPipeline::new signature 유지 (round-2 NEW-M1 Option B).
- kebab-rag: Cargo.toml 에 kebab-nli path 의존 추가.
- kebab-store-sqlite + kebab-tui: 두 신규 RefusalReason variant 에 대한 exhaustive match arm 추가 (snake_case label / 표시 문구).
- 모든 Answer 구축 site (rag 6 + cli/tui/eval 3 fixture) 에 verification: None 추가.
- wire schemas: answer.schema.json verification field + \$defs.VerificationSummary + refusal_reason.enum 2 추가. error.schema.json code.enum + details.description 2 추가 (forward-looking reserved).
- docs/ARCHITECTURE.md: Mermaid Adapters subgraph 의 nli 노드 + rag→nli + app→nli (forward-looking) + nli→config edges. nli→core edge 는 skip (kebab-nli/Cargo.toml direct dep 가 config 만, ARCHITECTURE 컨벤션 = direct deps only). 디렉토리 트리에 crates/kebab-nli/ 추가.
Tests: kebab-core 3 (serde rename + verification skip + struct shape) + kebab-config 6 (defaults + legacy + env + malformed env) + kebab-cli wire 5 (schema verification + enum 검증).
검증: cargo test --workspace -j 1 회귀 0 (pre-existing kebab-mcp::tools_call_ask_multi_hop flaky 1개 동일 — spec 에 명시된 known-flaky). cargo clippy --workspace --all-targets -D warnings clean.
Wire 영향: additive minor — answer.v1 의 verification optional + refusal_reason.enum 확장 + error.v1.code 확장.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1190 lines
42 KiB
Rust
1190 lines
42 KiB
Rust
//! Unit + snapshot tests for the Ask pane (P9-3).
|
|
//!
|
|
//! Worker thread / streaming path is NOT exercised here — that would
|
|
//! require a real Ollama + SQLite KB. Tests drive the pane via
|
|
//! hand-populated `AskState`.
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use kebab_config::Config;
|
|
use kebab_core::{
|
|
Answer, AnswerCitation, AnswerRetrievalSummary, Citation, ModelRef,
|
|
PromptTemplateVersion, RefusalReason, SearchMode, TokenUsage, TraceId, Turn,
|
|
WorkspacePath,
|
|
};
|
|
use kebab_tui::{App, AskState, KeyOutcome, Pane, handle_key_ask, render_ask};
|
|
use ratatui::Terminal;
|
|
use ratatui::backend::TestBackend;
|
|
use ratatui::layout::Rect;
|
|
use time::OffsetDateTime;
|
|
|
|
fn fresh_app() -> App {
|
|
let mut config = Config::defaults();
|
|
config.storage.data_dir = "/tmp/kebab-tui-ask-tests-noop".to_string();
|
|
config.workspace.root = "/tmp/kebab-tui-ask-tests-noop/workspace".to_string();
|
|
let mut app = App::new(config).expect("App::new");
|
|
app.focus = Pane::Ask;
|
|
// p9-fb-12 follow-up: mirror the run loop's auto-flip on pane
|
|
// switch — Search/Ask auto-Insert. Tests that want Normal-mode
|
|
// navigation behaviour set `app.mode = Mode::Normal` explicitly.
|
|
app.mode = kebab_tui::Mode::auto_for(Pane::Ask);
|
|
app.ask = Some(AskState::default());
|
|
app
|
|
}
|
|
|
|
fn make_answer(grounded: bool, refusal: Option<RefusalReason>, body: &str) -> Answer {
|
|
Answer {
|
|
answer: body.to_string(),
|
|
citations: vec![AnswerCitation {
|
|
marker: Some("1".to_string()),
|
|
citation: Citation::Line {
|
|
path: WorkspacePath::new("notes/foo.md".into()).unwrap(),
|
|
start: 12,
|
|
end: 14,
|
|
section: Some("Section A".into()),
|
|
},
|
|
// fb-32: TUI ask test fixture pinned to UNIX_EPOCH + stale=false;
|
|
// staleness rendering covered in dedicated tests (Task 11).
|
|
indexed_at: OffsetDateTime::UNIX_EPOCH,
|
|
stale: false,
|
|
}],
|
|
grounded,
|
|
refusal_reason: refusal,
|
|
model: ModelRef {
|
|
id: "qwen2.5:7b-instruct".into(),
|
|
provider: "ollama".into(),
|
|
dimensions: None,
|
|
},
|
|
embedding: Some(ModelRef {
|
|
id: "multilingual-e5-small".into(),
|
|
provider: "fastembed".into(),
|
|
dimensions: Some(384),
|
|
}),
|
|
prompt_template_version: PromptTemplateVersion("rag-v2".into()),
|
|
retrieval: AnswerRetrievalSummary {
|
|
trace_id: TraceId("test-trace".into()),
|
|
mode: SearchMode::Hybrid,
|
|
k: 10,
|
|
score_gate: 0.05,
|
|
top_score: 0.8,
|
|
chunks_returned: 7,
|
|
chunks_used: 3,
|
|
},
|
|
usage: TokenUsage {
|
|
prompt_tokens: 100,
|
|
completion_tokens: 50,
|
|
latency_ms: 1200,
|
|
},
|
|
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
|
|
conversation_id: None,
|
|
turn_index: None,
|
|
hops: None,
|
|
verification: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn esc_returns_to_library_and_clears_streaming() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
s.streaming = true;
|
|
s.partial = "partial answer…".into();
|
|
}
|
|
let outcome = handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
|
|
let s = app.ask.as_ref().unwrap();
|
|
assert!(!s.streaming);
|
|
assert!(s.rx.is_none());
|
|
assert!(s.thread.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn typing_appends_to_input() {
|
|
let mut app = fresh_app();
|
|
for ch in "hello".chars() {
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
|
|
);
|
|
}
|
|
assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "hello");
|
|
}
|
|
|
|
#[test]
|
|
fn backspace_pops_input() {
|
|
let mut app = fresh_app();
|
|
{
|
|
app.ask.as_mut().unwrap().input.push_str("abcd");
|
|
}
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "abc");
|
|
}
|
|
|
|
/// p9-fb-12 follow-up: `e` types into input in Insert mode (does
|
|
/// NOT toggle explain). Replaces the pre-fb-12 heuristic
|
|
/// "input.is_empty() then toggle else type" with mode-authoritative
|
|
/// dispatch.
|
|
#[test]
|
|
fn e_types_in_insert_mode_does_not_toggle_explain() {
|
|
let mut app = fresh_app();
|
|
// Insert auto for Ask, but explicit for clarity.
|
|
app.mode = kebab_tui::Mode::Insert;
|
|
assert!(!app.ask.as_ref().unwrap().explain);
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
|
|
);
|
|
let s = app.ask.as_ref().unwrap();
|
|
assert_eq!(s.input.as_str(), "e", "e must type in Insert mode");
|
|
assert!(!s.explain, "explain must NOT toggle in Insert mode");
|
|
}
|
|
|
|
/// p9-fb-12 follow-up: `j` / `k` are scroll commands in Normal mode.
|
|
/// In Insert they type. Replaces input-empty heuristic.
|
|
#[test]
|
|
fn jk_scroll_in_normal_mode_type_in_insert() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Normal;
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.ask.as_ref().unwrap().scroll, 1, "j scrolls down in Normal");
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.ask.as_ref().unwrap().scroll, 0, "k scrolls up in Normal");
|
|
// Now Insert — j/k type.
|
|
app.mode = kebab_tui::Mode::Insert;
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
|
);
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "jk");
|
|
assert_eq!(app.ask.as_ref().unwrap().scroll, 0, "no scroll in Insert");
|
|
}
|
|
|
|
/// p9-fb-12 follow-up: `e` toggles explain in Normal mode (was
|
|
/// previously gated on `input.is_empty()` heuristic). Test forces
|
|
/// Normal explicitly to mirror the run-loop flow (user pressed Esc).
|
|
#[test]
|
|
fn e_toggles_explain_in_normal_mode() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Normal;
|
|
assert!(!app.ask.as_ref().unwrap().explain);
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
|
|
);
|
|
assert!(app.ask.as_ref().unwrap().explain);
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
|
|
);
|
|
assert!(!app.ask.as_ref().unwrap().explain);
|
|
}
|
|
|
|
#[test]
|
|
fn e_typed_into_input_when_input_nonempty() {
|
|
let mut app = fresh_app();
|
|
{
|
|
app.ask.as_mut().unwrap().input.push_str("qu");
|
|
}
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
|
|
);
|
|
let s = app.ask.as_ref().unwrap();
|
|
assert_eq!(s.input.as_str(), "que");
|
|
assert!(!s.explain, "explain must NOT toggle while typing a word");
|
|
}
|
|
|
|
#[test]
|
|
fn enter_with_empty_input_is_continue() {
|
|
let mut app = fresh_app();
|
|
let outcome = handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(outcome, KeyOutcome::Continue);
|
|
assert!(!app.ask.as_ref().unwrap().streaming);
|
|
}
|
|
|
|
#[test]
|
|
fn enter_while_streaming_is_noop() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
s.input.push_str("anything");
|
|
s.streaming = true;
|
|
}
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
|
);
|
|
// streaming flag remains true (no new worker spawned)
|
|
assert!(app.ask.as_ref().unwrap().streaming);
|
|
// No thread spawned because enter was a no-op.
|
|
assert!(app.ask.as_ref().unwrap().thread.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn render_pre_submission_shows_hint() {
|
|
let app = fresh_app();
|
|
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_ask(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("ask"), "input bar visible");
|
|
assert!(
|
|
rendered.contains("type a question") || rendered.contains("Enter"),
|
|
"pre-submission hint visible"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_streaming_shows_partial_with_cursor() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
s.input.push_str("what is RRF fusion?");
|
|
s.streaming = true;
|
|
s.partial = "RRF는 reciprocal rank fusion".into();
|
|
}
|
|
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_ask(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("streaming"), "streaming hint visible");
|
|
assert!(
|
|
rendered.contains("reciprocal rank fusion"),
|
|
"partial body rendered"
|
|
);
|
|
assert!(rendered.contains("▍"), "cursor block rendered mid-stream");
|
|
}
|
|
|
|
#[test]
|
|
fn render_grounded_answer_with_citation() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
s.input.push_str("test");
|
|
let ans = make_answer(true, None, "test answer body [1].");
|
|
// p9-fb-16: transcript renders completed turns; populate one
|
|
// alongside last_answer so the right-panel status + body
|
|
// assertions both have something to find.
|
|
s.turns.push(Turn {
|
|
question: "test question".into(),
|
|
answer: ans.answer.clone(),
|
|
citations: ans.citations.clone(),
|
|
created_at: ans.created_at,
|
|
});
|
|
s.last_answer = Some(ans);
|
|
}
|
|
let backend = TestBackend::new(100, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| {
|
|
let area = Rect::new(0, 0, 100, 24);
|
|
render_ask(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("test answer body"), "answer body rendered");
|
|
assert!(rendered.contains("grounded ✓"), "grounded status visible");
|
|
assert!(rendered.contains("notes/foo.md"), "citation path rendered");
|
|
assert!(rendered.contains("[1]"), "citation marker rendered");
|
|
}
|
|
|
|
#[test]
|
|
fn render_refusal_score_gate_shows_status_without_citation_index_panic() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
let mut ans = make_answer(false, Some(RefusalReason::ScoreGate), "insufficient grounding to answer.");
|
|
ans.citations.clear(); // refusal often has no citations
|
|
s.turns.push(Turn {
|
|
question: "test refusal question".into(),
|
|
answer: ans.answer.clone(),
|
|
citations: ans.citations.clone(),
|
|
created_at: ans.created_at,
|
|
});
|
|
s.last_answer = Some(ans);
|
|
}
|
|
let backend = TestBackend::new(120, 20);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
// Test passes if render does not panic on empty citations.
|
|
terminal
|
|
.draw(|f| {
|
|
let area = Rect::new(0, 0, 120, 20);
|
|
render_ask(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("insufficient grounding"), "refusal body rendered");
|
|
assert!(rendered.contains("grounded ✗"), "ungrounded status visible");
|
|
assert!(rendered.contains("score_gate"), "refusal reason surfaced");
|
|
}
|
|
|
|
/// p9-fb-32: when `AnswerCitation.stale == true`, the Ask pane's
|
|
/// citations panel inserts a Warning-styled `[STALE] ` Span between
|
|
/// the marker and the path URI.
|
|
#[test]
|
|
fn ask_citations_show_stale_badge_for_stale_citation() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
let mut ans = make_answer(true, None, "answer body [1] [2].");
|
|
// Replace fixture's single fresh citation with two — one stale
|
|
// (notes/old.md) and one fresh (notes/new.md) — so the test
|
|
// can assert the badge attaches to one row only.
|
|
ans.citations = vec![
|
|
AnswerCitation {
|
|
marker: Some("1".into()),
|
|
citation: Citation::Line {
|
|
path: WorkspacePath::new("notes/old.md".into()).unwrap(),
|
|
start: 1,
|
|
end: 1,
|
|
section: None,
|
|
},
|
|
indexed_at: OffsetDateTime::UNIX_EPOCH,
|
|
stale: true,
|
|
},
|
|
AnswerCitation {
|
|
marker: Some("2".into()),
|
|
citation: Citation::Line {
|
|
path: WorkspacePath::new("notes/new.md".into()).unwrap(),
|
|
start: 5,
|
|
end: 5,
|
|
section: None,
|
|
},
|
|
indexed_at: OffsetDateTime::UNIX_EPOCH,
|
|
stale: false,
|
|
},
|
|
];
|
|
s.turns.push(Turn {
|
|
question: "test".into(),
|
|
answer: ans.answer.clone(),
|
|
citations: ans.citations.clone(),
|
|
created_at: ans.created_at,
|
|
});
|
|
s.last_answer = Some(ans);
|
|
}
|
|
let backend = TestBackend::new(120, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| {
|
|
let area = Rect::new(0, 0, 120, 24);
|
|
render_ask(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 somewhere on the citations panel: {rendered}"
|
|
);
|
|
let stale_line = rendered
|
|
.lines()
|
|
.find(|l| l.contains("notes/old.md"))
|
|
.expect("stale citation row must render");
|
|
assert!(
|
|
stale_line.contains("[STALE]"),
|
|
"stale citation row must carry [STALE] badge: {stale_line}"
|
|
);
|
|
let fresh_line = rendered
|
|
.lines()
|
|
.find(|l| l.contains("notes/new.md"))
|
|
.expect("fresh citation row must render");
|
|
assert!(
|
|
!fresh_line.contains("[STALE]"),
|
|
"fresh citation row must NOT carry [STALE] badge: {fresh_line}"
|
|
);
|
|
// Color side: the `[` of `[STALE]` must be Yellow (Warning role).
|
|
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() == "["
|
|
&& 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 in citations must use Yellow (Warning) fg"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explain_toggle_changes_panel_title() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
s.last_answer = Some(make_answer(true, None, "answer body."));
|
|
s.explain = true;
|
|
}
|
|
let backend = TestBackend::new(100, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| {
|
|
let area = Rect::new(0, 0, 100, 24);
|
|
render_ask(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("explain (per-claim)"),
|
|
"explain mode panel title"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enter_with_detached_prior_thread_is_blocked() {
|
|
// R1 fix: after Esc, the prior worker is detached (thread still
|
|
// running, rx cleared, streaming=false). A new Enter must NOT
|
|
// spawn a second worker against the same Ollama endpoint until
|
|
// the prior thread finishes.
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
s.input.push_str("another question");
|
|
s.streaming = false;
|
|
// Simulate a detached prior worker by hand-installing a
|
|
// never-ending JoinHandle. (We can't easily make a sleeping
|
|
// thread without timing flakiness; an empty-loop shim works.)
|
|
s.thread = Some(std::thread::spawn(|| {
|
|
// Loop until the test drops the JoinHandle's owner via
|
|
// App going out of scope. is_finished() will report
|
|
// false until then.
|
|
loop {
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
}
|
|
}));
|
|
}
|
|
let outcome = handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
|
);
|
|
// Enter is a no-op while a prior thread is attached.
|
|
assert_eq!(outcome, KeyOutcome::Continue);
|
|
let s = app.ask.as_ref().unwrap();
|
|
assert!(!s.streaming, "no second worker spawned");
|
|
// Detach so the never-ending thread can be reaped on test exit.
|
|
let _leaked = app.ask.as_mut().unwrap().thread.take();
|
|
}
|
|
|
|
#[test]
|
|
fn no_ask_state_returns_to_library() {
|
|
let mut config = Config::defaults();
|
|
config.storage.data_dir = "/tmp/kebab-tui-ask-tests-noop".into();
|
|
let mut app = App::new(config).unwrap();
|
|
app.focus = Pane::Ask;
|
|
// ask slot intentionally None
|
|
let outcome = handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
|
|
}
|
|
|
|
// ── p9-fb-16: multi-turn conversation transcript ──────────────────────────
|
|
|
|
#[test]
|
|
fn ctrl_l_clears_conversation_state() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
s.conversation_id = Some("conv_test".into());
|
|
s.turns.push(Turn {
|
|
question: "Q".into(),
|
|
answer: "A".into(),
|
|
citations: Vec::new(),
|
|
created_at: OffsetDateTime::from_unix_timestamp(0).unwrap(),
|
|
});
|
|
s.last_answer = Some(make_answer(true, None, "A"));
|
|
s.partial = "leftover".into();
|
|
s.current_question = Some("in flight".into());
|
|
s.scroll = 5;
|
|
s.streaming = true;
|
|
// Note: thread / rx 는 JoinHandle 인 만큼 직접 mock 어려움 —
|
|
// streaming flag 만으로 detach side-effect 검증.
|
|
}
|
|
let outcome = handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL),
|
|
);
|
|
assert_eq!(outcome, KeyOutcome::Continue);
|
|
let s = app.ask.as_ref().unwrap();
|
|
assert!(s.turns.is_empty(), "turns cleared");
|
|
assert!(s.conversation_id.is_none(), "conversation_id cleared");
|
|
assert!(s.last_answer.is_none(), "last_answer cleared");
|
|
assert!(s.partial.is_empty(), "partial cleared");
|
|
assert!(s.current_question.is_none(), "current_question cleared");
|
|
assert_eq!(s.scroll, 0, "scroll reset");
|
|
// 회차 1 fix: streaming flag + thread/rx 도 detach.
|
|
assert!(!s.streaming, "streaming flag cleared");
|
|
assert!(s.thread.is_none(), "thread detached");
|
|
assert!(s.rx.is_none(), "rx detached");
|
|
}
|
|
|
|
#[test]
|
|
fn render_refusal_turn_in_transcript_uses_yellow_when_last_answer_ungrounded() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
let mut ans = make_answer(false, Some(RefusalReason::ScoreGate), "REFUSED BODY");
|
|
ans.citations.clear();
|
|
s.turns.push(Turn {
|
|
question: "Q".into(),
|
|
answer: ans.answer.clone(),
|
|
citations: Vec::new(),
|
|
created_at: ans.created_at,
|
|
});
|
|
s.last_answer = Some(ans);
|
|
}
|
|
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_ask(f, area, &app);
|
|
})
|
|
.unwrap();
|
|
// Find the cell containing the first character of REFUSED BODY
|
|
// and assert its fg is Yellow (the refusal-style override).
|
|
let buffer = terminal.backend().buffer().clone();
|
|
let mut found = None;
|
|
for y in 0..buffer.area.height {
|
|
for x in 0..buffer.area.width {
|
|
let cell = &buffer[(x, y)];
|
|
if cell.symbol() == "R" {
|
|
// First R after Q: line — likely the answer body.
|
|
// Check fg color.
|
|
if let ratatui::style::Color::Yellow = cell.fg {
|
|
found = Some((x, y));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if found.is_some() {
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
found.is_some(),
|
|
"expected at least one yellow R cell from REFUSED BODY in the transcript"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_transcript_shows_completed_turns_in_order() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
let ts = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
|
|
s.turns.push(Turn {
|
|
question: "first question".into(),
|
|
answer: "first answer".into(),
|
|
citations: Vec::new(),
|
|
created_at: ts,
|
|
});
|
|
s.turns.push(Turn {
|
|
question: "second question".into(),
|
|
answer: "second answer".into(),
|
|
citations: Vec::new(),
|
|
created_at: ts,
|
|
});
|
|
}
|
|
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_ask(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("Q1"), "Q1 marker rendered");
|
|
assert!(rendered.contains("Q2"), "Q2 marker rendered");
|
|
let q1_pos = rendered.find("Q1").unwrap();
|
|
let q2_pos = rendered.find("Q2").unwrap();
|
|
assert!(q1_pos < q2_pos, "chronological order: Q1 before Q2");
|
|
assert!(rendered.contains("first question"), "first question text");
|
|
assert!(rendered.contains("second answer"), "second answer text");
|
|
assert!(rendered.contains("transcript (2 turns)"), "title shows count");
|
|
}
|
|
|
|
#[test]
|
|
fn render_streaming_inflight_turn_appears_below_completed_turns() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
s.turns.push(Turn {
|
|
question: "first".into(),
|
|
answer: "ANSWERED".into(),
|
|
citations: Vec::new(),
|
|
created_at: OffsetDateTime::from_unix_timestamp(0).unwrap(),
|
|
});
|
|
s.streaming = true;
|
|
s.current_question = Some("follow-up".into());
|
|
s.partial = "PARTIAL".into();
|
|
}
|
|
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_ask(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("ANSWERED"), "completed turn body");
|
|
assert!(rendered.contains("PARTIAL"), "in-flight partial body");
|
|
assert!(rendered.contains("▍"), "cursor block on in-flight turn");
|
|
let answered_pos = rendered.find("ANSWERED").unwrap();
|
|
let partial_pos = rendered.find("PARTIAL").unwrap();
|
|
assert!(
|
|
answered_pos < partial_pos,
|
|
"completed turn before in-flight; got: {answered_pos} vs {partial_pos}"
|
|
);
|
|
}
|
|
|
|
/// p9-fb-10: typing Hangul into Ask input advances cursor by 2
|
|
/// per char and round-trips through the buffer correctly.
|
|
#[test]
|
|
fn hangul_typing_in_ask_input_advances_cursor_by_two_per_char() {
|
|
let mut app = fresh_app();
|
|
// Switch to ask + INSERT mode so chars type as input.
|
|
app.focus = Pane::Ask;
|
|
app.mode = kebab_tui::Mode::auto_for(Pane::Ask);
|
|
for ch in "한글".chars() {
|
|
kebab_tui::handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
|
|
);
|
|
}
|
|
assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "한글");
|
|
assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 4);
|
|
kebab_tui::handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "한");
|
|
assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 2);
|
|
}
|
|
|
|
// ── p9-fb-22: cursor mid-string editing in Ask input ──────────────────────
|
|
|
|
/// p9-fb-22 (issue #94): Left arrow rewinds the cursor; subsequent
|
|
/// Char insertion lands at that mid-string position (not at the end).
|
|
#[test]
|
|
fn left_arrow_then_typing_inserts_at_cursor_in_ask() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Insert;
|
|
for ch in "abc".chars() {
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
|
}
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
|
|
let s = app.ask.as_ref().unwrap();
|
|
assert_eq!(s.input.as_str(), "abXc", "X inserts before c, not at end");
|
|
assert_eq!(s.input.cursor_col(), 3, "cursor sits between X and c");
|
|
}
|
|
|
|
/// p9-fb-22 (issue #94): Right arrow at end of input is a no-op
|
|
/// (no overflow, no panic).
|
|
#[test]
|
|
fn right_arrow_at_end_is_noop_in_ask() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Insert;
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
|
|
let s = app.ask.as_ref().unwrap();
|
|
assert_eq!(s.input.cursor_col(), 1);
|
|
}
|
|
|
|
/// p9-fb-22 (issue #94): Home jumps cursor to the start; End to
|
|
/// the end. Available regardless of mode.
|
|
#[test]
|
|
fn home_end_jump_cursor_in_ask() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Insert;
|
|
for ch in "hello".chars() {
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
|
}
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
|
|
assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 0);
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
|
|
assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 5);
|
|
}
|
|
|
|
/// p9-fb-22 (issue #94): Delete key at the cursor removes the next
|
|
/// char without rewinding the cursor.
|
|
#[test]
|
|
fn delete_key_removes_char_at_cursor_in_ask() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Insert;
|
|
for ch in "abc".chars() {
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
|
}
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
|
|
let s = app.ask.as_ref().unwrap();
|
|
assert_eq!(s.input.as_str(), "bc", "Delete removed the leading 'a'");
|
|
assert_eq!(s.input.cursor_col(), 0, "cursor stayed at column 0");
|
|
}
|
|
|
|
/// p9-fb-22 (issue #94): Hangul + Left arrow rewinds by 2 display
|
|
/// columns (one wide char), keeping the byte boundary intact.
|
|
#[test]
|
|
fn hangul_left_arrow_rewinds_by_two_cols_in_ask() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Insert;
|
|
for ch in "한글".chars() {
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
|
}
|
|
assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 4);
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
|
assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 2);
|
|
// Inserting at the new cursor position lands between the two
|
|
// syllables, proving cursor_col is not just a display annotation.
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
|
|
assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "한X글");
|
|
}
|
|
|
|
// ── p9-fb-22: follow-tail auto-scroll on new transcript content ───────────
|
|
|
|
/// p9-fb-22 (issue #95): a freshly constructed AskState defaults to
|
|
/// `follow_tail = true` so the first answer streams into view.
|
|
#[test]
|
|
fn ask_state_default_follow_tail_is_true() {
|
|
let s = AskState::default();
|
|
assert!(s.follow_tail, "follow_tail is on by default");
|
|
}
|
|
|
|
/// p9-fb-22 (issue #95): pressing `k` in Normal disengages follow-
|
|
/// tail so the user can review prior turns without the renderer
|
|
/// snapping back to the bottom on the next streamed token.
|
|
#[test]
|
|
fn k_disengages_follow_tail_in_ask() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Normal;
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
|
|
assert!(!app.ask.as_ref().unwrap().follow_tail);
|
|
}
|
|
|
|
/// p9-fb-22 (issue #95): Shift-G jumps the transcript to the bottom
|
|
/// and re-engages follow-tail so subsequent streaming auto-scrolls
|
|
/// again.
|
|
#[test]
|
|
fn shift_g_re_engages_follow_tail_in_ask() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Normal;
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
s.follow_tail = false;
|
|
s.scroll = 7;
|
|
}
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT));
|
|
let s = app.ask.as_ref().unwrap();
|
|
assert!(s.follow_tail, "Shift-G re-engages follow-tail");
|
|
assert_eq!(s.scroll, 0, "scroll cleared (renderer recomputes)");
|
|
}
|
|
|
|
/// p9-fb-22 (issue #95): Ctrl-L clears the conversation AND resets
|
|
/// follow_tail to true so the next submission auto-scrolls.
|
|
#[test]
|
|
fn ctrl_l_resets_follow_tail_in_ask() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Normal;
|
|
app.ask.as_mut().unwrap().follow_tail = false;
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
|
|
assert!(app.ask.as_ref().unwrap().follow_tail);
|
|
}
|
|
|
|
/// p9-fb-24: PgDn advances Ask scroll by `PAGE_STEP` (= 10) and
|
|
/// disengages follow-tail (matches `j` semantics — manual scroll =
|
|
/// freeze).
|
|
#[test]
|
|
fn page_down_advances_scroll_and_freezes_follow_tail_in_ask() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Normal;
|
|
let outcome = handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(outcome, KeyOutcome::Continue);
|
|
let s = app.ask.as_ref().unwrap();
|
|
assert_eq!(s.scroll, 10, "PgDn shifts scroll by PAGE_STEP");
|
|
assert!(!s.follow_tail, "PgDn freezes follow_tail like j/k");
|
|
}
|
|
|
|
/// p9-fb-24: PgUp rewinds Ask scroll by `PAGE_STEP` (saturating at 0)
|
|
/// and disengages follow-tail.
|
|
#[test]
|
|
fn page_up_rewinds_scroll_saturating_and_freezes_follow_tail_in_ask() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Normal;
|
|
app.ask.as_mut().unwrap().scroll = 25;
|
|
app.ask.as_mut().unwrap().follow_tail = true;
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
|
|
);
|
|
let s = app.ask.as_ref().unwrap();
|
|
assert_eq!(s.scroll, 15);
|
|
assert!(!s.follow_tail);
|
|
app.ask.as_mut().unwrap().scroll = 3;
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.ask.as_ref().unwrap().scroll, 0);
|
|
}
|
|
|
|
/// p9-fb-24: PgUp / PgDn fire from BOTH Insert and Normal modes
|
|
/// (physical keys, no typing ambiguity — same as Left/Right/Home/End
|
|
/// from p9-fb-22).
|
|
#[test]
|
|
fn page_keys_fire_from_insert_mode_in_ask() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Insert;
|
|
handle_key_ask(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE),
|
|
);
|
|
assert_eq!(app.ask.as_ref().unwrap().scroll, 10);
|
|
}
|
|
|
|
/// p9-fb-22 (issue #95): when follow_tail is on and the transcript
|
|
/// has many lines, the rendered buffer's last visible line includes
|
|
/// content from the tail of the answer (not the head).
|
|
#[test]
|
|
fn follow_tail_renders_tail_when_transcript_overflows() {
|
|
let mut app = fresh_app();
|
|
{
|
|
let s = app.ask.as_mut().unwrap();
|
|
// Stuff the transcript with 30 turns so the rendered viewport
|
|
// (height 12 → ~9 inner rows after borders + bottom split)
|
|
// can't show them all.
|
|
for i in 0..30 {
|
|
s.turns.push(Turn {
|
|
question: format!("Q{i}"),
|
|
answer: format!("A{i}-body-text"),
|
|
citations: Vec::new(),
|
|
created_at: OffsetDateTime::from_unix_timestamp(0).unwrap(),
|
|
});
|
|
}
|
|
s.follow_tail = true;
|
|
}
|
|
let backend = TestBackend::new(60, 20);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| render_ask(f, Rect::new(0, 0, 60, 20), &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");
|
|
// The very last turn (Q29 / A29) must be visible somewhere in
|
|
// the buffer — without follow-tail, the renderer would pin to
|
|
// the top and only the first few turns would show.
|
|
assert!(
|
|
rendered.contains("A29-body-text"),
|
|
"tail of transcript must be visible when follow_tail is on; got:\n{rendered}"
|
|
);
|
|
}
|
|
|
|
// ── p9-fb-41: multi-hop toggle ───────────────────────────────────────────
|
|
|
|
/// `F2` flips `AskState.multi_hop` from any mode (Normal or Insert)
|
|
/// — it's a physical function key, not a Char, so the mode gating
|
|
/// in handle_key_ask doesn't apply.
|
|
#[test]
|
|
fn f2_toggles_multi_hop_flag_from_insert_mode() {
|
|
let mut app = fresh_app();
|
|
// fresh_app sets Insert mode on the Ask pane (auto-flip).
|
|
assert_eq!(app.mode, kebab_tui::Mode::Insert);
|
|
assert!(!app.ask.as_ref().unwrap().multi_hop, "default off");
|
|
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
|
assert!(
|
|
app.ask.as_ref().unwrap().multi_hop,
|
|
"first F2 turns multi-hop on"
|
|
);
|
|
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
|
assert!(
|
|
!app.ask.as_ref().unwrap().multi_hop,
|
|
"second F2 turns it back off"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn f2_toggles_multi_hop_flag_from_normal_mode() {
|
|
let mut app = fresh_app();
|
|
app.mode = kebab_tui::Mode::Normal;
|
|
assert!(!app.ask.as_ref().unwrap().multi_hop);
|
|
|
|
handle_key_ask(&mut app, KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
|
assert!(
|
|
app.ask.as_ref().unwrap().multi_hop,
|
|
"F2 in Normal mode must also toggle multi-hop"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn input_pane_shows_multi_hop_badge_when_toggled_on() {
|
|
let mut app = fresh_app();
|
|
app.ask.as_mut().unwrap().multi_hop = true;
|
|
|
|
let backend = TestBackend::new(80, 20);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| render_ask(f, Rect::new(0, 0, 80, 20), &app))
|
|
.unwrap();
|
|
let rendered = render_to_string(terminal.backend().buffer());
|
|
assert!(
|
|
rendered.contains("multi-hop"),
|
|
"input pane must surface a multi-hop badge when toggled on; got:\n{rendered}"
|
|
);
|
|
assert!(
|
|
rendered.contains("F2=multi-hop"),
|
|
"ask input title must advertise the F2 binding; got:\n{rendered}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn input_pane_omits_multi_hop_badge_when_toggled_off() {
|
|
let app = fresh_app();
|
|
assert!(!app.ask.as_ref().unwrap().multi_hop);
|
|
|
|
let backend = TestBackend::new(80, 20);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| render_ask(f, Rect::new(0, 0, 80, 20), &app))
|
|
.unwrap();
|
|
let rendered = render_to_string(terminal.backend().buffer());
|
|
// The title still advertises the binding (so users discover the
|
|
// feature) but the *badge* text "multi-hop" must NOT appear next
|
|
// to the prompt — the line is the toggle-state signal.
|
|
//
|
|
// We can't simply assert `!rendered.contains("multi-hop")` because
|
|
// the title itself contains the word. Instead split on the input
|
|
// prompt and confirm the badge segment of the input line is absent.
|
|
// Match the layout: the input pane is the first row, title on the
|
|
// border, prompt + badge on the inner row.
|
|
assert!(
|
|
rendered.contains("F2=multi-hop"),
|
|
"title binding hint must always be visible; got:\n{rendered}"
|
|
);
|
|
let prompt_row = rendered.lines().find(|l| l.contains("?")).unwrap_or("");
|
|
assert!(
|
|
!prompt_row.contains("multi-hop"),
|
|
"the badge belongs on the prompt row only when toggled on; got row:\n{prompt_row}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn status_panel_summarizes_hops_when_answer_has_trace() {
|
|
use kebab_core::{HopKind, HopRecord};
|
|
|
|
let mut app = fresh_app();
|
|
let mut answer = make_answer(true, None, "compound answer [#1]");
|
|
answer.hops = Some(vec![
|
|
HopRecord {
|
|
iter: 0,
|
|
kind: HopKind::Decompose,
|
|
sub_queries: vec!["q1".into(), "q2".into()],
|
|
context_chunks_added: 0,
|
|
forced_stop: false,
|
|
llm_call_ms: 7,
|
|
},
|
|
HopRecord {
|
|
iter: 1,
|
|
kind: HopKind::Decide,
|
|
sub_queries: vec![],
|
|
context_chunks_added: 3,
|
|
forced_stop: false,
|
|
llm_call_ms: 5,
|
|
},
|
|
HopRecord {
|
|
iter: 2,
|
|
kind: HopKind::Synthesize,
|
|
sub_queries: vec![],
|
|
context_chunks_added: 0,
|
|
forced_stop: false,
|
|
llm_call_ms: 11,
|
|
},
|
|
]);
|
|
app.ask.as_mut().unwrap().last_answer = Some(answer);
|
|
|
|
let backend = TestBackend::new(80, 20);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| render_ask(f, Rect::new(0, 0, 80, 20), &app))
|
|
.unwrap();
|
|
let rendered = render_to_string(terminal.backend().buffer());
|
|
assert!(
|
|
rendered.contains("multi-hop: 3 hops"),
|
|
"status panel must surface the hop count; got:\n{rendered}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn status_panel_omits_hops_summary_for_single_pass() {
|
|
let mut app = fresh_app();
|
|
let mut answer = make_answer(true, None, "single-pass answer [#1]");
|
|
answer.hops = None;
|
|
app.ask.as_mut().unwrap().last_answer = Some(answer);
|
|
|
|
let backend = TestBackend::new(80, 20);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| render_ask(f, Rect::new(0, 0, 80, 20), &app))
|
|
.unwrap();
|
|
let rendered = render_to_string(terminal.backend().buffer());
|
|
// The status panel renders 3 lines (grounded / prompt / k/used)
|
|
// for single-pass — no "multi-hop:" line. Only the *title*
|
|
// binding hint ("F2=multi-hop") may contain the substring.
|
|
// Filter that row out, then assert the remaining buffer has no
|
|
// hops summary.
|
|
let body: String = rendered
|
|
.lines()
|
|
.filter(|l| !l.contains("F2=multi-hop"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
!body.contains("multi-hop:"),
|
|
"single-pass answer must NOT render the multi-hop summary line; got:\n{body}"
|
|
);
|
|
}
|
|
|
|
/// Light field-shape pin: the toggle exists, is bool, defaults
|
|
/// to false, and round-trips through the public `AskState` surface.
|
|
/// The actual spawn-time snapshot semantics (toggle value at Enter
|
|
/// is the value the worker sees) are guaranteed by the
|
|
/// `let multi_hop = s.multi_hop;` line at the top of
|
|
/// `spawn_ask_worker` — exercised in live multi-hop dogfood rather
|
|
/// than here (worker thread needs Ollama + a real KB).
|
|
#[test]
|
|
fn ask_state_multi_hop_field_default_false_and_round_trips() {
|
|
let mut app = fresh_app();
|
|
let s = app.ask.as_mut().unwrap();
|
|
assert!(!s.multi_hop, "default false");
|
|
s.multi_hop = true;
|
|
assert!(s.multi_hop, "settable to true");
|
|
s.multi_hop = false;
|
|
assert!(!s.multi_hop, "settable back to false");
|
|
}
|
|
|
|
|
|
/// Small render helper shared with the rest of the test module's
|
|
/// buffer-snapshot pattern. We define it locally here to avoid
|
|
/// reaching into private internals.
|
|
fn render_to_string(buffer: &ratatui::buffer::Buffer) -> String {
|
|
(0..buffer.area.height)
|
|
.map(|y| {
|
|
(0..buffer.area.width)
|
|
.map(|x| buffer[(x, y)].symbol())
|
|
.collect::<String>()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|