use super::*;
use crate::app::PickerState;
use crate::git::{DiffContent, DiffLine, FileDiff, FileStatus, Hunk, LineKind};
use crate::test_support::{
added_hunk, added_hunk_app, app_with_file, app_with_files, app_with_hunks,
binary_file as timed_binary_file, diff_line, file_view_state, hunk, install_search, make_file,
numbered_added_lines, prefixed_diff_lines, single_added_app, single_added_file,
single_added_hunk_file, single_hunk_app,
};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn render_buffer(app: &App, w: u16, h: u16) -> ratatui::buffer::Buffer {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, app)).expect("draw");
terminal.backend().buffer().clone()
}
fn render_to_string(app: &App, w: u16, h: u16) -> String {
let buffer = render_buffer(app, w, h);
let mut out = String::new();
for y in 0..buffer.area().height {
out.push_str(&buffer_row_text(&buffer, y));
out.push('\n');
}
out
}
fn render_footer_text(app: &App, w: u16, h: u16) -> String {
let buffer = render_buffer(app, w, h);
buffer_row_text(&buffer, buffer.area().height - 1)
}
fn buffer_row_text(buffer: &ratatui::buffer::Buffer, y: u16) -> String {
let mut out = String::new();
for x in 0..buffer.area().width {
out.push_str(buffer[(x, y)].symbol());
}
out
}
fn first_cell_matching<F>(buffer: &ratatui::buffer::Buffer, f: F) -> Option<(u16, u16)>
where
F: Fn(&ratatui::buffer::Cell) -> bool,
{
for y in 0..buffer.area().height {
for x in 0..buffer.area().width {
if f(&buffer[(x, y)]) {
return Some((x, y));
}
}
}
None
}
fn buffer_has_cell<F>(buffer: &ratatui::buffer::Buffer, f: F) -> bool
where
F: Fn(&ratatui::buffer::Cell) -> bool,
{
first_cell_matching(buffer, f).is_some()
}
#[test]
fn render_empty_state_when_no_files() {
let app = app_with_files(Vec::new());
let view = render_to_string(&app, 70, 6);
assert!(
view.contains("No changes since baseline (baseline: abcdef1)"),
"expected empty state with short SHA, got:\n{view}"
);
assert!(view.contains("[follow]"));
}
#[test]
fn render_diff_line_numbered_inserts_line_number_span_after_bar() {
let line = diff_line(LineKind::Context, "hello");
let gutter = LineNumberGutter::single(2);
let base = render_diff_line(
&line,
false,
false,
40,
None,
None,
Color::Reset,
Color::Reset,
&[],
);
let mut rendered = add_line_number_gutters(
vec![base],
diff_ln_span((Some(10), Some(10)), &gutter),
&gutter,
);
let rendered = rendered.remove(0);
assert!(
rendered.spans.len() >= 3,
"expected at least 3 spans, got {}",
rendered.spans.len()
);
let ln = rendered.spans[1].content.as_ref();
assert!(ln.contains("10"), "line-number gutter text: {ln:?}");
assert!(
ln.starts_with(' ') && ln.ends_with(' '),
"gutter must be padded with 1 leading / 1 trailing space: {ln:?}"
);
assert_eq!(ln.len(), gutter.total_width);
}
#[test]
fn render_diff_line_numbered_added_row_shows_new_side() {
let line = diff_line(LineKind::Added, "x");
let gutter = LineNumberGutter::single(2);
let base = render_diff_line(
&line,
false,
false,
40,
None,
None,
Color::Reset,
Color::Reset,
&[],
);
let mut rendered =
add_line_number_gutters(vec![base], diff_ln_span((None, Some(11)), &gutter), &gutter);
let rendered = rendered.remove(0);
let ln = rendered.spans[1].content.as_ref();
assert!(ln.contains("11"), "Added row must show new number: {ln:?}");
assert_eq!(ln.len(), gutter.total_width);
}
#[test]
fn render_diff_line_numbered_deleted_row_leaves_gutter_blank() {
let line = diff_line(LineKind::Deleted, "y");
let gutter = LineNumberGutter::single(2);
let base = render_diff_line(
&line,
false,
false,
40,
None,
None,
Color::Reset,
Color::Reset,
&[],
);
let mut rendered =
add_line_number_gutters(vec![base], diff_ln_span((Some(12), None), &gutter), &gutter);
let rendered = rendered.remove(0);
let ln = rendered.spans[1].content.as_ref();
assert!(
ln.chars().all(|c| c == ' '),
"Deleted row gutter must be blank: {ln:?}"
);
assert_eq!(ln.len(), gutter.total_width);
}
#[test]
fn render_diff_line_wrapped_numbered_continuation_rows_blank_the_gutter() {
let line = diff_line(LineKind::Context, "aaaaaaaaaa");
let gutter = LineNumberGutter::single(2);
let base = render_diff_line_wrapped(
&line,
false,
Some(0),
4,
None,
None,
Color::Reset,
Color::Reset,
&[],
);
let rendered =
add_line_number_gutters(base, diff_ln_span((Some(10), Some(10)), &gutter), &gutter);
assert!(rendered.len() >= 2, "content must wrap into 2+ rows");
let first_ln = rendered[0].spans[1].content.as_ref();
assert!(
first_ln.contains("10"),
"first row must show 10: {first_ln:?}"
);
let cont_ln = rendered[1].spans[1].content.as_ref();
assert!(
cont_ln.chars().all(|c| c == ' '),
"continuation row must be all spaces: {cont_ln:?}"
);
assert_eq!(cont_ln.len(), gutter.total_width);
}
#[test]
fn render_file_view_line_numbered_shows_single_column() {
let gutter = LineNumberGutter::single(3);
let base = render_file_view_line(
"hello world",
false,
40,
Style::default(),
None,
std::path::Path::new("foo.rs"),
false,
);
let mut rendered = add_line_number_gutters(vec![base], file_ln_span(42, &gutter), &gutter);
let rendered = rendered.remove(0);
assert!(rendered.spans.len() >= 3);
let ln = rendered.spans[1].content.as_ref();
assert!(ln.contains("42"), "file-view gutter: {ln:?}");
assert_eq!(ln.len(), gutter.total_width);
}
#[test]
fn render_file_view_line_wrapped_numbered_blanks_continuation() {
let gutter = LineNumberGutter::single(3);
let base = render_file_view_line_wrapped(
"aaaaaaaaaa",
Some(0),
4,
Style::default(),
None,
std::path::Path::new("foo.rs"),
false,
);
let rendered = add_line_number_gutters(base, file_ln_span(42, &gutter), &gutter);
assert!(rendered.len() >= 2);
let cont_ln = rendered[1].spans[1].content.as_ref();
assert!(
cont_ln.chars().all(|c| c == ' '),
"continuation row must be blank: {cont_ln:?}"
);
}
#[test]
fn sticky_hunk_header_also_reserves_ln_gutter() {
let mut app = single_hunk_app(
"src/foo.rs",
10,
prefixed_diff_lines(LineKind::Context, "line ", 30),
100,
);
app.show_line_numbers = true;
app.build_layout();
app.scroll = 20;
let buffer = render_buffer(&app, 80, 10);
let top_row: String = (0..buffer.area().width)
.map(|x| buffer[(x, 0)].symbol().chars().next().unwrap_or(' '))
.collect();
assert!(
top_row.contains("@@"),
"top row must hold the sticky header: {top_row:?}"
);
let header_x = top_row.find("@@").unwrap();
let mut body_x: Option<usize> = None;
for y in 1..buffer.area().height {
let row: String = (0..buffer.area().width)
.map(|x| buffer[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect();
if let Some(col) = row.find("line ") {
body_x = Some(col);
break;
}
}
let bx = body_x.expect("at least one DiffLine body must be visible");
assert_eq!(
(header_x as isize) - (bx as isize),
2,
"sticky header '@@' must sit 2 cells right of DiffLine body (header_x={header_x}, body_x={bx})"
);
}
#[test]
fn ln_gutter_preserves_hunk_header_vs_diff_body_alignment() {
let make_app = |show_ln: bool| {
let mut app = single_hunk_app(
"src/foo.rs",
10,
vec![
diff_line(LineKind::Context, "fn ok()"),
diff_line(LineKind::Added, "x"),
],
100,
);
app.show_line_numbers = show_ln;
app.build_layout();
app
};
let probe = |app: &App| -> (usize, usize) {
let buffer = render_buffer(app, 80, 12);
(
first_text_run(&buffer, "@@").0 as usize,
first_text_run(&buffer, "fn ok()").0 as usize,
)
};
let (off_h, off_b) = probe(&make_app(false));
let (on_h, on_b) = probe(&make_app(true));
assert_eq!(
(on_h as isize - off_h as isize),
(on_b as isize - off_b as isize),
"gutter toggle must shift hunk header and diff body by the same amount (off: @@={off_h}, fn={off_b}; on: @@={on_h}, fn={on_b})"
);
assert!(on_b > off_b, "LN ON must widen the left gutter");
}
#[test]
fn render_scroll_shows_line_numbers_when_enabled() {
let mut app = single_hunk_app(
"src/foo.rs",
10,
vec![
diff_line(LineKind::Context, "fn ok()"),
diff_line(LineKind::Added, "let x = 1;"),
],
100,
);
app.show_line_numbers = true;
app.build_layout();
let view = render_to_string(&app, 80, 12);
assert!(
view.contains(" 10 "),
"Context/Added row must show the worktree line number:\n{view}"
);
}
#[test]
fn render_scroll_omits_line_numbers_in_stream_mode_even_when_enabled() {
let mut app = added_hunk_app("src/foo.rs", 100, &["x"], 100);
app.show_line_numbers = true;
app.view_mode = crate::app::ViewMode::Stream;
app.build_layout();
let view = render_to_string(&app, 80, 12);
assert!(
!view.contains(" 100 "),
"Stream mode must not render line-number gutter:\n{view}"
);
}
#[test]
fn render_scroll_drops_gutter_when_viewport_is_extremely_narrow() {
let mut app = app_with_file(single_added_hunk_file("src/foo.rs", 10, "xyz", 100));
app.show_line_numbers = true;
app.build_layout();
let view = render_to_string(&app, 12, 4);
assert!(
!view.contains(" 10 "),
"narrow viewport must drop the gutter:\n{view}"
);
}
#[test]
fn line_number_digits_clamps_to_lower_bound_of_two() {
assert_eq!(line_number_digits(0), 2);
assert_eq!(line_number_digits(1), 2);
assert_eq!(line_number_digits(9), 2);
assert_eq!(line_number_digits(10), 2);
assert_eq!(line_number_digits(99), 2);
assert_eq!(line_number_digits(100), 3);
assert_eq!(line_number_digits(9999), 4);
}
#[test]
fn render_scroll_shows_file_header_hunk_header_and_diff_line() {
let app = single_hunk_app(
"src/foo.rs",
10,
vec![
diff_line(LineKind::Context, "fn ok()"),
diff_line(LineKind::Added, "let x = 1;"),
diff_line(LineKind::Deleted, "let y = 2;"),
],
100,
);
let view = render_to_string(&app, 80, 12);
assert!(view.contains("src/foo.rs"), "missing file header:\n{view}");
assert!(
view.contains("@@ L10"),
"missing hunk header line range:\n{view}"
);
assert!(
view.contains("+1/-1"),
"missing hunk header counts:\n{view}"
);
assert!(view.contains("let x = 1;"), "missing added line:\n{view}");
assert!(view.contains("let y = 2;"), "missing deleted line:\n{view}");
}
#[test]
fn render_scroll_lines_use_background_color_for_added_and_deleted() {
let app = single_hunk_app(
"src/foo.rs",
1,
vec![
diff_line(LineKind::Added, "x"),
diff_line(LineKind::Deleted, "y"),
],
100,
);
let buffer = render_buffer(&app, 80, 12);
let found_added_bg = buffer_has_cell(&buffer, |cell| {
cell.symbol() == "x" && cell.style().bg == Some(BG_ADDED)
});
let found_deleted_bg = buffer_has_cell(&buffer, |cell| {
cell.symbol() == "y" && cell.style().bg == Some(BG_DELETED)
});
assert!(
found_added_bg,
"expected an added 'x' cell with green background"
);
assert!(
found_deleted_bg,
"expected a deleted 'y' cell with red background"
);
}
#[test]
fn diff_view_uses_document_highlight_for_tsx() {
let tmp = tempfile::tempdir().expect("tmp");
let rel = "src/Button.tsx";
let abs = tmp.path().join(rel);
std::fs::create_dir_all(abs.parent().unwrap()).expect("mkdir");
let content = "export const button = (\n <Button\n kind=\"primary\"\n onClick={() => save()}\n />\n);\n";
std::fs::write(&abs, content).expect("write tsx");
let mut app = app_with_file(make_file(
rel,
vec![added_hunk(
1,
&[
"export const button = (",
" <Button",
" kind=\"primary\"",
" onClick={() => save()}",
" />",
");",
],
)],
100,
));
app.root = tmp.path().to_path_buf();
app.scroll_to(0);
let buffer = render_buffer(&app, 100, 14);
let (x, y, _) = first_text_run(&buffer, "kind");
assert_eq!(
buffer[(x, y)].style().fg,
Some(Color::Cyan),
"tsx attribute in diff view should use tree-sitter document highlight"
);
}
#[test]
fn diff_view_uses_baseline_document_highlight_for_deleted_tsx() {
let tmp = tempfile::tempdir().expect("tmp");
let root = tmp.path();
std::process::Command::new("git")
.args(["init", "--quiet", "--initial-branch=main"])
.current_dir(root)
.status()
.expect("git init");
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(root)
.status()
.expect("git config email");
std::process::Command::new("git")
.args(["config", "user.name", "kizu test"])
.current_dir(root)
.status()
.expect("git config name");
let rel = "src/Button.tsx";
let abs = root.join(rel);
std::fs::create_dir_all(abs.parent().unwrap()).expect("mkdir");
let before = "export const button = (\n <Button\n kind=\"primary\"\n onClick={() => save()}\n />\n);\n";
let after = "export const button = (\n <Button\n onClick={() => save()}\n />\n);\n";
std::fs::write(&abs, before).expect("write before");
std::process::Command::new("git")
.args(["add", rel])
.current_dir(root)
.status()
.expect("git add");
std::process::Command::new("git")
.args(["commit", "--quiet", "-m", "seed"])
.current_dir(root)
.status()
.expect("git commit");
let baseline = crate::git::head_sha(root).expect("head");
std::fs::write(&abs, after).expect("write after");
let files = crate::git::compute_diff(root, &baseline).expect("diff");
let mut app = app_with_files(files);
app.root = root.to_path_buf();
app.baseline_sha = baseline;
app.scroll_to(0);
let buffer = render_buffer(&app, 100, 14);
let (x, y, _) = first_text_run(&buffer, "kind");
assert_eq!(
buffer[(x, y)].style().fg,
Some(Color::Cyan),
"deleted tsx attribute should use baseline document highlight"
);
}
#[test]
fn diff_view_does_not_document_highlight_non_js_files() {
let tmp = tempfile::tempdir().expect("tmp");
let rel = "src/foo.rs";
let abs = tmp.path().join(rel);
std::fs::create_dir_all(abs.parent().unwrap()).expect("mkdir");
std::fs::write(&abs, "pub fn answer() -> i32 { 42 }\n").expect("write rust");
let mut app = app_with_file(make_file(
rel,
vec![added_hunk(1, &["pub fn answer() -> i32 { 42 }"])],
100,
));
app.root = tmp.path().to_path_buf();
let _buffer = render_buffer(&app, 100, 10);
let highlighter = app.highlighter.get().expect("highlighter initialized");
assert_eq!(
highlighter.document_cache_len(),
0,
"diff view should not build whole-document highlights for non-JS/TS files"
);
}
#[test]
fn file_view_uses_document_highlight_for_tsx() {
let content = "export const button = (\n <Button\n kind=\"primary\"\n onClick={() => save()}\n />\n);\n";
let mut app = app_with_files(Vec::new());
app.file_view = Some(file_view_state(
"src/Button.tsx",
content.lines().map(String::from).collect(),
2,
true,
));
let buffer = render_buffer(&app, 100, 10);
let (x, y, _) = first_text_run(&buffer, "kind");
assert_eq!(
buffer[(x, y)].style().fg,
Some(Color::Cyan),
"tsx attribute in file view should use tree-sitter document highlight"
);
}
#[test]
fn nowrap_added_row_background_extends_to_viewport_edge() {
let app = single_added_app("a.rs", "tiny");
let width: u16 = 40;
let buffer = render_buffer(&app, width, 12);
let y = row_containing(&buffer, "tiny");
for x in 5..width {
let cell = &buffer[(x, y)];
assert_eq!(
cell.style().bg,
Some(BG_ADDED),
"cell (x={x}, y={y}) lost the added background; symbol = {:?}",
cell.symbol()
);
}
}
#[test]
fn render_scroll_lines_omit_plus_minus_prefix() {
let app = single_hunk_app(
"src/foo.rs",
1,
vec![
diff_line(LineKind::Added, "ADDED_LINE"),
diff_line(LineKind::Deleted, "DELETED_LINE"),
],
100,
);
let view = render_to_string(&app, 80, 12);
assert!(
!view.contains("+ADDED_LINE"),
"must not carry a `+` prefix next to added body:\n{view}"
);
assert!(
!view.contains("-DELETED_LINE"),
"must not carry a `-` prefix next to deleted body:\n{view}"
);
}
#[test]
fn wrap_mode_does_not_show_any_marker_when_terminal_newline_present() {
let long_content: String = (0..120u8).map(|i| (b'a' + (i % 26)) as char).collect();
let mut app = single_added_app("a.rs", &long_content);
app.wrap_lines = true;
let view = render_to_string(&app, 80, 14);
assert!(
!view.contains("¶"),
"v0.5 M2: `¶` must no longer appear on normal wrap rows:\n{view}"
);
assert!(
!view.contains("∅"),
"no EOF marker when has_trailing_newline=true:\n{view}"
);
assert!(
view.contains(&long_content[90..110]),
"expected wrapped continuation to be visible:\n{view}"
);
}
#[test]
fn render_diff_line_nowrap_cjk_pads_to_cell_width_not_char_count() {
use unicode_width::UnicodeWidthStr;
let line = diff_line(LineKind::Added, "あいうえお");
let rendered = super::render_diff_line(
&line,
false,
false,
20,
None,
None,
Color::Rgb(10, 50, 10),
Color::Rgb(60, 10, 10),
&[],
);
let body_cells: usize = rendered
.spans
.iter()
.skip(1)
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
assert_eq!(
body_cells, 20,
"nowrap CJK body must pad to body_width in cells, got {body_cells} cells",
);
}
#[test]
fn wrap_at_chars_respects_cjk_display_width() {
let chunks = super::wrap_at_chars("日本語テスト", 4);
assert_eq!(chunks, vec!["日本", "語テ", "スト"]);
}
#[test]
fn wrap_at_chars_handles_mixed_ascii_and_cjk() {
let chunks = super::wrap_at_chars("ab漢字cd", 4);
assert_eq!(chunks, vec!["ab漢", "字cd"]);
}
#[test]
fn wrap_mode_cjk_line_wraps_within_viewport() {
let forty_kanji: String = "あいうえおかきくけこ".repeat(4);
assert_eq!(forty_kanji.chars().count(), 40);
let mut app = single_added_app("a.rs", &forty_kanji);
app.wrap_lines = true;
let view = render_to_string(&app, 45, 10);
assert!(view.contains("あ"), "first kanji must be visible:\n{view}");
let late_kanji: char = forty_kanji.chars().nth(35).unwrap();
assert!(
view.contains(late_kanji),
"late CJK char {late_kanji:?} must survive wrap:\n{view}"
);
}
#[test]
fn wrap_mode_shows_eof_marker_when_no_terminal_newline() {
let long_content: String = (0..40u8).map(|i| (b'a' + (i % 26)) as char).collect();
let mut file = single_added_file("a.rs", &long_content, 100);
let DiffContent::Text(hunks) = &mut file.content else {
panic!("expected text diff");
};
hunks[0].lines[0].has_trailing_newline = false;
let mut app = app_with_file(file);
app.wrap_lines = true;
let view = render_to_string(&app, 40, 10);
assert!(
view.contains("∅"),
"EOF-no-newline must render a `∅` marker in wrap mode:\n{view}"
);
assert!(!view.contains("¶"), "legacy `¶` must be gone:\n{view}");
}
#[test]
fn nowrap_mode_shows_eof_marker_when_no_terminal_newline() {
let mut file = single_added_file("a.rs", "short", 100);
let DiffContent::Text(hunks) = &mut file.content else {
panic!("expected text diff");
};
hunks[0].lines[0].has_trailing_newline = false;
let mut app = app_with_file(file);
app.wrap_lines = false;
let view = render_to_string(&app, 80, 10);
assert!(
view.contains("∅"),
"EOF-no-newline must render a `∅` marker in nowrap mode:\n{view}"
);
}
#[test]
fn nowrap_mode_omits_marker_for_normal_line() {
let mut app = single_added_app("a.rs", "short");
app.wrap_lines = false;
let view = render_to_string(&app, 80, 10);
assert!(
!view.contains("¶") && !view.contains("∅"),
"normal nowrap row must carry no end-of-line marker:\n{view}"
);
}
#[test]
fn line_numbers_hint_appears_in_footer_and_marks_state() {
let mut app = single_added_app("a.rs", "x");
let off_view = render_to_string(&app, 80, 8);
assert!(
off_view.contains("nums off"),
"footer must spell out the disabled LN state:\n{off_view}"
);
app.show_line_numbers = true;
app.build_layout();
let on_view = render_to_string(&app, 80, 8);
assert!(
on_view.contains("nums on"),
"footer must spell out the enabled LN state:\n{on_view}"
);
}
#[test]
fn line_numbers_hint_marks_stream_mode_as_off() {
let mut app = single_added_app("a.rs", "x");
app.show_line_numbers = true;
app.view_mode = crate::app::ViewMode::Stream;
app.build_layout();
let view = render_to_string(&app, 80, 8);
assert!(
view.contains("nums") && view.contains("off"),
"stream footer must flag LN as disabled:\n{view}"
);
}
#[test]
fn wrap_nowrap_indicator_appears_in_footer() {
let mut app = single_added_app("a.rs", "x");
let nowrap_view = render_to_string(&app, 80, 8);
assert!(nowrap_view.contains("nowrap"));
app.wrap_lines = true;
let wrap_view = render_to_string(&app, 80, 8);
assert!(wrap_view.contains("wrap"));
assert!(!wrap_view.contains("nowrap"));
}
#[test]
fn responsive_footer_keeps_state_not_keymap_when_normal_mode_is_narrow() {
let mut app = single_added_app(
"src/extremely/long/path/that/pushes/status/content/out/of/sight/component.rs",
"x",
);
app.follow_mode = false;
app.wrap_lines = true;
app.show_line_numbers = true;
app.head_dirty = true;
app.build_layout();
let footer = render_footer_text(&app, 64, 8);
assert!(footer.contains("[manual]"), "missing mode:\n{footer}");
assert!(
footer.contains("wrap"),
"narrow footer must keep wrap state visible without relying on key labels:\n{footer}"
);
assert!(
footer.contains("nums"),
"narrow footer must keep line-number state visible without relying on key labels:\n{footer}"
);
assert!(
!footer.contains("w wrap"),
"footer should not carry keymap:\n{footer}"
);
assert!(
!footer.contains("# nums"),
"footer should not carry keymap:\n{footer}"
);
assert!(
!footer.contains("picker"),
"narrow footer should drop verbose low-priority labels first:\n{footer}"
);
}
#[test]
fn responsive_footer_keeps_back_hint_when_file_view_path_is_long() {
let mut app = app_with_files(Vec::new());
app.file_view = Some(file_view_state(
"src/extremely/long/path/that/would/otherwise/hide/the/back/hint/demo.rs",
vec!["first".into(), "second".into(), "third".into()],
1,
true,
));
app.wrap_lines = true;
app.show_line_numbers = true;
let footer = render_footer_text(&app, 56, 8);
assert!(
footer.contains("[file") || footer.contains("[file view]"),
"missing file-view mode:\n{footer}"
);
assert!(
footer.contains("wrap"),
"file-view footer must keep wrap state visible:\n{footer}"
);
assert!(
footer.contains("nums"),
"file-view footer must keep line-number state visible:\n{footer}"
);
assert!(
footer.contains("Esc") || footer.contains("back"),
"file-view footer must keep the back hint visible:\n{footer}"
);
}
#[test]
fn help_overlay_uses_configured_key_labels() {
let mut app = single_added_app("src/foo.rs", "x");
app.follow_mode = false;
app.config.keys.cursor_placement = 'Z';
app.config.keys.wrap_toggle = 'W';
app.config.keys.line_numbers_toggle = 'L';
app.config.keys.picker = 'p';
app.help_overlay = true;
let view = render_to_string(&app, 100, 24);
assert!(
view.contains("Z") && view.contains("center"),
"help overlay must show remapped cursor-placement key:\n{view}"
);
assert!(
view.contains("W") && view.contains("wrap"),
"help overlay must show remapped wrap key:\n{view}"
);
assert!(
view.contains("L") && view.contains("line numbers"),
"help overlay must show remapped line-number key:\n{view}"
);
assert!(
view.contains("p") && view.contains("picker"),
"help overlay must show remapped picker key:\n{view}"
);
}
#[test]
fn file_view_marks_eof_no_newline_on_last_line_nowrap() {
let mut app = app_with_files(Vec::new());
app.file_view = Some(file_view_state(
"foo.rs",
vec!["first".into(), "tail-no-newline".into()],
1,
false,
));
app.wrap_lines = false;
let view = render_to_string(&app, 40, 8);
assert!(
view.contains("∅"),
"file view must mark EOF-no-newline on the last line:\n{view}"
);
}
#[test]
fn file_view_omits_marker_when_last_line_has_newline() {
let mut app = app_with_files(Vec::new());
app.file_view = Some(file_view_state(
"foo.rs",
vec!["first".into(), "last".into()],
1,
true,
));
app.wrap_lines = false;
let view = render_to_string(&app, 40, 8);
assert!(
!view.contains("∅") && !view.contains("¶"),
"normal file must carry no EOF marker:\n{view}"
);
}
#[test]
fn file_view_wrap_mode_renders_late_content_and_footer_indicator() {
let long = format!("const DATA: &str = {:?};", "0123456789".repeat(12));
let mut app = app_with_files(Vec::new());
app.file_view = Some(file_view_state("src/demo.rs", vec![long.clone()], 0, true));
app.wrap_lines = true;
let view = render_to_string(&app, 40, 8);
assert!(
view.contains(&long[45..65]),
"wrapped file view must surface a later slice of the long line:\n{view}"
);
assert!(
view.contains("[file view]"),
"file view footer missing:\n{view}"
);
assert!(view.contains("wrap"), "wrap indicator missing:\n{view}");
}
#[test]
fn render_scroll_marks_binary_file_with_notice() {
let app = app_with_file(timed_binary_file("assets/icon.png", 0));
let view = render_to_string(&app, 80, 8);
assert!(view.contains("assets/icon.png"));
assert!(view.contains("[binary file - diff suppressed]"));
assert!(view.contains("bin"));
}
#[test]
fn render_picker_overlays_a_box_with_query_and_filtered_list() {
let mut app = app_with_files(vec![
single_added_file("src/auth.rs", "x", 300),
single_added_file("src/handler.rs", "y", 200),
single_added_file("tests/auth_test.rs", "z", 100),
]);
app.picker = Some(PickerState {
query: "auth".into(),
cursor: 0,
});
let view = render_to_string(&app, 90, 14);
assert!(view.contains("> auth"), "missing query line:\n{view}");
assert!(view.contains("src/auth.rs"), "missing src/auth.rs:\n{view}");
assert!(
view.contains("tests/auth_test.rs"),
"missing tests/auth_test.rs:\n{view}"
);
assert!(view.contains("Files 2/3"), "missing files counter:\n{view}");
assert!(view.contains("[picker]"));
assert!(view.contains("type to filter"));
assert!(view.contains("Esc"));
}
#[test]
fn render_footer_shows_last_error_in_red_when_set() {
let mut app = single_added_app("src/foo.rs", "x");
app.last_error = Some("git diff exploded".into());
let buffer = render_buffer(&app, 140, 6);
let footer_y = buffer.area().height - 1;
let footer_text = buffer_row_text(&buffer, footer_y);
let had_red_x = (0..buffer.area().width).any(|x| {
let cell = &buffer[(x, footer_y)];
cell.symbol() == "×" && cell.style().fg == Some(Color::Red)
});
assert!(
footer_text.contains("git diff exploded"),
"footer:\n{footer_text}"
);
assert!(
had_red_x,
"expected red '×' marker before the error message"
);
}
#[test]
fn render_footer_shows_source_aware_watcher_failures() {
let mut app = single_added_app("src/foo.rs", "x");
app.watcher_health.record_failure(
crate::watcher::WatchSource::GitRefs,
"watcher [git.refs]: refs watcher dead".into(),
);
app.watcher_health.record_failure(
crate::watcher::WatchSource::Worktree,
"watcher [worktree]: worktree watcher dead".into(),
);
let view = render_to_string(&app, 200, 6);
assert!(
view.contains("⚠ WATCHER"),
"missing watcher warning:\n{view}"
);
assert!(
view.contains("watcher [git.refs]: refs watcher dead"),
"missing git watcher message:\n{view}"
);
assert!(
view.contains("watcher [worktree]: worktree"),
"missing worktree watcher message:\n{view}"
);
}
#[test]
fn render_footer_shows_input_health_warning() {
let mut app = single_added_app("src/foo.rs", "x");
app.input_health = Some("input: stream hiccup".into());
let view = render_to_string(&app, 140, 6);
assert!(view.contains("⚠ INPUT"), "missing input warning:\n{view}");
assert!(
view.contains("input: stream hiccup"),
"missing input health message:\n{view}"
);
}
#[test]
fn render_footer_switches_to_manual_when_follow_mode_off() {
let mut app = single_added_app("src/foo.rs", "x");
app.follow_mode = false;
let view = render_to_string(&app, 80, 6);
assert!(view.contains("[manual]"), "expected [manual]:\n{view}");
}
fn hunk_with_context(old_start: usize, ctx: &str, lines: Vec<DiffLine>) -> Hunk {
let mut hunk = hunk(old_start, lines);
hunk.context = Some(ctx.to_string());
hunk
}
fn modified_file_status(name: &str, status: FileStatus, secs: u64) -> FileDiff {
let mut file = make_file(name, vec![added_hunk(1, &["x"])], secs);
file.status = status;
file
}
#[test]
fn file_header_path_color_encodes_status() {
let mut app = app_with_files(vec![
modified_file_status("a.rs", FileStatus::Modified, 100),
modified_file_status("b.rs", FileStatus::Added, 200),
modified_file_status("c.rs", FileStatus::Deleted, 300),
modified_file_status("d.rs", FileStatus::Untracked, 400),
]);
app.scroll_to(0);
let buffer = render_buffer(&app, 80, 30);
let want = [
("a.rs", Color::Cyan),
("b.rs", Color::Green),
("c.rs", Color::Red),
("d.rs", Color::Yellow),
];
for (name, expected) in want {
let (x, y, _) = first_text_run(&buffer, name);
assert_eq!(
buffer[(x, y)].style().fg,
Some(expected),
"{name} should be in {expected:?}"
);
}
}
#[test]
fn file_header_shows_prefix_when_set() {
let mut file = single_added_file("src/auth.rs", "x", 100);
file.header_prefix = Some("14:03:22 Write".to_string());
let mut app = app_with_file(file);
app.scroll = 0;
let buffer = render_buffer(&app, 60, 10);
let first_line = buffer_row_text(&buffer, 0);
assert!(
first_line.contains("14:03:22 Write"),
"header should contain prefix, got: {first_line:?}"
);
}
#[test]
fn hunk_header_uses_function_context_when_available() {
let app = app_with_hunks(
"src/auth.rs",
vec![hunk_with_context(
10,
"fn verify_token(claims: &Claims) -> Result<bool> {",
vec![diff_line(LineKind::Added, "let x = 1;")],
)],
100,
);
let view = render_to_string(&app, 100, 14);
assert!(
view.contains("@@ fn verify_token(claims: &Claims) -> Result<bool> {"),
"expected xfuncname header, got:\n{view}"
);
assert!(
!view.contains("@@ -10,0 +10,1 @@"),
"old hunk-range header leaked through:\n{view}"
);
}
#[test]
fn hunk_header_shows_line_range_and_counts() {
let app = app_with_hunks(
"a.rs",
vec![Hunk {
old_start: 10,
old_count: 2,
new_start: 10,
new_count: 5,
lines: vec![
diff_line(LineKind::Context, "ok"),
diff_line(LineKind::Added, "new1"),
diff_line(LineKind::Added, "new2"),
diff_line(LineKind::Added, "new3"),
diff_line(LineKind::Deleted, "old1"),
],
context: Some("fn example()".to_string()),
}],
100,
);
let view = render_to_string(&app, 100, 14);
assert!(
view.contains("L10"),
"expected line range L10, got:\n{view}"
);
assert!(
view.contains("+3/-1"),
"expected +3/-1 counts, got:\n{view}"
);
}
#[test]
fn hunk_header_pure_deletion_uses_baseline_range_not_l0() {
let app = app_with_hunks(
"a.rs",
vec![Hunk {
old_start: 1,
old_count: 3,
new_start: 0,
new_count: 0,
lines: vec![
diff_line(LineKind::Deleted, "gone1"),
diff_line(LineKind::Deleted, "gone2"),
diff_line(LineKind::Deleted, "gone3"),
],
context: None,
}],
100,
);
let view = render_to_string(&app, 100, 14);
assert!(
!view.contains("L0"),
"pure deletion must not render L0 as the header range:\n{view}"
);
assert!(
view.contains("L1-3") || view.contains("L1"),
"pure deletion header must fall back to baseline range:\n{view}"
);
}
#[test]
fn hunk_header_shows_range_in_fallback_format() {
let app = app_with_hunks(
"a.rs",
vec![Hunk {
old_start: 5,
old_count: 1,
new_start: 5,
new_count: 3,
lines: vec![
diff_line(LineKind::Added, "x"),
diff_line(LineKind::Added, "y"),
diff_line(LineKind::Deleted, "z"),
],
context: None,
}],
100,
);
let view = render_to_string(&app, 100, 14);
assert!(view.contains("L5"), "expected line range L5, got:\n{view}");
assert!(
view.contains("+2/-1"),
"expected +2/-1 counts, got:\n{view}"
);
}
#[test]
fn selected_hunk_is_bright_and_unselected_hunk_is_dim() {
let mut app = app_with_hunks(
"a.rs",
vec![added_hunk(1, &["~~~~"]), added_hunk(20, &["!!!!"])],
100,
);
app.scroll_to(app.layout.hunk_starts[0]);
let buffer = render_buffer(&app, 100, 14);
let mut focused_cells: Vec<(Option<Color>, Modifier)> = Vec::new();
let mut unfocused_cells: Vec<(Option<Color>, Modifier)> = Vec::new();
for y in 0..buffer.area().height {
for x in 5..buffer.area().width {
let cell = &buffer[(x, y)];
let style = cell.style();
if cell.symbol() == "~" {
focused_cells.push((style.bg, style.add_modifier));
}
if cell.symbol() == "!" {
unfocused_cells.push((style.bg, style.add_modifier));
}
}
}
assert!(
!focused_cells.is_empty(),
"focused hunk body '~' never rendered"
);
assert!(
!unfocused_cells.is_empty(),
"unfocused hunk body '!' never rendered"
);
assert!(
focused_cells
.iter()
.all(|(bg, m)| *bg == Some(BG_ADDED) && !m.contains(Modifier::DIM)),
"focused hunk must be BG_ADDED without DIM, got {focused_cells:?}"
);
assert!(
unfocused_cells
.iter()
.all(|(bg, m)| *bg == Some(BG_ADDED) && m.contains(Modifier::DIM)),
"unfocused hunk must be BG_ADDED with DIM, got {unfocused_cells:?}"
);
}
#[test]
fn selected_hunk_displays_yellow_left_bar() {
let mut app = added_hunk_app("src/foo.rs", 1, &["first", "second"], 100);
app.scroll_to(app.layout.hunk_starts[0]);
let buffer = render_buffer(&app, 80, 14);
let had_yellow_bar = buffer_has_cell(&buffer, |cell| {
cell.symbol() == "▎" && cell.style().fg == Some(Color::Yellow)
});
assert!(
had_yellow_bar,
"expected a yellow '▎' on the selected hunk row"
);
}
#[test]
fn cursor_row_displays_arrow_marker_distinct_from_hunk_bar() {
let mut app = added_hunk_app("src/foo.rs", 1, &["first", "second"], 100);
app.scroll_to(app.layout.hunk_starts[0] + 1);
let buffer = render_buffer(&app, 80, 14);
let had_arrow = buffer_has_cell(&buffer, |cell| {
cell.symbol() == "▶" && cell.style().fg == Some(Color::Yellow)
});
let had_plain_bar = buffer_has_cell(&buffer, |cell| {
cell.symbol() == "▎" && cell.style().fg == Some(Color::Yellow)
});
assert!(had_arrow, "expected a yellow '▶' arrow at the cursor row");
assert!(
had_plain_bar,
"expected a yellow '▎' ribbon on the other selected row"
);
}
#[test]
fn hunk_header_cursor_displays_arrow_marker() {
let mut app = single_added_app("src/foo.rs", "first");
app.scroll_to(app.layout.hunk_starts[0]);
let view = render_to_string(&app, 80, 10);
assert!(
view.contains("▶"),
"cursor parked on a hunk header must still be visible:\n{view}"
);
}
#[test]
fn hunk_header_cursor_arrow_is_yellow_and_bold() {
let mut app = single_added_app("src/foo.rs", "first");
app.scroll_to(app.layout.hunk_starts[0]);
let buffer = render_buffer(&app, 80, 10);
let found = buffer_has_cell(&buffer, |cell| {
let st = cell.style();
cell.symbol() == "▶"
&& st.fg == Some(Color::Yellow)
&& st.add_modifier.contains(Modifier::BOLD)
});
assert!(
found,
"cursor `▶` on a hunk header must be Yellow + Bold, not Cyan"
);
}
#[test]
fn seen_hunk_header_shows_fold_glyph() {
let mut app = single_added_app("src/foo.rs", "first");
app.scroll_to(app.layout.hunk_starts[0] + 1); app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
));
let view = render_to_string(&app, 80, 10);
assert!(
view.contains("▸"),
"seen hunk header must display a ▸ fold glyph:\n{view}"
);
}
#[test]
fn file_header_cursor_displays_arrow_marker() {
let app = single_added_app("src/foo.rs", "first");
let view = render_to_string(&app, 80, 10);
assert!(
view.contains("▶"),
"cursor parked on a file header must still be visible:\n{view}"
);
}
#[test]
fn binary_notice_cursor_displays_arrow_marker() {
let mut app = app_with_file(timed_binary_file("assets/icon.png", 0));
app.scroll_to(1);
let view = render_to_string(&app, 80, 8);
assert!(
view.contains("▶"),
"cursor parked on a binary notice row must still be visible:\n{view}"
);
}
#[test]
fn centered_cursor_renders_arrow_near_viewport_middle() {
let lines = numbered_added_lines(40);
let mut app = app_with_context_hunk("src/foo.rs", "fn long_function() {", lines, 100);
let header = app.layout.hunk_starts[0];
app.scroll_to(header + 20);
app.anim = None;
let buffer = render_buffer(&app, 80, 12);
let (_, y) = first_cell_matching(&buffer, |cell| {
cell.symbol() == "▶" && cell.style().fg == Some(Color::Yellow)
})
.expect("expected the cursor `▶` to be drawn");
assert!(
(4..=8).contains(&y),
"expected cursor near viewport middle, was at row {y}"
);
}
#[test]
fn top_cursor_renders_arrow_near_viewport_top() {
let lines = numbered_added_lines(40);
let mut app = app_with_context_hunk("src/foo.rs", "fn long_function() {", lines, 100);
let header = app.layout.hunk_starts[0];
app.scroll_to(header + 20);
app.anim = None;
app.cursor_placement = crate::app::CursorPlacement::Top;
let buffer = render_buffer(&app, 80, 12);
let (_, y) = first_cell_matching(&buffer, |cell| {
cell.symbol() == "▶" && cell.style().fg == Some(Color::Yellow)
})
.expect("expected the cursor `▶` to be drawn");
assert_eq!(y, 1, "expected cursor at viewport ceiling, was at row {y}");
}
#[test]
fn sticky_header_decision_agrees_with_final_body_height() {
let lines = numbered_added_lines(20);
let mut app = app_with_context_hunk("src/foo.rs", "fn boundary() {", lines, 100);
let header_row = app.layout.hunk_starts[0];
app.scroll_to(header_row + 5);
app.anim = None;
let buffer = render_buffer(&app, 80, 8);
let row0 = buffer_row_text(&buffer, 0);
assert!(
row0.contains("boundary") || row0.contains("@@"),
"row 0 must show the hunk header (sticky or inline), got:\n{row0}"
);
}
#[test]
fn sticky_hunk_header_appears_when_cursor_is_below_it() {
let lines = numbered_added_lines(40);
let mut app = app_with_context_hunk("src/foo.rs", "fn long_function() {", lines, 100);
let header_row = app.layout.hunk_starts[0];
app.scroll_to(header_row + 10);
app.anim = None;
let buffer = render_buffer(&app, 80, 8);
let row0 = buffer_row_text(&buffer, 0);
assert!(
row0.contains("long_function"),
"row 0 should be the pinned hunk header, got:\n{row0}"
);
}
fn app_with_context_hunk(name: &str, ctx: &str, lines: Vec<DiffLine>, secs: u64) -> App {
app_with_hunks(name, vec![hunk_with_context(1, ctx, lines)], secs)
}
fn find_text_runs(buf: &ratatui::buffer::Buffer, needle: &str) -> Vec<(u16, u16, usize)> {
let mut out = Vec::new();
let width = buf.area().width;
let height = buf.area().height;
let body_height = height.saturating_sub(1);
for y in 0..body_height {
let row: String = (0..width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect();
let mut search_from = 0;
while let Some(off) = row[search_from..].find(needle) {
let start_byte = search_from + off;
let mut x = 0u16;
let mut cum_bytes = 0usize;
for xi in 0..width {
let sym = buf[(xi, y)].symbol();
if cum_bytes == start_byte {
x = xi;
break;
}
cum_bytes += sym.len();
}
out.push((x, y, needle.chars().count()));
search_from = start_byte + needle.len();
}
}
out
}
fn first_text_run(buf: &ratatui::buffer::Buffer, needle: &str) -> (u16, u16, usize) {
find_text_runs(buf, needle)
.into_iter()
.next()
.unwrap_or_else(|| panic!("{needle} not found in buffer"))
}
fn row_containing(buf: &ratatui::buffer::Buffer, needle: &str) -> u16 {
first_text_run(buf, needle).1
}
#[test]
fn search_current_match_renders_with_yellow_background() {
let mut app = single_added_app("a.rs", "let foo = 1;");
let match_count = install_search(&mut app, "foo", 0);
assert_eq!(match_count, 1, "test fixture precondition");
let buffer = render_buffer(&app, 80, 6);
let runs = find_text_runs(&buffer, "foo");
assert_eq!(runs.len(), 1, "rendered buffer should contain one `foo`");
let (x, y, len) = runs[0];
for dx in 0..len as u16 {
let cell = &buffer[(x + dx, y)];
let style = cell.style();
assert_eq!(
style.bg,
Some(Color::Yellow),
"current-match cell at ({},{}) must carry Yellow bg, got {:?}",
x + dx,
y,
style,
);
assert_eq!(
style.fg,
Some(Color::Black),
"current-match cell at ({},{}) must carry Black fg, got {:?}",
x + dx,
y,
style,
);
assert!(
style.add_modifier.contains(Modifier::BOLD),
"current-match cell at ({},{}) must be bold, got {:?}",
x + dx,
y,
style,
);
}
}
#[test]
fn search_other_matches_render_with_underline_and_preserve_diff_bg() {
let mut app = single_added_app("a.rs", "foo bar foo");
let match_count = install_search(&mut app, "foo", 0);
assert_eq!(match_count, 2, "test fixture precondition");
let buffer = render_buffer(&app, 80, 6);
let runs = find_text_runs(&buffer, "foo");
assert_eq!(runs.len(), 2, "expected two `foo` runs in the buffer");
let (x0, y0, len0) = runs[0];
let first_cell = &buffer[(x0, y0)].style();
assert_eq!(first_cell.bg, Some(Color::Yellow));
let (x1, y1, len1) = runs[1];
for dx in 0..len1 as u16 {
let style = buffer[(x1 + dx, y1)].style();
assert_eq!(
style.bg,
Some(BG_ADDED),
"non-current match cell at ({},{}) must keep bg_added green, got {:?}",
x1 + dx,
y1,
style,
);
assert!(
style.add_modifier.contains(Modifier::UNDERLINED),
"non-current match cell at ({},{}) must be underlined, got {:?}",
x1 + dx,
y1,
style,
);
assert!(
style.add_modifier.contains(Modifier::BOLD),
"non-current match cell at ({},{}) must be bold, got {:?}",
x1 + dx,
y1,
style,
);
}
for xi in (x0 + len0 as u16)..x1 {
let style = buffer[(xi, y0)].style();
assert_eq!(
style.bg,
Some(BG_ADDED),
"inter-match cell at ({},{}) must keep bg_added green, got {:?}",
xi,
y0,
style,
);
assert!(
!style.add_modifier.contains(Modifier::UNDERLINED),
"inter-match cell at ({},{}) must NOT be underlined, got {:?}",
xi,
y0,
style,
);
}
}
#[test]
fn search_current_match_on_cursor_row_retains_yellow_background() {
let mut app = single_added_app("a.rs", "let foo = 1;");
install_search(&mut app, "foo", 0);
let match_row = app.search.as_ref().unwrap().matches[0].row;
app.scroll = match_row;
app.follow_mode = false;
let buffer = render_buffer(&app, 80, 6);
let runs = find_text_runs(&buffer, "foo");
assert_eq!(runs.len(), 1, "rendered buffer should contain one `foo`");
let (x, y, len) = runs[0];
for dx in 0..len as u16 {
let cell = &buffer[(x + dx, y)];
let style = cell.style();
assert_eq!(
style.bg,
Some(Color::Yellow),
"current-match cell at ({},{}) must keep Yellow bg even on the cursor row, got {:?}",
x + dx,
y,
style,
);
assert_eq!(
style.fg,
Some(Color::Black),
"current-match cell at ({},{}) must keep Black fg on the cursor row, got {:?}",
x + dx,
y,
style,
);
}
}
#[test]
fn footer_shows_search_query_and_position() {
let mut app = added_hunk_app("a.rs", 1, &["foo one", "foo two", "foo three"], 100);
let match_count = install_search(&mut app, "foo", 1); assert_eq!(match_count, 3);
app.follow_mode = false;
let view = render_to_string(&app, 120, 8);
assert!(
view.contains("/foo"),
"footer must echo the confirmed search query, got:\n{view}"
);
assert!(
view.contains("[2/3]"),
"footer must show [current/total] position, got:\n{view}"
);
}
#[test]
fn search_other_match_in_unfocused_hunk_is_not_dimmed() {
let mut app = app_with_hunks(
"a.rs",
vec![
added_hunk(1, &["first foo"]),
added_hunk(50, &["second foo"]),
],
100,
);
let match_count = install_search(&mut app, "foo", 0);
assert_eq!(match_count, 2, "test fixture precondition");
let first_row = app.search.as_ref().unwrap().matches[0].row;
app.follow_mode = false;
app.scroll = first_row;
let buffer = render_buffer(&app, 80, 20);
let runs = find_text_runs(&buffer, "foo");
assert_eq!(runs.len(), 2, "both `foo` runs must render in the viewport");
let (x1, y1, len1) = runs[1];
for dx in 0..len1 as u16 {
let style = buffer[(x1 + dx, y1)].style();
assert!(
!style.add_modifier.contains(Modifier::DIM),
"non-current match in unfocused hunk at ({},{}) must NOT carry DIM, got {:?}",
x1 + dx,
y1,
style,
);
assert!(
style.add_modifier.contains(Modifier::UNDERLINED),
"non-current match in unfocused hunk at ({},{}) must be underlined, got {:?}",
x1 + dx,
y1,
style,
);
}
}