use super::*;
use crate::diff_review::{ReviewFile, ReviewLine, ReviewLineKind, ReviewSnapshot};
use ratatui::{Terminal, backend::TestBackend};
fn state() -> MissionControlState {
let mut state = MissionControlState::default();
state.select_right_column_tab(RightColumnTab::Diff);
state.diff.apply_snapshot(Arc::new(PreparedSnapshot::new(
ReviewSnapshot {
root: "/repo".into(),
files: vec![ReviewFile {
path: "src/main.rs".into(),
original: "fn old() {}\n".into(),
current: "fn new() {}\n".into(),
notice: None,
rows: vec![
ReviewLine {
old_line: Some(1),
new_line: None,
text: "fn old() {}".into(),
kind: ReviewLineKind::Removed,
},
ReviewLine {
old_line: None,
new_line: Some(1),
text: "fn new() {}".into(),
kind: ReviewLineKind::Added,
},
],
}],
comments: vec![],
},
None,
)));
state
}
fn key(state: &mut MissionControlState, code: KeyCode) {
apply(
state,
DiffCommand::Key(KeyEvent::new(code, KeyModifiers::NONE)),
);
}
fn alt(state: &mut MissionControlState, character: char) {
apply(
state,
DiffCommand::Key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::ALT)),
);
}
#[test]
fn diff_focus_cycle_preserves_prompt_and_routes_comments() {
let mut state = state();
state.set_prompt_text("Keep my prompt", 14);
assert!(!owns_key(
&state,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)
));
for expected in [1, 2, 3, 0] {
key(&mut state, KeyCode::Tab);
assert_eq!(state.diff.focus, expected);
}
assert!(state.is_prompt_focused());
key(&mut state, KeyCode::BackTab);
key(&mut state, KeyCode::Down);
key(&mut state, KeyCode::Enter);
let dialog = state.diff.dialog.as_ref().unwrap();
assert_eq!(dialog.side, ReviewSide::Changed);
assert_eq!(dialog.line, 1);
apply(
&mut state,
DiffCommand::Paste("Review this\ncarefully".into()),
);
alt(&mut state, 's');
assert!(state.diff.saving);
assert!(
matches!(&state.diff.pending, Some(CommentChange::Save { text, line: 1, .. }) if text == "Review this\ncarefully")
);
assert!(
state.diff.dialog.is_some(),
"keep draft until worker acknowledges"
);
assert_eq!(state.prompt_plain_text(), "Keep my prompt");
}
#[test]
fn vanished_file_comments_can_be_opened_and_resolved() {
let mut state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
snapshot.files[0].original.clear();
snapshot.files[0].rows.clear();
snapshot.comments.push(ReviewComment {
id: "one".into(),
path: "src/main.rs".into(),
side: ReviewSide::Changed,
line: 3,
anchor: "gone".into(),
text: "retained".into(),
stale: true,
resolved: false,
});
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
key(&mut state, KeyCode::Tab);
key(&mut state, KeyCode::Down);
key(&mut state, KeyCode::Enter);
assert!(state.diff.dialog.as_ref().unwrap().stale);
alt(&mut state, 'r');
assert!(matches!(
state.diff.pending,
Some(CommentChange::Resolve { resolved: true, .. })
));
}
#[test]
fn diff_render_replaces_transcript_and_keeps_rail_on_short_screens() {
for (width, height) in [(120, 35), (80, 18), (32, 10)] {
let state = state();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| crate::tui::render::draw(frame, &state))
.unwrap();
let layout = crate::tui::layout::layout_for(Rect::new(0, 0, width, height), &state);
let diff_tab = layout.right_column_tab_area(RightColumnTab::Diff).unwrap();
assert!(diff_tab.bottom() <= height);
assert_eq!(
layout.right_column_tab_at(diff_tab.x, diff_tab.y),
Some(RightColumnTab::Diff)
);
assert!(layout.activity_tree.is_none());
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("Diff"));
if width >= 80 {
assert!(text.contains("Original"));
assert!(text.contains("fn new"));
}
}
}
#[test]
fn mouse_source_selects_before_opening_and_escape_cancels() {
let mut state = state();
let area = Rect::new(0, 0, 120, 35);
let columns = render::columns(crate::tui::layout::layout_for(area, &state).transcript);
let click = |x, y| MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x,
row: y,
modifiers: KeyModifiers::NONE,
};
apply(
&mut state,
DiffCommand::Mouse(click(columns[1].x + 1, columns[1].y + 1), area),
);
assert!(state.diff.dialog.is_none());
let mut terminal = Terminal::new(TestBackend::new(120, 35)).unwrap();
terminal
.draw(|frame| {
draw(
frame,
crate::tui::layout::layout_for(area, &state).transcript,
&state,
)
})
.unwrap();
let marker: String = (1..=3)
.map(|offset| {
terminal.backend().buffer()[(columns[1].x + offset, columns[1].y + 1)].symbol()
})
.collect();
assert_eq!(marker, "[+]");
apply(
&mut state,
DiffCommand::Mouse(click(columns[1].x + 1, columns[1].y + 1), area),
);
assert_eq!(
state.diff.dialog.as_ref().unwrap().side,
ReviewSide::Original
);
key(&mut state, KeyCode::Esc);
assert!(state.diff.dialog.is_none());
}
#[test]
fn diff_folder_grouping_and_refresh_retain_comment_identity() {
let mut state = state();
let mut source = (***state.diff.snapshot.as_ref().unwrap()).clone();
source.comments.push(ReviewComment {
id: "selected".into(),
path: "src/main.rs".into(),
side: ReviewSide::Changed,
line: 1,
anchor: "fn new() {}".into(),
text: "review".into(),
stale: false,
resolved: false,
});
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(source.clone(), None)));
assert_eq!(
state
.diff
.tree_entries()
.iter()
.map(|e| e.label.as_str())
.collect::<Vec<_>>(),
["src/", " main.rs", " [•] D:1 review"]
);
key(&mut state, KeyCode::Tab);
key(&mut state, KeyCode::Down);
let mut other = source.files[0].clone();
other.path = "aaa/nested/other.rs".into();
source.files.insert(0, other);
let mut comment = source.comments[0].clone();
comment.id = "earlier".into();
source.comments.insert(0, comment);
let refreshed = PreparedSnapshot::new(source, state.diff.snapshot.as_deref());
state.diff.apply_snapshot(Arc::new(refreshed));
assert_eq!(state.diff.selected, 1);
assert_eq!(
state.diff.tree_entries()[state.diff.tree_row].identity,
"comment:selected"
);
assert!(
state
.diff
.tree_entries()
.iter()
.any(|entry| entry.label == " nested/")
);
assert!(
state
.diff
.tree_entries()
.iter()
.any(|entry| entry.label == " other.rs")
);
key(&mut state, KeyCode::Enter);
assert_eq!(
state.diff.dialog.as_ref().unwrap().id.as_deref(),
Some("selected")
);
}
#[test]
fn diff_projection_preserves_multiline_syntax_and_reuses_unchanged_sources() {
use crate::rendering::DisplayRole;
let state = state();
let mut source = (***state.diff.snapshot.as_ref().unwrap()).clone();
source.files[0].original = "/* start\ninside comment\n*/\n".into();
source.files[0].current = "/* start\nchanged comment\n*/\n".into();
let first = PreparedSnapshot::new(source.clone(), None);
assert!(
first.files_display[0].original[1]
.spans
.iter()
.all(|span| span.role == DisplayRole::Comment)
);
assert!(
first.files_display[0].current[1]
.spans
.iter()
.all(|span| span.role == DisplayRole::Comment)
);
let second = PreparedSnapshot::new(source.clone(), Some(&first));
assert!(Arc::ptr_eq(
&first.files_display[0],
&second.files_display[0]
));
source.files[0].current.push_str("fn changed() {}\n");
let third = PreparedSnapshot::new(source, Some(&second));
assert!(!Arc::ptr_eq(
&second.files_display[0],
&third.files_display[0]
));
}
#[test]
fn diff_original_removals_are_colored_and_worktree_is_visible() {
use crate::rendering::DisplayRole;
let state = state();
let mut terminal = Terminal::new(TestBackend::new(120, 15)).unwrap();
terminal
.draw(|frame| draw(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
assert!(text.contains("Files · /repo"));
assert!(text.contains("src/"));
assert!(text.contains(" main.rs"));
let original = render::columns(Rect::new(0, 0, 120, 15))[1];
assert_eq!(
buffer[(original.x + 8, original.y + 1)].bg,
state
.theme
.display_role(DisplayRole::DiffRemoved)
.bg
.unwrap()
);
}
#[test]
fn clicking_source_borders_does_not_open_a_comment() {
let mut state = state();
let area = Rect::new(0, 0, 120, 35);
let columns = render::columns(super::super::layout::layout_for(area, &state).transcript);
for column in &columns[1..] {
for (x, y) in [
(column.x, column.y),
(column.x + 2, column.bottom() - 1),
(column.right() - 1, column.y + 1),
(column.right() - 2, column.y + 1),
] {
mouse_input(
&mut state,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x,
row: y,
modifiers: KeyModifiers::NONE,
},
area,
);
assert!(state.diff.dialog.is_none());
}
}
}
fn scrolling_state() -> MissionControlState {
let mut state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
let file = &mut snapshot.files[0];
file.original = (1..=40).map(|line| format!("original {line}\n")).collect();
file.rows = (1..=40)
.map(|line| ReviewLine {
old_line: Some(line),
new_line: Some(line),
text: format!("original {line}"),
kind: ReviewLineKind::Context,
})
.collect();
file.rows[2].new_line = None;
file.rows[2].kind = ReviewLineKind::Removed;
file.rows.insert(
1,
ReviewLine {
old_line: None,
new_line: Some(2),
text: "inserted".into(),
kind: ReviewLineKind::Added,
},
);
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
state
}
#[test]
fn wheel_keeps_focus_and_selection_and_click_targets_scrolled_line() {
let mut state = scrolling_state();
let area = Rect::new(0, 0, 120, 30);
let columns = render::columns(crate::tui::layout::layout_for(area, &state).transcript);
let mouse = |kind, pane: usize| MouseEvent {
kind,
column: columns[pane].x + 1,
row: columns[pane].y + 1,
modifiers: KeyModifiers::NONE,
};
let focus = state.focus_pane;
let tree_row = state.diff.tree_row;
for pane in [1, 2, 0] {
apply(
&mut state,
DiffCommand::Mouse(mouse(MouseEventKind::ScrollDown, pane), area),
);
}
assert_eq!(state.focus_pane, focus);
assert_eq!(
(
state.diff.focus,
state.diff.original_row,
state.diff.diff_row,
state.diff.tree_row
),
(0, 0, 0, tree_row)
);
assert_eq!(state.diff.viewport_offsets(5)[1..], [1, 2]);
apply(
&mut state,
DiffCommand::Mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2), area),
);
assert!(state.diff.dialog.is_none());
apply(
&mut state,
DiffCommand::Mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2), area),
);
let dialog = state.diff.dialog.as_ref().unwrap();
assert_eq!((dialog.side, dialog.line), (ReviewSide::Changed, 2));
}
#[test]
fn scroll_mapping_handles_additions_deletions_and_keyboard_visibility() {
let mut state = scrolling_state();
let page = &mut state.diff;
page.scroll_viewport(3, true, 5);
assert_eq!(page.viewport_offsets(5)[1..], [1, 1]);
page.scroll_viewport(3, true, 5);
assert_eq!(page.viewport_offsets(5)[1..], [1, 2]);
page.scroll_viewport(2, true, 5);
assert_eq!(page.viewport_offsets(5)[1..], [2, 3]);
for _ in 0..15 {
page.scroll_viewport(3, true, 5);
}
page.focus = 3;
page.navigate(true);
assert_eq!(page.diff_row, 1);
assert_eq!(page.viewport_offsets(5)[2], 1);
for _ in 0..10 {
page.navigate(true);
}
assert_eq!(page.viewport_offsets(5)[2], 7);
let mut snapshot = (***page.snapshot.as_ref().unwrap()).clone();
snapshot.files[0].rows.truncate(1);
snapshot.files[0].original = "one\n".into();
page.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
assert_eq!(page.viewport_offsets(5)[1..], [0, 0]);
assert_eq!((page.original_row, page.diff_row), (0, 0));
}
#[test]
fn diff_large_source_keeps_syntax_and_plain_budget_remainder() {
use crate::rendering::{DisplayRole, highlight::highlight_source_file};
let source = format!(
"fn start() {{}}\n/*\n{}*/\nfn end() {{}}\n",
"inside\n".repeat(450)
);
let state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
snapshot.files[0].current = source;
let prepared = PreparedSnapshot::new(snapshot, None);
let lines = &prepared.files_display[0].current;
assert!(
lines[0]
.spans
.iter()
.any(|span| span.role == DisplayRole::Keyword)
);
assert!(
lines[440]
.spans
.iter()
.all(|span| span.role == DisplayRole::Comment)
);
assert!(
lines[453]
.spans
.iter()
.any(|span| span.role == DisplayRole::Keyword)
);
assert_ne!(
state.theme.display_role(DisplayRole::Keyword).fg,
state.theme.display_role(DisplayRole::FallbackCode).fg
);
let source = format!("fn start() {{}}\n{}\nfn remainder() {{}}", "x".repeat(4097));
let lines = highlight_source_file(&source, Some("rs"));
assert!(
lines[0]
.spans
.iter()
.any(|span| span.role == DisplayRole::Keyword)
);
assert_eq!(lines[2].plain_text(), "fn remainder() {}");
assert!(
lines[2]
.spans
.iter()
.all(|span| span.role == DisplayRole::FallbackCode)
);
}
#[test]
fn diff_plain_unknown_source_uses_themed_change_foregrounds() {
use crate::rendering::DisplayRole;
let mut state = state();
let mut source = (***state.diff.snapshot.as_ref().unwrap()).clone();
source.files[0].path = "unknown.no_syntax".into();
source.files[0].original = "OLD\n".into();
source.files[0].current = "NEW\n".into();
source.files[0].rows[0].text = "OLD".into();
source.files[0].rows[1].text = "NEW".into();
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(source, None)));
for depth in [
crate::appearance::ColorDepth::TrueColor,
crate::appearance::ColorDepth::Ansi256,
] {
let mut appearance = crate::appearance::RuntimeAppearance::default();
appearance.policy.depth = depth;
state.theme =
crate::tui::theme::MissionControlTheme::from_runtime_appearance(&appearance, 1);
let mut terminal = Terminal::new(TestBackend::new(120, 15)).unwrap();
terminal
.draw(|frame| draw(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
for (text, role) in [
("OLD", DisplayRole::DiffRemoved),
("NEW", DisplayRole::DiffInserted),
] {
let cells = buffer
.content
.windows(3)
.find(|cells| cells.iter().map(|cell| cell.symbol()).collect::<String>() == text)
.unwrap();
assert!(
cells
.iter()
.all(|cell| Some(cell.fg) == state.theme.display_role(role).fg)
);
}
}
}
#[test]
fn diff_dialog_keeps_alt_tab_shortcuts_while_normal_diff_releases_them() {
use crate::tui::input::{KeyViewports, handle_key_with_all_viewports};
let mut state = state();
key(&mut state, KeyCode::BackTab);
key(&mut state, KeyCode::Enter);
assert!(state.diff.dialog.is_some());
for number in ['1', '2', '3'] {
handle_key_with_all_viewports(
KeyEvent::new(KeyCode::Char(number), KeyModifiers::ALT),
&mut state,
KeyViewports::default(),
&[],
);
assert_eq!(state.right_column_tab, RightColumnTab::Diff);
assert!(state.diff.dialog.is_some());
}
key(&mut state, KeyCode::Esc);
for (number, expected) in [('3', RightColumnTab::Diff), ('1', RightColumnTab::Activity)] {
handle_key_with_all_viewports(
KeyEvent::new(KeyCode::Char(number), KeyModifiers::ALT),
&mut state,
KeyViewports::default(),
&[],
);
assert_eq!(state.right_column_tab, expected);
}
}
#[test]
fn comment_editor_deletion_keys_only_edit_and_alt_d_discards() {
let mut state = state();
state.diff.open_line(true);
let dialog = state.diff.dialog.as_mut().unwrap();
dialog.id = Some("saved".into());
apply(&mut state, DiffCommand::Paste("ab".into()));
key(&mut state, KeyCode::Tab);
key(&mut state, KeyCode::BackTab);
key(&mut state, KeyCode::Backspace);
assert_eq!(state.diff.dialog.as_ref().unwrap().editor.text(), "a");
key(&mut state, KeyCode::Left);
key(&mut state, KeyCode::Delete);
assert_eq!(state.diff.dialog.as_ref().unwrap().editor.text(), "");
assert!(state.diff.pending.is_none());
apply(
&mut state,
DiffCommand::Key(KeyEvent::new_with_kind(
KeyCode::Backspace,
KeyModifiers::NONE,
crossterm::event::KeyEventKind::Repeat,
)),
);
assert!(state.diff.pending.is_none());
key(&mut state, KeyCode::Delete);
key(&mut state, KeyCode::Backspace);
assert!(state.diff.pending.is_none());
assert!(state.diff.dialog.is_some());
apply(&mut state, DiffCommand::Paste("nonempty draft".into()));
alt(&mut state, 'd');
assert!(matches!(&state.diff.pending, Some(CommentChange::Delete(id)) if id == "saved"));
assert!(state.diff.dialog.is_some());
}
#[test]
fn comment_dialog_is_compact_centered_and_reopens_resolved_comments() {
let mut state = state();
state.diff.open_line(true);
let dialog = state.diff.dialog.as_mut().unwrap();
dialog.id = Some("saved".into());
dialog.resolved = true;
let area = Rect::new(0, 0, 120, 35);
let text = render::dialog_text_area(area);
assert_eq!(text, Rect::new(37, 15, 46, 5));
let mut terminal = Terminal::new(TestBackend::new(120, 35)).unwrap();
terminal.draw(|frame| draw_dialog(frame, &state)).unwrap();
let buffer = terminal.backend().buffer();
let output: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
assert!(output.contains("[Alt-S] Save"));
assert!(output.contains("[Alt-R] Reopen"));
assert!(output.contains("[Alt-D] Discard"));
assert!(!output.contains("Tab:"));
alt(&mut state, 'r');
assert!(matches!(
state.diff.pending,
Some(CommentChange::Resolve {
resolved: false,
..
})
));
}
#[test]
fn empty_save_keeps_dialog_and_refresh_requires_another_first_click() {
let mut state = state();
state.diff.open_line(true);
alt(&mut state, 's');
assert!(state.diff.dialog.is_some());
assert!(!state.diff.saving);
assert!(state.diff.error.is_some());
key(&mut state, KeyCode::Esc);
state.diff.clicked_source = Some((2, 0));
state
.diff
.apply_snapshot(state.diff.snapshot.clone().unwrap());
assert!(state.diff.clicked_source.is_none());
}
#[test]
fn compact_comment_keeps_wrapped_paragraph_cursor_visible() {
assert_compact_comment_cursor_visible(false);
}
#[test]
fn compact_comment_keeps_trailing_newline_cursor_visible() {
assert_compact_comment_cursor_visible(true);
}
fn assert_compact_comment_cursor_visible(trailing_newline: bool) {
for width in [38, 80, 120] {
let mut state = state();
key(&mut state, KeyCode::BackTab);
key(&mut state, KeyCode::Down);
key(&mut state, KeyCode::Enter);
for character in "Review 界 e\u{301} carefully. ".repeat(30).chars() {
key(&mut state, KeyCode::Char(character));
}
if trailing_newline {
key(&mut state, KeyCode::Enter);
}
let mut terminal = Terminal::new(TestBackend::new(width, 20)).unwrap();
terminal.draw(|frame| draw_dialog(frame, &state)).unwrap();
let text_area = render::dialog_text_area(Rect::new(0, 0, width, 20));
assert_eq!(text_area.height, 2);
let cursor =
ratatui::backend::Backend::get_cursor_position(terminal.backend_mut()).unwrap();
assert!(text_area.contains(cursor), "width {width}: {cursor:?}");
let editor = &state.diff.dialog.as_ref().unwrap().editor;
assert_eq!(editor.rendered_cursor_position(), Some(cursor));
assert_eq!(editor.cursor_at_position(cursor), Some(editor.text().len()));
if trailing_newline {
assert_eq!(cursor.x, text_area.x);
}
key(&mut state, KeyCode::Char('Z'));
terminal.draw(|frame| draw_dialog(frame, &state)).unwrap();
let cursor =
ratatui::backend::Backend::get_cursor_position(terminal.backend_mut()).unwrap();
assert!(text_area.contains(cursor));
let buffer = terminal.backend().buffer();
assert!(
(text_area.y..text_area.bottom()).any(|y| {
(text_area.x..text_area.right()).any(|x| buffer[(x, y)].symbol() == "Z")
}),
"newly typed text must remain visible at width {width}"
);
}
}
#[test]
fn alt_d_discards_unsaved_draft_without_deleting_a_saved_comment() {
let mut state = state();
state.diff.open_line(true);
apply(&mut state, DiffCommand::Paste("draft".into()));
alt(&mut state, 'd');
assert!(state.diff.dialog.is_none());
assert!(state.diff.pending.is_none());
assert!(!state.diff.saving);
}
#[test]
fn comment_dialog_busy_shell_does_not_offer_or_allow_close() {
for pending_taken in [false, true] {
let mut state = state();
state.diff.open_line(true);
apply(&mut state, DiffCommand::Paste("draft".into()));
alt(&mut state, 's');
assert!(state.diff.saving);
if pending_taken {
state.diff.pending.take();
}
let mut terminal = Terminal::new(TestBackend::new(120, 35)).unwrap();
terminal.draw(|frame| draw_dialog(frame, &state)).unwrap();
let output: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(output.contains("Saving…"));
assert!(!output.contains("[Esc]"));
assert!(!output.contains("[Alt-S]"));
key(&mut state, KeyCode::Esc);
assert!(state.diff.dialog.is_some());
}
}
#[test]
fn compact_gutter_keeps_source_visible_and_colors_comment_status() {
use crate::rendering::DisplayRole;
for (stale, resolved, marker, role) in [
(false, false, "[•]", DisplayRole::Link),
(true, false, "[•]", DisplayRole::DiffChanged),
(false, true, "[✓]", DisplayRole::DiffInserted),
] {
let mut state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
snapshot.comments.push(ReviewComment {
id: "marker".into(),
path: "src/main.rs".into(),
side: ReviewSide::Original,
line: 1,
anchor: "anchor".into(),
text: "Review".into(),
stale,
resolved,
});
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
state.diff.focus = 2;
let area = Rect::new(0, 0, 50, 8);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal.draw(|frame| draw(frame, area, &state)).unwrap();
let buffer = terminal.backend().buffer();
let original = render::content_area(render::columns(area)[1]);
let text: String = (original.x..original.right())
.map(|x| buffer[(x, original.y)].symbol())
.collect();
assert!(text.starts_with(&format!("{marker}1 fn old()")), "{text}");
assert_eq!(
buffer[(original.x + 1, original.y)].fg,
state.theme.display_role(role).fg.unwrap()
);
}
}
#[test]
fn diff_scrollbars_match_shared_widget_at_each_viewport_offset() {
let mut state = scrolling_state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
for index in 0..20 {
let mut file = snapshot.files[0].clone();
file.path = format!("src/extra{index}.rs");
snapshot.files.push(file);
}
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
let area = Rect::new(0, 0, 100, 10);
let visible = 8;
for position in [0, 1, 20, 100] {
state.diff.viewport = DiffViewport {
tree: position,
original: position,
diff: position + 1,
reveal: 0,
};
let offsets = state.diff.viewport_offsets(visible);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal.draw(|frame| draw(frame, area, &state)).unwrap();
for (index, column) in render::columns(area).iter().enumerate() {
let scrollbar_area = column.inner(ratatui::layout::Margin::new(1, 1));
let mut expected = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
expected
.draw(|frame| {
crate::tui::render::render_scrollbar_with_theme(
frame,
scrollbar_area,
state.diff.row_count(index + 1).saturating_sub(visible) + 1,
offsets[index],
visible,
state.theme,
)
})
.unwrap();
let x = scrollbar_area.right() - 1;
let mut symbols = String::new();
for y in scrollbar_area.y..scrollbar_area.bottom() {
let actual = &terminal.backend().buffer()[(x, y)];
assert_eq!(actual, &expected.backend().buffer()[(x, y)]);
symbols.push_str(actual.symbol());
}
assert!(symbols.contains('┃'), "missing thumb: {symbols}");
assert!(symbols.contains('│'), "missing track: {symbols}");
}
}
}
#[test]
fn line_number_width_grows_only_with_source_digits() {
let mut state = scrolling_state();
let area = Rect::new(0, 0, 60, 6);
for (offset, expected) in [
(0, " 1 original 1"),
(9, "10 original 10"),
(36, "37 original 37"),
] {
state.diff.viewport.original = offset;
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal.draw(|frame| draw(frame, area, &state)).unwrap();
let content = render::content_area(render::columns(area)[1]);
let text: String = (content.x..content.right())
.map(|x| terminal.backend().buffer()[(x, content.y)].symbol())
.collect();
assert!(text.starts_with(expected), "{text}");
}
}
#[test]
fn diff_gutters_keep_old_and_new_identities_when_scrolled() {
use crate::rendering::DisplayRole;
let mut state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
let file = &mut snapshot.files[0];
file.original = (1..=120)
.map(|line| format!("let value_{line} = {line};\n"))
.collect();
file.current = (1..=145)
.map(|line| format!("// inserted {line}\n"))
.chain(file.original.lines().enumerate().map(|(index, line)| {
if index == 106 {
"let replacement = 252;\n".to_owned()
} else {
format!("{line}\n")
}
}))
.collect();
file.rows = similar::TextDiff::from_lines(&file.original, &file.current)
.iter_all_changes()
.map(|change| ReviewLine {
old_line: change.old_index().map(|index| index + 1),
new_line: change.new_index().map(|index| index + 1),
text: change.value().trim_end_matches('\n').to_owned(),
kind: match change.tag() {
similar::ChangeTag::Equal => ReviewLineKind::Context,
similar::ChangeTag::Delete => ReviewLineKind::Removed,
similar::ChangeTag::Insert => ReviewLineKind::Added,
},
})
.collect();
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
state.diff.focus = 3;
state.diff.diff_row = 252;
let area = Rect::new(0, 0, 120, 6);
let content = render::content_area(render::columns(area)[2]);
let expected = [
" 106 251 let value_106 = 106;",
" 107 -let value_107 = 107;",
"[+] 252+let replacement = 252;",
" 108 253 let value_108 = 108;",
];
for offset in 250..=252 {
state.diff.viewport.diff = offset;
state.diff.viewport.reveal = 0;
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal.draw(|frame| draw(frame, area, &state)).unwrap();
let buffer = terminal.backend().buffer();
for (index, expected) in expected.iter().enumerate().skip(offset - 250) {
let y = content.y + (250 + index - offset) as u16;
let text: String = (content.x..content.right())
.map(|x| buffer[(x, y)].symbol())
.collect();
assert_eq!(text.trim_end(), *expected);
let display = &state.diff.snapshot.as_ref().unwrap().files_display[0];
let source = match index {
0 => &display.current[250],
1 => &display.original[106],
2 => &display.current[251],
_ => &display.current[252],
};
let role = source.spans[0].role;
assert!(!matches!(
role,
DisplayRole::Plain | DisplayRole::FallbackCode
));
assert_eq!(
buffer[(content.x + 11, y)].fg,
state.theme.display_role(role).fg.unwrap()
);
}
}
}
#[test]
fn diff_number_columns_fit_each_source_independently() {
for (old_count, new_count, removed, added) in [
(9, 120, "1 -fn old() {}", " 1+fn new() {}"),
(120, 9, " 1 -fn old() {}", " 1+fn new() {}"),
(1, 1, "1 -fn old() {}", " 1+fn new() {}"),
] {
let mut state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
snapshot.files[0]
.original
.push_str(&"// old\n".repeat(old_count - 1));
snapshot.files[0]
.current
.push_str(&"// new\n".repeat(new_count - 1));
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
let area = Rect::new(0, 0, 100, 6);
let content = render::content_area(render::columns(area)[2]);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal.draw(|frame| draw(frame, area, &state)).unwrap();
for (index, expected) in [removed, added].into_iter().enumerate() {
let text: String = (content.x..content.right())
.map(|x| terminal.backend().buffer()[(x, content.y + index as u16)].symbol())
.collect();
assert_eq!(text.trim_end(), expected);
}
}
}