use super::support::git_diff::{
added_line, comment_diff_document, context_line, git_diff_document, hunk, modified_file_with_hunks, removed_line,
sample_git_diff_document, wrapping_split_document,
};
use std::path::PathBuf;
use tui::testing::{assert_buffer_eq, cols, key, render_component, render_lines};
use tui::{Component, Event, KeyCode, MIN_GUTTER_WIDTH, SEPARATOR_WIDTH, ViewContext};
use wisp::components::app::{GitDiffLoadState, GitDiffMode};
use wisp::git_diff::GitDiffDocument;
const LEFT_HINT_LINE: &str = "j/k move h/l fold/open enter view u undo r refresh Esc close";
const RIGHT_HINT_LINE: &str = "j/k move h back c comment s submit o full file u undo r refresh Esc close";
fn make_mode(doc: GitDiffDocument) -> GitDiffMode {
let mut mode = GitDiffMode::new(PathBuf::from("."));
mode.load_document(doc);
mode
}
#[test]
fn wrapped_right_pane_rows_keep_a_neutral_boundary() {
let mut mode = make_mode(wrapping_split_document());
let term = render_component(|ctx| mode.render(ctx), 140, 12);
let lines = term.get_lines();
let first_row = lines
.iter()
.position(|line| line.contains("LEFT_MARK") && line.contains("RIGHT_HEAD"))
.expect("expected split row containing both left and right markers");
let right_start_byte = lines[first_row].find("RIGHT_HEAD").expect("expected RIGHT_HEAD marker in first row");
let right_start = lines[first_row][..right_start_byte].chars().count();
let wrapped_idx = lines
.iter()
.enumerate()
.skip(first_row + 1)
.find_map(|(index, line)| line.contains("RIGHT_TAIL").then_some(index))
.expect("expected wrapped continuation row containing RIGHT_TAIL marker");
let ctx = ViewContext::new((140, 12));
let added_bg = Some(ctx.theme.diff_added_bg());
let removed_bg = Some(ctx.theme.diff_removed_bg());
let separator_col = right_start.saturating_sub(SEPARATOR_WIDTH + MIN_GUTTER_WIDTH);
assert!(separator_col > 0, "first row's RIGHT_HEAD should be preceded by separator + gutter columns");
let separator_bg = term.get_style_at(wrapped_idx, separator_col).bg;
assert_ne!(separator_bg, added_bg, "separator column {separator_col} should not inherit added background");
assert_ne!(separator_bg, removed_bg, "separator column {separator_col} should not inherit removed background");
for col in (separator_col + 1)..right_start {
let actual_bg = term.get_style_at(wrapped_idx, col).bg;
assert_eq!(actual_bg, added_bg, "continuation gutter column {col} should carry the added background");
}
}
#[test]
fn wrapped_split_diff_continuation_row_keeps_neutral_padding() {
let mut mode = make_mode(wrapping_split_document());
let ctx = ViewContext::new((140, 12));
let frame = mode.render(&ctx);
let wrapped_row = frame
.lines()
.iter()
.find(|line| line.plain_text().contains("RIGHT_TAIL"))
.cloned()
.expect("expected wrapped continuation row containing RIGHT_TAIL");
let term = render_lines(&[wrapped_row], 140, 1);
let added_bg = Some(ctx.theme.diff_added_bg());
let removed_bg = Some(ctx.theme.diff_removed_bg());
let right_content_start = term
.get_lines()
.first()
.and_then(|line| (0..line.len()).find(|&col| term.get_style_at(0, col).bg == added_bg))
.expect("wrapped row should contain at least one span with the diff_added bg");
let neutral_start = right_content_start.saturating_sub(SEPARATOR_WIDTH + MIN_GUTTER_WIDTH);
for col in neutral_start..right_content_start {
let actual_bg = term.get_style_at(0, col).bg;
assert_ne!(actual_bg, added_bg, "padding column {col} should not inherit added background");
assert_ne!(actual_bg, removed_bg, "padding column {col} should not inherit removed background");
}
}
#[test]
fn diff_header_aligns_with_split_line_numbers() {
let mut mode = make_mode(git_diff_document(vec![modified_file_with_hunks(
"x.rs",
vec![hunk("@@ -1,2 +1,2 @@", 1, 2, 1, 2, vec![removed_line("old();", 1), added_line("new();", 1)])],
)]));
let lines = render_component(|ctx| mode.render(ctx), 140, 7).get_lines();
let header = lines.iter().find(|line| line.contains("x.rs modified")).expect("diff header should render");
assert!(header.contains("│ x.rs modified"), "diff header should align with split line numbers: {header:?}");
}
#[test]
fn git_diff_view_keeps_wrapped_code_out_of_the_line_number_gutter() {
let filler = "A".repeat(48);
let mut mode = make_mode(git_diff_document(vec![modified_file_with_hunks(
"x.rs",
vec![hunk(
"@@ -1,2 +1,2 @@",
1,
2,
1,
2,
vec![removed_line("LEFT_MARK", 1), added_line(format!("RIGHT_HEAD {filler} RIGHT_TAIL"), 1)],
)],
)]));
let term = render_component(|ctx| mode.render(ctx), 140, 7);
assert_buffer_eq(
&term,
&[
cols(&[(" Git Diff 1 file +1 -1", 28), ("│", 1), (" x.rs modified +1 -1", 0)]),
cols(&[(&"─".repeat(28), 28), ("│", 1), (&"─".repeat(111), 0)]),
cols(&[("▎── x.rs", 21), ("+1 -1 M", 7), ("│", 1), (" 1 LEFT_MARK", 55), ("", 1), (" 1 RIGHT_HEAD", 55)]),
cols(&[("", 28), ("│", 1), ("", 55), ("", 1), (" ↪ ", 3), (filler.as_str(), 0)]),
cols(&[("", 28), ("│", 1), ("", 55), ("", 1), (" ↪ ", 3), ("RIGHT_TAIL", 0)]),
cols(&[("", 28), ("│", 1)]),
LEFT_HINT_LINE.to_string(),
],
);
}
#[test]
fn screenshot_shaped_git_diff_wrap_row_stays_out_of_gutters() {
let mut mode = make_mode(git_diff_document(vec![modified_file_with_hunks(
"split_diff.rs",
vec![hunk(
"@@ -56,2 +57,2 @@",
56,
2,
57,
2,
vec![
removed_line("let left = left_lines.get(i).cloned().unwrap_or_else(|| blank_panel(left_panel));", 56),
added_line(
"let left = left_lines.get(i).cloned().unwrap_or_else(|| blank_panel(left_panel, theme.code_bg()));",
57,
),
],
)],
)]));
let term = render_component(|ctx| mode.render(ctx), 151, 8);
let lines = term.get_lines();
let wrapped_idx = lines
.iter()
.position(|line| line.contains("blank_panel(left_panel));") && line.contains("theme.code_bg()));"))
.expect("expected wrapped row containing both continuation segments");
let wrapped_row = &lines[wrapped_idx];
assert_buffer_eq(
&render_lines(&[tui::Line::new(wrapped_row.clone())], 151, 1),
&[cols(&[
("", 28),
("│", 1),
(" ↪ ", 3),
("blank_panel(left_panel));", 57),
("", 1),
(" ↪ ", 3),
("blank_panel(left_panel, theme.code_bg()));", 0),
])],
);
let column_of = |needle: &str| {
let byte_start = wrapped_row.find(needle).unwrap_or_else(|| panic!("expected {needle:?} in wrapped row"));
wrapped_row[..byte_start].chars().count()
};
let left_start = column_of("blank_panel(left_panel));");
let right_start = column_of("blank_panel(left_panel, theme.code_bg()));");
let ctx = ViewContext::new((151, 8));
let added_bg = Some(ctx.theme.diff_added_bg());
let removed_bg = Some(ctx.theme.diff_removed_bg());
for col in left_start.saturating_sub(MIN_GUTTER_WIDTH)..left_start {
let actual_bg = term.get_style_at(wrapped_idx, col).bg;
assert_eq!(actual_bg, removed_bg, "left continuation gutter column {col} should carry removed background");
}
let separator_col = right_start.saturating_sub(MIN_GUTTER_WIDTH + 1);
let separator_bg = term.get_style_at(wrapped_idx, separator_col).bg;
assert_ne!(separator_bg, added_bg, "separator column {separator_col} should not inherit added background");
assert_ne!(separator_bg, removed_bg, "separator column {separator_col} should not inherit removed background");
for col in right_start.saturating_sub(MIN_GUTTER_WIDTH)..right_start {
let actual_bg = term.get_style_at(wrapped_idx, col).bg;
assert_eq!(actual_bg, added_bg, "right continuation gutter column {col} should carry added background");
}
assert_eq!(term.get_style_at(wrapped_idx, left_start).bg, removed_bg);
assert_eq!(term.get_style_at(wrapped_idx, right_start).bg, added_bg);
}
fn make_long_header_doc() -> GitDiffDocument {
let mut doc = sample_git_diff_document();
let long_path = "src/components/git_diff_mode/this_is_a_deliberately_long_filename_that_should_be_clipped_in_the_patch_header.rs".to_string();
doc.files[0].old_path = Some(long_path.clone());
doc.files[0].path = long_path;
doc
}
#[test]
fn render_empty_state() {
let sb = 26;
let mut mode = GitDiffMode::new(PathBuf::from("."));
let term = render_component(|ctx| mode.render(ctx), 80, 3);
assert_buffer_eq(
&term,
&[
cols(&[("", sb), ("│", 1), ("No changes in working tree relative to HEAD", 0)]),
cols(&[("", sb), ("│", 1)]),
LEFT_HINT_LINE.to_string(),
],
);
assert_eq!(term.get_style_at(0, 0).bg, None, "empty-state sidebar filler should not set an explicit bg");
assert_eq!(term.get_style_at(0, sb).bg, None, "empty-state separator should not set an explicit bg");
}
#[test]
fn ready_state_sidebar_and_separator_use_no_explicit_bg() {
let sb = 28;
let doc = sample_git_diff_document();
let mut mode = make_mode(doc);
let term = render_component(|ctx| mode.render(ctx), 100, 9);
let ctx = ViewContext::new((100, 9));
assert_eq!(term.get_style_at(0, 1).bg, None, "sidebar header should not set an explicit bg");
assert_eq!(term.get_style_at(0, sb).bg, None, "split separator should not set an explicit bg");
assert_eq!(
term.get_style_at(0, sb).fg,
Some(ctx.theme.muted()),
"split separator should match the header rule style"
);
assert_eq!(term.get_style_at(3, 1).bg, None, "unselected sidebar rows should not set an explicit bg");
}
#[test]
fn render_error_state() {
let sb = 26;
let mut mode = GitDiffMode::new(PathBuf::from("."));
mode.set_load_state(GitDiffLoadState::Error { message: "not a repo".to_string() });
let term = render_component(|ctx| mode.render(ctx), 80, 3);
assert_buffer_eq(
&term,
&[
cols(&[("", sb), ("│", 1), ("Git diff unavailable: not a repo", 0)]),
cols(&[("", sb), ("│", 1)]),
LEFT_HINT_LINE.to_string(),
],
);
}
#[test]
fn render_shows_file_list_and_patch() {
let sb = 28;
let doc = sample_git_diff_document();
let mut mode = make_mode(doc);
let term = render_component(|ctx| mode.render(ctx), 100, 9);
assert_buffer_eq(
&term,
&[
cols(&[(" Git Diff 2 files +2 -1", sb), ("│", 1), ("a.rs modified +1 -1", 0)]),
cols(&[(&"─".repeat(sb), sb), ("│", 1), (&"─".repeat(71), 0)]),
cols(&[("▎── a.rs", 21), ("+1 -1 M", 7), ("│", 1), ("1 fn main() {", 0)]),
cols(&[(" ── b.rs", 21), ("+1 -0 A", 7), ("│", 1), ("2 - old();", 0)]),
cols(&[("", sb), ("│", 1), ("2 + new();", 0)]),
cols(&[("", sb), ("│", 1), ("3 }", 0)]),
cols(&[("", sb), ("│", 1)]),
cols(&[("", sb), ("│", 1)]),
LEFT_HINT_LINE.to_string(),
],
);
}
#[test]
fn unified_added_only_diff_uses_one_line_number_column() {
let mut mode = make_mode(git_diff_document(vec![modified_file_with_hunks(
"lib.rs",
vec![hunk(
"@@ -50,2 +50,3 @@",
50,
2,
50,
3,
vec![
context_line("pub use existing::Item;", 50, 50),
added_line("pub use diffs::intraline::{IntralineEmphasis, intraline_emphasis};", 51),
context_line("pub use focus::{FocusOutcome, FocusRing};", 51, 52),
],
)],
)]));
let lines = render_component(|ctx| mode.render(ctx), 120, 8).get_lines();
assert!(
lines.iter().any(|line| line.contains("50 pub use existing::Item;")),
"context rows should render one line number column: {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("51 + pub use diffs::intraline")),
"added rows should render one line number column plus the diff marker: {lines:?}"
);
assert!(
lines.iter().all(|line| !line.contains("50 50") && !line.contains("51 52")),
"unified rows should not render old and new line number columns: {lines:?}"
);
}
#[test]
fn added_lines_use_added_background_style() {
let mut mode = make_mode(sample_git_diff_document());
let term = render_component(|ctx| mode.render(ctx), 100, 8);
let lines = term.get_lines();
let added_row = lines.iter().position(|line| line.contains("new();")).expect("expected added diff line");
let added_col = lines[added_row].find("new();").expect("expected added code text in row");
let ctx = ViewContext::new((100, 8));
assert_eq!(term.get_style_at(added_row, added_col).bg, Some(ctx.theme.diff_added_bg()));
}
#[test]
fn narrow_width_renders_unified_diff_rows() {
let mut mode = make_mode(sample_git_diff_document());
let term = render_component(|ctx| mode.render(ctx), 108, 10);
let lines = term.get_lines();
assert!(lines.iter().any(|line| line.contains("old();")), "expected removed line in unified view");
assert!(lines.iter().any(|line| line.contains("new();")), "expected added line in unified view");
assert!(
!lines.iter().any(|line| line.contains("old();") && line.contains("new();")),
"unified view should keep old/new content on separate rows"
);
}
#[test]
fn wide_width_renders_split_diff_rows() {
let mut mode = make_mode(sample_git_diff_document());
let term = render_component(|ctx| mode.render(ctx), 110, 10);
let lines = term.get_lines();
assert!(
lines.iter().any(|line| line.contains("old();") && line.contains("new();")),
"split view should render old/new content on the same row"
);
}
#[tokio::test]
async fn opening_full_file_retains_split_layout() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("foo.rs"), "fn main() {\n new_call();\n keep();\n}\n").unwrap();
let mut doc = git_diff_document(vec![modified_file_with_hunks(
"foo.rs",
vec![hunk(
"@@ -1,4 +1,4 @@",
1,
4,
1,
4,
vec![
context_line("fn main() {", 1, 1),
removed_line(" old_call();", 2),
added_line(" new_call();", 2),
context_line(" keep();", 3, 3),
context_line("}", 4, 4),
],
)],
)]);
doc.repo_root = dir.path().to_path_buf();
let mut mode = make_mode(doc);
let ctx = ViewContext::new((140, 12));
mode.render(&ctx);
mode.on_event(&Event::Key(key(KeyCode::Char('l')))).await; let hunk_lines = render_component(|ctx| mode.render(ctx), 140, 12).get_lines();
assert!(
hunk_lines.iter().any(|line| line.contains("old_call();") && line.contains("new_call();")),
"precondition: hunk view should be split, got {hunk_lines:?}"
);
mode.on_event(&Event::Key(key(KeyCode::Char('o')))).await; let lines = render_component(|ctx| mode.render(ctx), 140, 12).get_lines();
assert!(
lines.iter().any(|line| line.matches("fn main() {").count() == 2),
"full-file view should stay split: an unchanged line should appear in both panes, got {lines:?}"
);
assert!(
!lines.iter().any(|line| line.contains("old_call();")),
"full-file view shows the working tree, so removed lines should be gone, got {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("new_call();")),
"full-file view should still show the added line, got {lines:?}"
);
}
#[tokio::test]
async fn opening_full_file_stays_unified_when_narrow() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("foo.rs"), "fn main() {\n new_call();\n keep();\n}\n").unwrap();
let mut doc = git_diff_document(vec![modified_file_with_hunks(
"foo.rs",
vec![hunk(
"@@ -1,4 +1,4 @@",
1,
4,
1,
4,
vec![
context_line("fn main() {", 1, 1),
removed_line(" old_call();", 2),
added_line(" new_call();", 2),
context_line(" keep();", 3, 3),
context_line("}", 4, 4),
],
)],
)]);
doc.repo_root = dir.path().to_path_buf();
let mut mode = make_mode(doc);
let ctx = ViewContext::new((100, 12));
mode.render(&ctx);
mode.on_event(&Event::Key(key(KeyCode::Char('l')))).await;
mode.render(&ctx);
mode.on_event(&Event::Key(key(KeyCode::Char('o')))).await;
let lines = render_component(|ctx| mode.render(ctx), 100, 12).get_lines();
assert!(
lines.iter().all(|line| line.matches("fn main() {").count() <= 1),
"full-file view should stay unified when narrow, got {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("fn main() {")),
"full-file view should still render the file, got {lines:?}"
);
}
#[test]
fn scroll_track_appears_when_patch_overflows_viewport() {
let body: Vec<_> = (1..=40).map(|line_no| added_line(format!("line_{line_no}();"), line_no)).collect();
let mut mode = make_mode(git_diff_document(vec![modified_file_with_hunks(
"long.rs",
vec![hunk("@@ -0,0 +1,40 @@", 0, 0, 1, 40, body)],
)]));
let term = render_component(|ctx| mode.render(ctx), 100, 10);
let lines = term.get_lines();
let thumb_rows = lines.iter().filter(|line| line.ends_with('█')).count();
assert!(thumb_rows > 0, "overflowing patch should render a scrollbar thumb at the right edge: {lines:?}");
assert!(thumb_rows < 7, "thumb should not fill the whole track: {thumb_rows}");
}
#[test]
fn scroll_track_is_blank_when_patch_fits() {
let mut mode = make_mode(sample_git_diff_document());
let term = render_component(|ctx| mode.render(ctx), 100, 12);
let lines = term.get_lines();
assert!(
lines.iter().all(|line| !line.contains('█') && !line.contains('▪')),
"patch that fits the viewport should not render scrollbar glyphs: {lines:?}"
);
}
#[tokio::test]
async fn focused_panel_title_brightens_and_footer_follows_focus() {
let mut mode = make_mode(sample_git_diff_document());
let ctx = ViewContext::new((100, 20));
let term = render_component(|c| mode.render(c), 100, 20);
assert_eq!(term.get_style_at(0, 1).fg, Some(ctx.theme.accent()), "focused sidebar title should use accent");
let footer = term.get_lines().last().cloned().unwrap_or_default();
assert_eq!(footer, LEFT_HINT_LINE, "left-focused footer should show tree keys");
send_keys(&mut mode, &[KeyCode::Char('l')]).await;
let term = render_component(|c| mode.render(c), 100, 20);
assert_eq!(
term.get_style_at(0, 1).fg,
Some(ctx.theme.text_primary()),
"unfocused sidebar title should fall back to the text color"
);
let footer = term.get_lines().last().cloned().unwrap_or_default();
assert_eq!(footer, RIGHT_HINT_LINE, "right-focused footer should show diff keys");
}
#[test]
fn git_diff_mode_soft_wraps_long_patch_headers_in_rhs_panel() {
let mut mode = make_mode(make_long_header_doc());
let term = render_component(|ctx| mode.render(ctx), 100, 8);
let lines = term.get_lines();
assert!(
lines.iter().any(|line| line.contains("this_is_a_deliberately_long_filename")),
"expected a line containing the start of the long header, got {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("should_be_clipped_in_the_patch_header.rs")),
"expected a line containing the wrapped tail of the long header, got {lines:?}"
);
assert!(lines.iter().all(|line| line.chars().count() <= 100));
}
async fn send_keys(mode: &mut GitDiffMode, codes: &[KeyCode]) {
let ctx = ViewContext::new((100, 20));
for &code in codes {
mode.render(&ctx);
mode.on_event(&Event::Key(key(code))).await;
}
}
#[tokio::test]
async fn draft_comment_appears_after_correct_line_when_submitted_comment_exists() {
let mut mode = make_mode(comment_diff_document());
send_keys(&mut mode, &[KeyCode::Char('l')]).await;
send_keys(
&mut mode,
&[
KeyCode::Char('c'),
KeyCode::Char('f'),
KeyCode::Char('i'),
KeyCode::Char('r'),
KeyCode::Char('s'),
KeyCode::Char('t'),
KeyCode::Enter,
],
)
.await;
send_keys(
&mut mode,
&[
KeyCode::Char('j'),
KeyCode::Char('j'),
KeyCode::Char('c'),
KeyCode::Char('d'),
KeyCode::Char('r'),
KeyCode::Char('a'),
KeyCode::Char('f'),
KeyCode::Char('t'),
],
)
.await;
let term = render_component(|ctx| mode.render(ctx), 100, 20);
let lines = term.get_lines();
let line_one_row = lines.iter().position(|l| l.contains("line_one")).expect("line_one should render");
let comment_row = lines.iter().position(|l| l.contains("first")).expect("submitted comment should render");
let line_two_row = lines.iter().position(|l| l.contains("line_two")).expect("line_two should render");
let line_three_row = lines.iter().position(|l| l.contains("line_three")).expect("line_three should render");
let draft_row = lines.iter().position(|l| l.contains("draft")).expect("draft text should render");
assert!(
comment_row > line_one_row,
"submitted comment (row {comment_row}) should appear after line_one (row {line_one_row})"
);
assert!(
line_two_row > comment_row,
"line_two (row {line_two_row}) should appear after submitted comment (row {comment_row})"
);
assert!(
line_three_row > line_two_row,
"line_three (row {line_three_row}) should appear after line_two (row {line_two_row})"
);
assert!(
draft_row > line_three_row,
"draft (row {draft_row}) should appear after line_three (row {line_three_row}), \
not shifted up by the submitted comment splice"
);
}
#[tokio::test]
async fn submitted_comment_visible_on_last_line() {
let mut mode = make_mode(comment_diff_document());
send_keys(&mut mode, &[KeyCode::Char('l')]).await;
send_keys(
&mut mode,
&[
KeyCode::Char('j'),
KeyCode::Char('j'),
KeyCode::Char('j'),
KeyCode::Char('c'),
KeyCode::Char('h'),
KeyCode::Char('i'),
KeyCode::Enter,
],
)
.await;
let term = render_component(|ctx| mode.render(ctx), 100, 7);
let lines = term.get_lines();
assert!(lines.iter().any(|l| l.contains("line_three")), "cursor line should be visible, got: {lines:?}");
assert!(
lines.iter().any(|l| l.contains("hi")),
"submitted comment text should be visible in viewport, got: {lines:?}"
);
assert!(lines.iter().any(|l| l.contains("└")), "comment bottom border should be visible, got: {lines:?}");
}
#[tokio::test]
async fn draft_comment_bottom_border_visible_on_last_line() {
let mut mode = make_mode(comment_diff_document());
send_keys(&mut mode, &[KeyCode::Char('l')]).await;
send_keys(
&mut mode,
&[
KeyCode::Char('j'),
KeyCode::Char('j'),
KeyCode::Char('j'),
KeyCode::Char('c'),
KeyCode::Char('h'),
KeyCode::Char('i'),
],
)
.await;
let term = render_component(|ctx| mode.render(ctx), 100, 8);
let lines = term.get_lines();
assert!(lines.iter().any(|l| l.contains("hi")), "draft text should be visible, got: {lines:?}");
assert!(lines.iter().any(|l| l.contains("└")), "draft bottom border should be visible, got: {lines:?}");
}