Files
kebab/crates/kebab-tui/tests/library.rs
altair823 685007789a style: cargo fmt --all (round 4 ingest log feature follow-up)
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>
2026-05-28 04:18:40 +00:00

378 lines
13 KiB
Rust

//! Unit + snapshot tests for the Library pane.
//!
//! Snapshot tests use `ratatui::backend::TestBackend` so the run loop
//! is bypassed entirely — we drive `render_library` directly against
//! a synthetic `App`.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_config::Config;
use kebab_core::{
ChunkerVersion, DocSummary, DocumentId, Lang, ParserVersion, SourceType, TrustLevel,
WorkspacePath,
};
use kebab_tui::{App, KeyOutcome, Pane, render_library};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use time::OffsetDateTime;
fn make_doc(path: &str, title: &str, tags: Vec<&str>) -> DocSummary {
DocSummary {
doc_id: DocumentId(format!(
"{:0<32}",
path.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>()
)),
doc_path: WorkspacePath::new(path.into()).unwrap(),
title: title.into(),
lang: Lang("en".into()),
tags: tags.into_iter().map(String::from).collect(),
trust_level: TrustLevel::Primary,
source_type: SourceType::Note,
byte_len: 1024,
chunk_count: 4,
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
parser_version: ParserVersion("test-parser".into()),
chunker_version: ChunkerVersion("test-chunker".into()),
}
}
fn app_with_docs(docs: Vec<DocSummary>) -> App {
let mut config = Config::defaults();
// Storage paths point at /tmp so any accidental facade call
// would not touch the user's real KB. Tests below use the
// `populate_library_for_testing` test seam, never the facade.
config.storage.data_dir = "/tmp/kebab-tui-tests-noop".to_string();
let mut app = App::new(config).expect("App::new must succeed with defaults");
app.populate_library_for_testing(docs);
app
}
#[test]
fn empty_library_renders_block_only_no_panic() {
let app = app_with_docs(vec![]);
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_library(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("Library"),
"rendered frame must show Library header: {rendered}"
);
assert!(
rendered.contains("no docs") || rendered.contains("Library"),
"empty state hint should appear in the header line"
);
}
#[test]
fn handle_key_library_q_quits() {
let mut app = app_with_docs(vec![]);
let outcome = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::Quit);
}
#[test]
fn handle_key_library_esc_quits_when_no_overlay() {
let mut app = app_with_docs(vec![]);
let outcome =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::Quit);
}
#[test]
fn handle_key_library_slash_switches_to_search() {
let mut app = app_with_docs(vec![]);
let outcome = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Search));
}
#[test]
fn handle_key_library_question_switches_to_ask() {
let mut app = app_with_docs(vec![]);
let outcome = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Ask));
}
#[test]
fn handle_key_library_enter_does_not_switch_when_empty() {
let mut app = app_with_docs(vec![]);
let outcome =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::Continue);
}
#[test]
fn library_with_docs_renders_titles() {
let app = app_with_docs(vec![
make_doc("notes/foo.md", "Foo", vec!["alpha"]),
make_doc("notes/bar.md", "Bar", vec!["beta", "gamma"]),
make_doc("notes/baz.md", "Baz Title", vec![]),
]);
let backend = TestBackend::new(80, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 10);
render_library(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");
for title in &["Foo", "Bar", "Baz Title"] {
assert!(
rendered.contains(title),
"rendered must contain {title}, got:\n{rendered}"
);
}
}
#[test]
fn handle_key_library_arrow_down_moves_selection() {
let mut app = app_with_docs(vec![
make_doc("a.md", "A", vec![]),
make_doc("b.md", "B", vec![]),
make_doc("c.md", "C", vec![]),
]);
let outcome = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::Continue);
let outcome2 = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(outcome2, KeyOutcome::Continue);
// Third j hits the bottom; clamp must not panic / overflow.
let outcome3 = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(outcome3, KeyOutcome::Continue);
}
#[test]
fn handle_key_library_enter_inspects_when_docs_present() {
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
let outcome =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Inspect));
}
#[test]
fn handle_key_library_f_opens_filter_overlay_then_enter_refreshes() {
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
// Open filter.
let o1 = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
);
assert_eq!(o1, KeyOutcome::Continue);
// Type into tags buffer.
for ch in "foo".chars() {
kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
// Enter commits + refreshes.
let o2 =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(o2, KeyOutcome::Refresh);
}
/// p9-fb-10: filter overlay accepts Hangul tags via key events
/// and commits them to the doc filter.
#[test]
fn filter_overlay_accepts_hangul_tags() {
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
// Open filter overlay.
let o1 = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
);
assert_eq!(o1, KeyOutcome::Continue);
// Type Hangul into the tags buffer.
for ch in "한글".chars() {
kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
// Enter commits.
let o2 =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(o2, KeyOutcome::Refresh);
// The library filter should now contain "한글" as a tag.
let filter = app.library_filter_for_testing();
assert!(
filter.tags_any.iter().any(|t| t == "한글"),
"expected '한글' in tags filter: {:?}",
filter.tags_any,
);
}
/// p9-fb-10: filter overlay calls f.set_cursor_position so ratatui
/// shows the caret on the focused field. Pin: after opening the
/// overlay, render → terminal cursor is set + has non-zero x
/// (the label offset > 0).
#[test]
fn filter_overlay_render_places_cursor_on_focused_field() {
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
// Open filter.
let _ = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
);
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_library(f, area, &app);
})
.expect("render must not panic");
// After draw, ratatui calls backend.set_cursor_position when the
// frame's cursor_position is Some. The terminal's
// get_cursor_position proxies to the backend.
let pos = terminal
.get_cursor_position()
.expect("filter overlay must call set_cursor_position, so cursor pos must be readable");
// The Tags label ("tags_any (csv): ") has display_width 16; inner.x
// is 1 (inside border). With empty input cursor_col=0, expected x=17.
// We assert x>0 to avoid hardcoding the exact layout geometry while
// still confirming set_cursor_position was called with a meaningful
// offset (not stuck at origin).
assert!(
pos.x > 0,
"cursor x should be positive (label offset > 0): {pos:?}"
);
}
/// p9-fb-24: rendered Library pane shows the column header row above
/// the data rows. Header is in `Role::Heading` style; data rows in
/// the `Role::Body` / `Role::Selected` defaults.
#[test]
fn library_renders_column_header_row() {
let docs = vec![
make_doc("notes/alpha.md", "doc-alpha", vec!["rust"]),
make_doc("notes/beta.md", "doc-beta", vec!["docs"]),
make_doc("notes/gamma.md", "doc-gamma", vec![]),
];
let app = app_with_docs(docs);
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_library(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("TITLE")
&& rendered.contains("TAGS")
&& rendered.contains("UPDATED")
&& rendered.contains("CHUNKS"),
"header row labels not visible in:\n{rendered}"
);
let title_line_idx = rendered
.lines()
.position(|line| line.contains("TITLE"))
.expect("TITLE header should be present");
let lines_after = rendered
.lines()
.skip(title_line_idx + 1)
.collect::<Vec<_>>();
assert!(
lines_after.iter().any(|line| line.contains("doc-")),
"no data rows after header:\n{rendered}"
);
}
/// p9-fb-10: Library renders Hangul / CJK titles without overflowing
/// the title column. Smoke pin — render with a mixed Korean fixture
/// and confirm no panic + the truncated width fits the column.
#[test]
fn library_renders_korean_titles_without_overflow() {
let docs = vec![
make_doc(
"ko/한글-노트.md",
"러스트로 만드는 지식 베이스",
vec!["rust", "한글"],
),
make_doc("jp/漢字メモ.md", "日本語のテストドキュメント", vec!["jp"]),
make_doc("mix/hello-세계.md", "Hello, 세계 mixed title", vec!["mix"]),
];
let app = app_with_docs(docs);
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_library(f, area, &app);
})
.expect("render must not panic on CJK titles");
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");
// At least one Hangul / Kanji glyph survives the render path.
// TestBackend renders wide chars one-per-cell with the trailing
// cell empty, so the joined string has spaces between adjacent
// wide chars — assert single glyphs, not multi-char substrings.
assert!(
rendered.contains('러') || rendered.contains('한'),
"expected a Hangul glyph in rendered frame: {rendered}"
);
assert!(
rendered.contains('日') || rendered.contains('漢'),
"expected a Kanji glyph in rendered frame: {rendered}"
);
}