Files
kebab/crates/kebab-tui/tests/library.rs
altair823 c3dbe64903 feat(kebab-tui): p9-fb-24 task 5 — Library column header row
Wire `format_doc_header` into `render_doc_list`: render the block
independently, split block_inner into a 1-row header + list via
vertical Layout, and drop the `.block(block)` from the List widget.
Remove `#[allow(dead_code)]` from `format_doc_header` now that it
is consumed. Add `library_renders_column_header_row` integration test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:49:51 +00:00

376 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}"
);
}