Files
vamp/tests/unit/diff.rs
T
2026-10-21 11:28:55 +02:00

3295 lines
116 KiB
Rust

use super::*;
use crate::editor::{Editor, Effect, Mode};
std::thread_local! {
pub(super) static WIDTH_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[test]
fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
let history = HistoryComparison::new([
"xbc def\nkeep\n",
"abc def\nkeep\n",
"abc deg\nadded\nkeep\n",
]);
let rows: Vec<_> = (0..history.row_count())
.map(|row| {
std::array::from_fn::<_, 3, _>(|column| history.column(column).view().native_row(row))
})
.collect();
assert_eq!(
rows,
[
[Some(0), Some(0), Some(0)],
[None, None, Some(1)],
[Some(1), Some(1), Some(2)],
[Some(2), Some(2), Some(3)],
]
);
for column in 0..3 {
let history_column = history.column(column);
let projection = history_column.view();
for (display, row) in rows.iter().enumerate() {
if let Some(source) = row[column] {
assert_eq!(projection.display_row(source), Some(display));
}
}
}
let base = history.column(1);
let base = base.view();
let ColumnRow::Text(line) = base.row(0).unwrap() else {
panic!("changed base row must contain text");
};
assert!(line.changed);
for column in [0, 6] {
assert!(line.changes().any(|range| range.contains(&column)));
}
assert!(!line.changes().any(|range| range.contains(&4)));
let ColumnRow::Text(line) = base.row(2).unwrap() else {
panic!("unchanged base row must contain text");
};
assert!(!line.changed);
assert_eq!(line.changes().count(), 0);
let right = history.column(2);
let ColumnRow::Text(line) = right.view().row(1).unwrap() else {
panic!("inserted right row must contain text");
};
assert_eq!(line.text, Some("added"));
assert!(matches!(base.row(1), Some(ColumnRow::Gap)));
// Rebuilding for a new input/base choice must not retain old source mappings.
let history = HistoryComparison::new([
"abc def\nkeep\n",
"abc deg\nadded\nkeep\n",
"xbc def\nkeep\n",
]);
assert_eq!(history.column(1).view().native_row(1), Some(1));
assert_eq!(history.column(0).view().native_row(1), None);
assert_eq!(history.column(2).view().native_row(1), None);
for text in ["", "\n", "same", "same\n"] {
let history = HistoryComparison::new([text; 3]);
let native_rows = text.split('\n').count();
assert_eq!(history.row_count(), native_rows);
for column in 0..3 {
for row in 0..native_rows {
assert_eq!(history.column(column).view().native_row(row), Some(row));
assert_eq!(history.column(column).view().display_row(row), Some(row));
}
}
}
}
#[test]
fn resolver_equal_text_retains_native_rows_and_cached_viewport() {
for text in ["", "\n", "same\n", "same"] {
let lines: Vec<_> = text.split('\n').map(str::to_owned).collect();
let mut state = DiffState::new();
state.set_resolver_text(&lines, &lines);
assert_eq!(state.pane_layout(), PaneLayout::Split);
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
assert_eq!(
state.comparison.as_ref().unwrap().files[0].path,
Path::new("resolution.txt")
);
assert_eq!(state.files.as_ref().unwrap().entries().len(), 1);
for row in 0..lines.len() {
assert!(state.source_display_row(Focus::Before, row).is_some());
assert!(state.source_display_row(Focus::After, row).is_some());
}
let id = state.comparison_id;
state.top = 1;
state.left_before = 3;
state.left_after = 4;
state.set_resolver_text(&lines, &lines);
assert_eq!(state.comparison_id, id);
assert_eq!((state.top, state.left_before, state.left_after), (1, 3, 4));
}
}
#[test]
fn resolver_catalog_reveals_the_current_native_file() {
let mut state = DiffState::new();
state.set_resolver_paths(
PathBuf::new(),
vec!["binary.bin".into(), "first.txt".into(), "second.txt".into()],
1,
);
let files = state.files.as_ref().unwrap();
assert_eq!(
files.entries().nth(files.selected).unwrap().path,
Path::new("first.txt")
);
assert!(!files.is_conflicted(Path::new("first.txt")));
state
.files
.as_mut()
.unwrap()
.set_conflicted(Path::new("first.txt"), true);
state.set_resolver_paths(
PathBuf::new(),
vec!["binary.bin".into(), "first.txt".into(), "second.txt".into()],
2,
);
let files = state.files.as_ref().unwrap();
assert_eq!(
files.entries().nth(files.selected).unwrap().path,
Path::new("second.txt")
);
assert!(files.is_conflicted(Path::new("first.txt")));
assert!(!files.is_conflicted(Path::new("second.txt")));
}
fn excerpt_state(len: usize, changed: &[usize]) -> DiffState {
let mut state = DiffState::new();
state.phase = Phase::View;
state.focus = Focus::Before;
state.rows = (0..len)
.map(|index| AlignedRow {
before: Some(index),
after: Some(index + 3),
kind: if changed.contains(&index) {
RowKind::Changed
} else {
RowKind::Equal
},
changes: Vec::new(),
})
.collect();
state.reset_excerpts();
state
}
#[test]
fn excerpts_cover_context_and_hidden_rows_once_without_reindexing() {
let state = excerpt_state(100, &[30, 70]);
assert_eq!(state.excerpt_ranges, [(20, 41), (60, 81)]);
assert_eq!(
state.display_rows[0],
DisplayRow::Fold { start: 0, end: 20 }
);
assert_eq!(
state.display_rows[22],
DisplayRow::Fold { start: 41, end: 60 }
);
assert_eq!(
state.display_rows[44],
DisplayRow::Fold {
start: 81,
end: 100
}
);
let mut covered = Vec::new();
for row in &state.display_rows {
match *row {
DisplayRow::Line(index) => {
covered.push(index);
assert_eq!(state.rows[index].before, Some(index));
assert_eq!(state.rows[index].after, Some(index + 3));
}
DisplayRow::Fold { start, end } => {
assert!(
state.rows[start..end]
.iter()
.all(|row| row.kind == RowKind::Equal)
);
covered.extend(start..end);
}
}
}
assert_eq!(covered, (0..100).collect::<Vec<_>>());
for index in [30, 70] {
assert_eq!(
state
.display_rows
.iter()
.filter(|&&row| row == DisplayRow::Line(index))
.count(),
1
);
}
}
#[test]
fn zero_context_boundaries_adjacent_changes_and_touching_ranges() {
let mut state = excerpt_state(60, &[0, 1, 30, 59]);
state.set_context_options(0, 15);
assert_eq!(state.excerpt_ranges, [(0, 2), (30, 31), (59, 60)]);
assert_eq!(
state.display_rows,
[
DisplayRow::Line(0),
DisplayRow::Line(1),
DisplayRow::Fold { start: 2, end: 30 },
DisplayRow::Line(30),
DisplayRow::Fold { start: 31, end: 59 },
DisplayRow::Line(59),
]
);
assert_eq!(excerpt_state(60, &[20, 41]).excerpt_ranges, [(10, 52)]);
assert_eq!(
excerpt_state(3, &[0, 1, 2]).display_rows,
[
DisplayRow::Line(0),
DisplayRow::Line(1),
DisplayRow::Line(2),
]
);
}
#[test]
fn expansion_anchors_source_rows_merges_and_clamps_both_directions() {
let mut state = excerpt_state(100, &[30, 70]);
state.top = 11;
state.handle(Input::Char('['));
assert_eq!(state.excerpt_ranges, [(5, 41), (60, 81)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(30));
state.handle(Input::Char('['));
assert_eq!(state.excerpt_ranges, [(0, 41), (60, 81)]);
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(0, 56), (60, 81)]);
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(0, 81)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(30));
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(0, 96)]);
state.handle(Input::Char(']'));
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(0, 100)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(30));
}
#[test]
fn folds_choose_following_excerpt_or_previous_at_end() {
let mut state = excerpt_state(100, &[30, 70]);
state.handle(Input::Char('['));
assert_eq!(state.display_rows[state.top], DisplayRow::Line(5));
state.top = state
.display_rows
.iter()
.position(|&row| row == (DisplayRow::Fold { start: 41, end: 60 }))
.unwrap();
state.handle(Input::Char('['));
assert_eq!(state.excerpt_ranges, [(5, 41), (45, 81)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(45));
state.handle(Input::End);
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(5, 41), (45, 96)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(81));
}
#[test]
fn navigation_uses_display_length_and_custom_increment() {
let mut state = excerpt_state(100, &[30]);
state.handle(Input::End);
state.handle(Input::Down);
assert_eq!(state.top, 22);
state.handle(Input::Up);
assert_eq!(state.top, 21);
state.handle(Input::Home);
state.handle(Input::Up);
assert_eq!(state.top, 0);
state.set_context_options(0, 2);
state.handle(Input::Char('['));
assert_eq!(state.excerpt_ranges, [(28, 31)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(28));
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(28, 33)]);
}
#[test]
fn metadata_empty_and_wholly_added_removed_files_preserve_alignment() {
let mut state = DiffState::new();
let mut comparison = comparison();
comparison.files[0].hunks.clear();
comparison.files[0].after = comparison.files[0].before.clone();
state.comparison = Some(comparison);
state.align_file();
assert!(state.excerpt_ranges.is_empty());
assert!(state.display_rows.is_empty());
assert_eq!(state.rows.len(), 5);
state.file = 1;
state.align_file();
assert_eq!(
state.display_rows,
[DisplayRow::Line(0), DisplayRow::Line(1)]
);
assert!(
state
.rows
.iter()
.all(|row| row.kind == RowKind::Added && row.before.is_none())
);
let file = &mut state.comparison.as_mut().unwrap().files[1];
std::mem::swap(&mut file.before, &mut file.after);
file.hunks[0].before_len = 2;
file.hunks[0].after_len = 0;
state.align_file();
assert_eq!(
state.display_rows,
[DisplayRow::Line(0), DisplayRow::Line(1)]
);
assert!(
state
.rows
.iter()
.all(|row| row.kind == RowKind::Removed && row.after.is_none())
);
state.file = 2;
state.align_file();
assert!(state.rows.is_empty());
assert!(state.display_rows.is_empty());
assert!(state.comparison.as_ref().unwrap().files[2].notice.is_some());
}
fn revision(
target: RevisionTarget,
label: &str,
description: &str,
category: Category,
) -> Revision {
Revision {
target,
label: label.into(),
description: description.into(),
category,
}
}
fn catalog() -> Catalog {
Catalog {
kind: RepoKind::Git,
entries: vec![
revision(
RevisionTarget::Working,
"Working checkout",
"",
Category::Commits,
),
revision(
RevisionTarget::Commit("abcdef123".into()),
"abcdef123",
"Fix 界 e\u{301}",
Category::Commits,
),
revision(
RevisionTarget::Commit("fedcba321".into()),
"fedcba321",
"Another change",
Category::Commits,
),
revision(
RevisionTarget::FetchRemote("origin".into()),
"Load history from origin",
"",
Category::Commits,
),
revision(
RevisionTarget::Commit("branch-tip".into()),
"topic/branch",
"",
Category::Branches,
),
revision(
RevisionTarget::Remote {
remote: "origin".into(),
reference: "refs/heads/topic".into(),
},
"topic@origin",
"",
Category::Branches,
),
revision(
RevisionTarget::Commit("tag-tip".into()),
"release",
"",
Category::Tags,
),
],
notice: None,
}
}
fn pull_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcome {
Outcome::PullRequestPage {
closed,
result: Ok(PullRequestPage {
kind: RepoKind::Git,
remote: "origin".into(),
has_more,
entries: rows
.iter()
.map(|&(number, title, date)| {
(
revision(
RevisionTarget::PullRequest {
remote: "origin".into(),
number,
},
&format!("#{number} {title}"),
"Alice · main ← topic",
if closed {
Category::Closed
} else {
Category::Open
},
),
date.into(),
PullRequestReviewState::None,
)
})
.collect(),
}),
}
}
#[test]
fn pull_request_review_refresh_preserves_identity_sorting_and_raw_search_labels() {
let mut state = DiffState::pull_requests("origin".into());
state.take_request();
state.apply_result(
1,
Ok(pull_page(
false,
&[(1, "First", "1"), (2, "Second", "2")],
true,
)),
);
state.handle(Input::End);
for review_state in [
PullRequestReviewState::Approved,
PullRequestReviewState::Requested,
PullRequestReviewState::None,
] {
let mut page = pull_page(false, &[(1, "First updated", "3")], true);
if let Outcome::PullRequestPage {
result: Ok(page), ..
} = &mut page
{
page.entries[0].2 = review_state;
}
state.apply_result(1, Ok(page));
let selected = state.entries().nth(state.selected).unwrap();
assert_eq!(selected.label, "#1 First updated");
assert_eq!(state.pull_request_review_state(selected), review_state);
assert_eq!(state.entries().count(), 2);
assert_eq!(state.entries().next().unwrap().target, selected.target);
}
state.handle(Input::Char('🙏'));
assert_eq!(state.entries().count(), 0);
state.handle(Input::Backspace);
for ch in "#1 First updated".chars() {
state.handle(Input::Char(ch));
}
assert_eq!(state.entries().count(), 1);
assert_eq!(
state.pull_request_review_state(&catalog().entries[0]),
PullRequestReviewState::None
);
}
#[test]
fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion() {
let mut state = DiffState::pull_requests("origin".into());
assert_eq!(
state.categories(),
[
Category::Open,
Category::Closed,
Category::Drafts,
Category::All
]
);
state.take_request();
state.handle(Input::Char('\t'));
for ch in "FIX üni Alice".chars() {
state.handle(Input::Char(ch));
}
state.apply_result(
1,
Ok(pull_page(
true,
&[(2, "Fix Ünicode", "2026-01-02T00:00:00Z")],
true,
)),
);
assert!(state.loading);
assert!(state.pull_request_partial());
assert_eq!(state.entries().next().unwrap().label, "#2 Fix Ünicode");
state.handle(Input::Char('\t'));
state.handle(Input::Char('\t'));
state.apply_result(
1,
Ok(pull_page(
false,
&[
(1, "Fix Ünicode", "2026-01-03T00:00:00Z"),
(3, "Other work", "2026-01-01T00:00:00Z"),
],
true,
)),
);
assert_eq!(
state
.entries()
.map(|entry| entry.label.as_str())
.collect::<Vec<_>>(),
["#1 Fix Ünicode", "#2 Fix Ünicode"]
);
state.handle(Input::Char('\t'));
assert_eq!(state.query, "FIX üni Alice");
state.comparison_mode = ComparisonMode::Snapshots;
state.handle(Input::Enter);
assert_eq!(state.phase, Phase::After);
assert!(state.before.is_none() && state.after.is_none());
assert_eq!(state.comparison_mode, ComparisonMode::BranchChanges);
assert!(matches!(
state.take_request(),
Some(Operation::ResolvePullRequest(Revision {
target: RevisionTarget::PullRequest { number: 1, .. },
..
}))
));
}
#[test]
fn pull_request_drafts_filter_independently_and_share_open_loading_and_retry() {
let mut state = DiffState::pull_requests("origin".into());
state.take_request();
let mut page = pull_page(
false,
&[
(1, "Ready", "2024-01-01T00:00:00Z"),
(2, "Draft Ünicode", "2024-01-02T00:00:00Z"),
],
true,
);
if let Outcome::PullRequestPage {
result: Ok(page), ..
} = &mut page
{
page.entries[1].0.category = Category::Drafts;
}
state.apply_result(1, Ok(page));
state.apply_result(
1,
Ok(pull_page(
true,
&[(3, "Closed", "2024-01-03T00:00:00Z")],
false,
)),
);
let labels = |state: &DiffState| {
state
.entries()
.map(|entry| entry.label.clone())
.collect::<Vec<_>>()
};
assert_eq!(labels(&state), ["#1 Ready"]);
state.handle(Input::BackTab);
assert_eq!(
labels(&state),
["#3 Closed", "#2 Draft Ünicode", "#1 Ready"]
);
state.handle(Input::BackTab);
assert_eq!(labels(&state), ["#2 Draft Ünicode"]);
assert!(state.pull_request_partial());
state.apply_result(
1,
Ok(Outcome::PullRequestPage {
closed: false,
result: Err("open stream unavailable".into()),
}),
);
assert!(
state
.pull_request_notice()
.unwrap()
.contains("open stream unavailable")
);
state.handle(Input::Ctrl('r'));
assert_eq!(state.pull_request_retries, [1, 0]);
state.apply_result(1, Ok(pull_page(false, &[], false)));
assert!(!state.pull_request_partial());
for ch in "Ünicode Alice".chars() {
state.handle(Input::Char(ch));
}
assert_eq!(labels(&state), ["#2 Draft Ünicode"]);
state.handle(Input::Enter);
assert!(matches!(
state.take_request(),
Some(Operation::ResolvePullRequest(Revision {
target: RevisionTarget::PullRequest { number: 2, .. },
..
}))
));
}
#[test]
fn pull_request_all_streams_sort_deduplicate_and_preserve_selection_identity() {
let mut state = DiffState::pull_requests("origin".into());
state.take_request();
state.handle(Input::BackTab);
state.apply_result(
1,
Ok(pull_page(
false,
&[
(1, "First", "2026-01-01T00:00:00Z"),
(2, "Second", "2026-01-02T00:00:00Z"),
],
true,
)),
);
state.handle(Input::End);
assert_eq!(
state.entries().nth(state.selected).unwrap().label,
"#1 First"
);
state.apply_result(1, Ok(pull_page(true, &[], true)));
assert!(state.pull_request_partial());
state.apply_result(
1,
Ok(pull_page(
true,
&[(3, "Newest", "2026-01-03T00:00:00Z")],
true,
)),
);
assert_eq!(
state.entries().nth(state.selected).unwrap().label,
"#1 First"
);
state.apply_result(
1,
Ok(pull_page(
false,
&[(2, "Second updated", "2026-01-02T00:00:00Z")],
false,
)),
);
assert_eq!(state.entries().len(), 3);
assert_eq!(
state
.entries()
.map(|entry| entry.label.as_str())
.collect::<Vec<_>>(),
["#3 Newest", "#2 Second updated", "#1 First"]
);
assert!(state.pull_request_partial());
state.apply_result(1, Ok(pull_page(true, &[], false)));
assert!(!state.pull_request_partial());
assert!(!state.loading);
assert_eq!(
state.entries().nth(state.selected).unwrap().label,
"#1 First"
);
}
#[test]
fn pull_request_late_category_errors_retry_without_losing_rows_or_other_results() {
let mut editor = editor();
editor.execute("pull_requests origin");
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(
id,
Ok(pull_page(
false,
&[(1, "Open", "2026-01-01T00:00:00Z")],
true,
)),
);
editor.apply_diff_result(
id,
Ok(pull_page(
true,
&[(2, "Closed", "2026-01-02T00:00:00Z")],
true,
)),
);
editor.handle(Input::Char('\t'));
editor.apply_diff_result(
id,
Ok(Outcome::PullRequestPage {
closed: true,
result: Err("offline".into()),
}),
);
let diff = editor.diff().unwrap();
assert!(diff.pull_request_notice().unwrap().contains("offline"));
assert_eq!(diff.entries().next().unwrap().label, "#2 Closed");
assert!(diff.pull_request_partial());
editor.handle(Input::Ctrl('r'));
assert_eq!(editor.diff().unwrap().pull_request_retries, [0, 1]);
assert!(editor.take_diff_request().is_none());
editor.apply_diff_result(
id,
Ok(pull_page(
false,
&[(3, "More open", "2026-01-03T00:00:00Z")],
false,
)),
);
assert_eq!(
editor.diff().unwrap().entries().next().unwrap().label,
"#2 Closed"
);
editor.apply_diff_result(id, Ok(pull_page(true, &[], false)));
assert!(!editor.diff().unwrap().pull_request_partial());
editor.handle(Input::Enter);
let (_, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::ResolvePullRequest(_)));
assert!(!editor.apply_diff_result(id, Ok(pull_page(false, &[], false))));
}
#[test]
fn pull_request_loading_retry_escape_and_reopen_reject_stale_results() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("unsaved source".into())])
.unwrap();
editor.execute("pull_requests origin");
let (first, _) = editor.take_diff_request().unwrap();
editor.handle(Input::Enter);
assert!(editor.take_diff_request().is_none());
text(&mut editor, "alice");
editor.apply_diff_result(
first,
Ok(Outcome::PullRequestPage {
closed: false,
result: Err("API unavailable".into()),
}),
);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().pull_request_retries, [1, 0]);
assert!(editor.take_diff_request().is_none());
assert!(editor.apply_diff_result(
first,
Ok(pull_page(
false,
&[
(1, "Open", "2026-01-02T00:00:00Z"),
(2, "Other open", "2026-01-01T00:00:00Z"),
],
true
))
));
editor.handle(Input::Enter);
let (resolve, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::ResolvePullRequest(_)));
assert!(!editor.apply_diff_result(first, Ok(pull_page(true, &[], false))));
editor.apply_diff_result(resolve, Err("Historical head no longer retained".into()));
assert!(editor.diff().unwrap().before.is_none());
assert!(editor.diff().unwrap().after.is_none());
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (retry, operation) = editor.take_diff_request().unwrap();
assert!(matches!(
operation,
Operation::ResolvePullRequest(Revision {
target: RevisionTarget::PullRequest { number: 2, .. },
..
})
));
editor.handle(Input::Escape);
assert!(editor.diff().is_none());
assert!(!editor.apply_diff_result(retry, Ok(Outcome::Comparison(comparison()))));
assert_eq!(editor.lines(), ["unsaved source"]);
assert!(editor.dirty());
editor.execute("pull_requests");
let (reopened, _) = editor.take_diff_request().unwrap();
assert!(!editor.apply_diff_result(first, Ok(pull_page(false, &[], false))));
assert!(editor.apply_diff_result(reopened, Ok(pull_page(false, &[], false))));
assert!(editor.apply_diff_result(reopened, Ok(pull_page(true, &[], false))));
editor.handle(Input::Enter);
assert!(editor.take_diff_request().is_none());
}
#[test]
fn resolved_pull_request_modes_reuse_pinned_endpoints_without_resolving_again() {
let mut state = DiffState::pull_requests("origin".into());
state.take_request();
state.apply_result(
1,
Ok(pull_page(
false,
&[(1, "Open", "2026-01-01T00:00:00Z")],
true,
)),
);
state.handle(Input::Enter);
state.take_request();
let before = revision(
RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: "refs/heads/main".into(),
id: "base-sha".into(),
},
"main",
"",
Category::Open,
);
let after = revision(
RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: "refs/pull/1/head".into(),
id: "head-sha".into(),
},
"#1 Open",
"",
Category::Open,
);
state.apply_result(
2,
Ok(Outcome::ResolvedPullRequest {
before: before.clone(),
after: after.clone(),
}),
);
assert!(matches!(
state.take_request(),
Some(Operation::Compare {
mode: ComparisonMode::BranchChanges,
..
})
));
state.apply_result(3, Err("No common ancestor".into()));
assert_eq!(state.before.as_ref(), Some(&before));
assert_eq!(state.after.as_ref(), Some(&after));
state.toggle_comparison_mode().unwrap();
let Some(Operation::Compare {
before: pinned_before,
after: pinned_after,
mode,
..
}) = state.take_request()
else {
panic!("expected ordinary pinned comparison");
};
assert_eq!(
(pinned_before, pinned_after, mode),
(before, after, ComparisonMode::Snapshots)
);
}
fn editor() -> Editor {
Editor::open(None, vec![]).unwrap()
}
fn open(editor: &mut Editor) {
assert!(editor.execute("diff").is_none());
let (id, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::Discover));
assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog()))));
}
fn text(editor: &mut Editor, text: &str) {
for ch in text.chars() {
editor.handle(Input::Char(ch));
}
}
fn choose_pair(editor: &mut Editor) -> u64 {
editor.handle(Input::Down);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().phase, Phase::After);
editor.handle(Input::Enter);
let (id, operation) = editor.take_diff_request().unwrap();
match operation {
Operation::Compare { before, after, .. } => {
assert_eq!(before.target, RevisionTarget::Commit("abcdef123".into()));
assert_eq!(after.target, RevisionTarget::Working);
}
_ => panic!("expected compare"),
}
id
}
fn comparison() -> Comparison {
Comparison {
blame: Default::default(),
revisions: ["abcdef123".into(), "working:snapshot".into()],
before: "abcdef123".into(),
after: "Working checkout".into(),
root: PathBuf::new(),
editable_paths: BTreeSet::new(),
files: vec![
ChangedFile {
review_revisions: None,
stats: None,
path: "renamed.txt".into(),
old_path: Some("old.txt".into()),
status: FileStatus::Renamed,
loaded: true,
before: vec![
"same".into(),
"old".into(),
"tail".into(),
"deleted".into(),
"end".into(),
],
after: vec![
"same".into(),
"new".into(),
"extra".into(),
"tail".into(),
"end".into(),
],
before_eof: false,
after_eof: false,
hunks: vec![
Hunk {
before_start: 1,
before_len: 1,
after_start: 1,
after_len: 2,
},
Hunk {
before_start: 3,
before_len: 1,
after_start: 4,
after_len: 0,
},
],
notice: None,
},
ChangedFile {
review_revisions: None,
stats: None,
path: "added.txt".into(),
old_path: None,
status: FileStatus::Added,
loaded: true,
before: vec![],
after: vec!["a".into(), "b".into()],
before_eof: false,
after_eof: false,
hunks: vec![Hunk {
before_start: 0,
before_len: 0,
after_start: 0,
after_len: 2,
}],
notice: None,
},
ChangedFile {
review_revisions: None,
stats: None,
path: "removed.bin".into(),
old_path: None,
status: FileStatus::Removed,
loaded: true,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: Some("Binary file".into()),
},
],
}
}
#[test]
fn line_counts_use_frozen_metadata_only_until_hunks_are_loaded() {
let mut file = comparison().files.remove(0);
file.loaded = false;
assert_eq!(file.line_counts(), None);
file.stats = Some((99, 98));
assert_eq!(file.line_counts(), Some((99, 98)));
file.loaded = true;
assert_eq!(file.line_counts(), Some((2, 2)));
file.hunks.clear();
assert_eq!(file.line_counts(), Some((0, 0)));
}
#[test]
fn status_layout_cycles_only_present_panes_and_normalizes_selection() {
let mut state = live_state(vec!["old".into()], vec!["new".into()], true);
state.comparison = Some(comparison());
state.files = Some(FilePicker::from_paths(
PathBuf::new(),
[
(0, "renamed.txt".into()),
(1, "added.txt".into()),
(2, "removed.bin".into()),
],
));
for (path, layout, first, last) in [
(
"renamed.txt",
PaneLayout::Split,
Focus::Before,
Focus::After,
),
("added.txt", PaneLayout::After, Focus::After, Focus::After),
(
"removed.bin",
PaneLayout::Before,
Focus::Before,
Focus::Before,
),
] {
state.files_visible = true;
state.focus = Focus::Files;
assert!(state.select_file(Path::new(path)));
assert_eq!(state.pane_layout(), layout);
state.handle(Input::Char('\t'));
assert_eq!(state.focus, first);
state.handle(Input::BackTab);
assert_eq!(state.focus, Focus::Files);
state.handle(Input::BackTab);
assert_eq!(state.focus, last);
state.handle(Input::Char('\t'));
assert_eq!(state.focus, Focus::Files);
state.files_visible = false;
state.normalize_focus();
assert_eq!(state.focus, first);
state.handle(Input::BackTab);
assert_eq!(state.focus, last);
state.handle(Input::Char('\t'));
assert_eq!(state.focus, first);
}
state.focus = Focus::Before;
assert!(state.select_file(Path::new("added.txt")));
assert_eq!(state.focus, Focus::After);
assert!(state.select_file(Path::new("removed.bin")));
assert_eq!(state.focus, Focus::Before);
assert!(state.select_file(Path::new("renamed.txt")));
assert_eq!(state.focus, Focus::Before);
}
#[test]
fn rename_layout_and_editability_include_eof_changes() {
let mut state = live_state(vec!["same".into()], vec!["same".into()], true);
state.comparison.as_mut().unwrap().files[0].status = FileStatus::Renamed;
assert_eq!(state.pane_layout(), PaneLayout::Empty);
assert!(!state.after_editable());
state.normalize_focus();
assert_eq!(state.focus, Focus::Before);
state.files_visible = false;
for input in [Input::Char('\t'), Input::BackTab] {
state.handle(input);
assert_eq!(state.focus, Focus::Before);
}
state.comparison.as_mut().unwrap().files[0].after_eof = false;
assert_eq!(state.pane_layout(), PaneLayout::Split);
assert!(state.after_editable());
state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into();
assert!(state.after_editable());
state.comparison.as_mut().unwrap().files[0].status = FileStatus::Removed;
assert!(!state.after_editable());
state.comparison.as_mut().unwrap().files[0].status = FileStatus::Added;
assert!(state.after_editable());
let file = &mut state.comparison.as_mut().unwrap().files[0];
file.before.clear();
file.after.clear();
file.notice = Some("empty file".into());
state.normalize_focus();
assert_eq!(state.pane_layout(), PaneLayout::After);
assert_eq!(state.focus, Focus::After);
assert!(state.after_editable());
assert_eq!(
state.comparison.as_ref().unwrap().files[0]
.notice
.as_deref(),
Some("empty file")
);
}
#[test]
fn opening_diff_file_focuses_after_when_present() {
for view_mode in [ViewMode::Split, ViewMode::Unified] {
for presentation in [FilePresentation::Docked, FilePresentation::Floating] {
for status in [
FileStatus::Modified,
FileStatus::Renamed,
FileStatus::Added,
FileStatus::Removed,
] {
let mut editor = editor();
editor.set_diff_display_options(view_mode, crate::panes::SplitAxis::Vertical);
open(&mut editor);
let id = choose_pair(&mut editor);
let mut changes = comparison();
let index = match status {
FileStatus::Modified | FileStatus::Renamed => 0,
FileStatus::Added => 1,
FileStatus::Removed => 2,
};
let mut file = changes.files.remove(index);
file.status = status;
let expected_text = file.after.join("\n");
changes.files = vec![file];
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
if presentation == FilePresentation::Floating {
text(&mut editor, " o");
}
editor.handle(Input::Enter);
let diff = editor.diff().unwrap();
assert_eq!(
diff.focus,
if status == FileStatus::Removed {
Focus::Before
} else {
Focus::After
},
"{view_mode:?} {presentation:?} {status:?}",
);
assert!(!diff.files.as_ref().unwrap().focused);
assert_eq!(diff.files_visible, presentation == FilePresentation::Docked);
if status != FileStatus::Removed {
assert_eq!(editor.context().text, expected_text);
}
}
}
}
}
#[test]
fn added_file_picker_cancel_close_and_mouse_focus_use_after() {
let mut editor = editor();
open(&mut editor);
let id = choose_pair(&mut editor);
let mut changes = comparison();
changes.files = vec![changes.files.remove(1)];
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
editor.handle(Input::Escape);
assert_eq!(editor.diff().unwrap().focus, Focus::After);
text(&mut editor, " e");
editor.handle(Input::Ctrl('w'));
assert_eq!(editor.diff().unwrap().focus, Focus::After);
assert!(!editor.diff().unwrap().files_visible);
editor.scroll_diff(Focus::Before, true);
assert_eq!(editor.diff().unwrap().focus, Focus::After);
editor.handle(Input::BackTab);
assert_eq!(editor.diff().unwrap().focus, Focus::After);
}
#[test]
fn revision_matches_rank_later_contiguous_boundaries_and_tight_subsequences() {
assert_eq!(fuzzy_match("r---f---s/topic/rfs", "rfs").unwrap()[0], 2);
assert!(fuzzy_match("topic/rfs", "rfs") < fuzzy_match("topicxrfs", "rfs"));
assert!(fuzzy_match("r-f-s", "rfs") < fuzzy_match("r---f---s", "rfs"));
assert!(fuzzy_match("über-parser", "üpr").is_some());
assert!(fuzzy_match("parser", "srp").is_none());
}
#[test]
fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
for phase in [Phase::Before, Phase::After] {
for (category_index, &category) in RepoKind::Jujutsu.categories().iter().enumerate() {
let mut state = DiffState::new();
state.apply_result(
1,
Ok(Outcome::Catalog(Catalog {
kind: RepoKind::Jujutsu,
entries: vec![
revision(
RevisionTarget::Working,
"Working checkout",
"",
Category::Revisions,
),
revision(
RevisionTarget::Commit("description-target".into()),
"unrelated",
"sm/fin-1205-pilot-assignment-schema",
category,
),
revision(
RevisionTarget::Commit("immutable-fuzzy-target".into()),
"sm/fin-1205-pilot-assignment-schema",
"",
category,
),
revision(
RevisionTarget::Commit("immutable-exact-target".into()),
"f1205pas",
"",
category,
),
],
notice: None,
})),
);
state.phase = phase;
state.category = category_index;
state.query = "F1205PAS".into();
state.refilter();
assert_eq!(
state
.entries()
.skip(1)
.map(|entry| &entry.target)
.collect::<Vec<_>>(),
[
&RevisionTarget::Commit("immutable-exact-target".into()),
&RevisionTarget::Commit("immutable-fuzzy-target".into()),
&RevisionTarget::Commit("description-target".into()),
]
);
}
}
}
#[test]
fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() {
let mut editor = editor();
open(&mut editor);
text(&mut editor, "FIX 界 e\u{301}");
assert_eq!(
editor
.diff()
.unwrap()
.entries()
.map(|entry| entry.label.as_str())
.collect::<Vec<_>>(),
["Working checkout", "abcdef123"]
);
editor.handle(Input::Backspace);
assert_eq!(editor.diff().unwrap().query, "FIX 界 ");
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().category, 1);
assert_eq!(editor.diff().unwrap().entries().len(), 1);
editor.handle(Input::Ctrl('u'));
assert_eq!(editor.diff().unwrap().entries().len(), 3);
editor.handle(Input::BackTab);
assert_eq!(editor.diff().unwrap().category, 0);
editor.handle(Input::BackTab);
assert_eq!(editor.diff().unwrap().category, 2);
assert_eq!(editor.diff().unwrap().entries().len(), 2);
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().category, 0);
editor
.bind("selection", "x", Some("core.down".into()))
.unwrap();
editor.handle(Input::Char('x'));
assert_eq!(editor.diff().unwrap().selected, 1);
assert!(editor.diff().unwrap().query.is_empty());
editor.bind("selection", "ctrl+j", None).unwrap();
editor.handle(Input::Ctrl('j'));
assert_eq!(editor.diff().unwrap().selected, 1);
}
#[test]
fn selector_word_and_line_deletion_preserve_source_in_both_stages() {
for after in [false, true] {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("source text".into())])
.unwrap();
let source = editor.context().text;
let revision = editor.text_revision();
let carets = editor.carets();
open(&mut editor);
if after {
editor.handle(Input::Enter);
}
let phase = if after { Phase::After } else { Phase::Before };
text(&mut editor, "FIX missing");
assert_eq!(editor.diff().unwrap().entries().len(), 1);
editor.handle(Input::AltBackspace);
assert_eq!(editor.diff().unwrap().query, "FIX ");
assert_eq!(editor.diff().unwrap().entries().len(), 2);
editor.handle(Input::SuperBackspace);
assert!(editor.diff().unwrap().query.is_empty());
assert_eq!(
editor
.diff()
.unwrap()
.entries()
.map(|entry| entry.label.as_str())
.collect::<Vec<_>>(),
[
"Working checkout",
"abcdef123",
"fedcba321",
"Load history from origin"
]
);
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
editor.handle(input);
}
assert_eq!(editor.diff().unwrap().phase, phase);
assert_eq!(editor.context().text, source);
assert_eq!(editor.text_revision(), revision);
assert_eq!(editor.carets(), carets);
}
}
#[test]
fn loader_preserves_after_step_query_category_and_selection_then_compares() {
let mut editor = editor();
open(&mut editor);
editor.handle(Input::Down);
editor.handle(Input::Enter);
text(&mut editor, "origin");
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (id, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::FetchRemote(remote) if remote == "origin"));
assert_eq!(editor.diff().unwrap().phase, Phase::After);
assert!(editor.diff().unwrap().loading);
editor.handle(Input::Enter);
assert!(editor.take_diff_request().is_none());
let mut loaded = catalog();
loaded.entries.push(revision(
RevisionTarget::Commit("remote-commit".into()),
"remote-commit",
"origin remote history",
Category::Commits,
));
assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(loaded))));
let state = editor.diff().unwrap();
assert_eq!(state.phase, Phase::After);
assert_eq!(state.query, "origin");
assert_eq!(state.category, 0);
assert_eq!(
state.entries().nth(state.selected).unwrap().target,
RevisionTarget::FetchRemote("origin".into())
);
assert_eq!(state.entries().len(), 3);
let remote_position = state
.entries()
.position(
|entry| matches!(&entry.target, RevisionTarget::Commit(id) if id == "remote-commit"),
)
.unwrap();
editor.handle(Input::Home);
for _ in 0..remote_position {
editor.handle(Input::Down);
}
editor.handle(Input::Enter);
let (id, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::Compare { after, .. }
if after.target == RevisionTarget::Commit("remote-commit".into())));
assert!(editor.apply_diff_result(id, Err("comparison failed".into())));
assert_eq!(editor.diff().unwrap().phase, Phase::After);
assert_eq!(editor.diff().unwrap().query, "origin");
assert!(editor.diff().unwrap().error.is_some());
editor.handle(Input::Enter);
assert!(matches!(
editor.take_diff_request(),
Some((_, Operation::Compare { .. }))
));
}
#[test]
fn selector_escape_closes_each_stage_and_rejects_late_results() {
for after in [false, true] {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("unsaved source".into())])
.unwrap();
let buffer = editor.buffer_id();
open(&mut editor);
let request = if after {
Some(choose_pair(&mut editor))
} else {
text(&mut editor, "query");
None
};
editor.handle(Input::Escape);
assert!(editor.diff().is_none());
assert_eq!(editor.buffer_id(), buffer);
assert_eq!(editor.lines(), ["unsaved source"]);
assert!(editor.dirty());
if let Some(id) = request {
assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))));
assert!(editor.diff().is_none());
}
}
}
#[test]
fn selector_printable_input_precedes_normal_bindings_but_not_selection_overrides() {
for after in [false, true] {
let mut editor = editor();
open(&mut editor);
if after {
editor.handle(Input::Enter);
}
editor
.bind("normal", "x", Some("core.command".into()))
.unwrap();
for ch in ":_| x".chars() {
editor.handle(Input::Char(ch));
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.pane_layout().len(), 1);
}
assert_eq!(editor.diff().unwrap().query, ":_| x");
editor.handle(Input::Ctrl('u'));
editor
.bind("selection", ":", Some("core.down".into()))
.unwrap();
editor.handle(Input::Char(':'));
assert_eq!(editor.diff().unwrap().selected, 1);
assert!(editor.diff().unwrap().query.is_empty());
}
let mut editor = editor();
open(&mut editor);
editor.handle(Input::Ctrl('p'));
editor.handle(Input::Char(':'));
assert_eq!(editor.palette().unwrap().query, ":");
assert_eq!(editor.mode(), Mode::Normal);
editor.handle(Input::Escape);
assert!(editor.palette().is_none());
assert!(editor.diff().is_some());
}
#[test]
fn file_to_directory_change_opens_both_historical_paths() {
let mut editor = editor();
open(&mut editor);
let id = choose_pair(&mut editor);
let mut changes = comparison();
changes.files[0].path = "x".into();
changes.files[1].path = "x/y".into();
changes.files.truncate(2);
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
let picker = editor.diff().unwrap().files.as_ref().unwrap();
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().file, 0);
text(&mut editor, " e");
editor.handle(Input::Char('i'));
text(&mut editor, "x/y");
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().file, 1);
assert_eq!(editor.diff().unwrap().focus, Focus::After);
}
#[test]
fn changed_tree_filters_navigates_and_opens_without_touching_buffers() {
let mut editor = editor();
let original = editor.context().text;
let carets = editor.carets();
let revision = editor.text_revision();
open(&mut editor);
let id = choose_pair(&mut editor);
let mut changes = comparison();
changes.files[0].path = "src/deep/renamed.txt".into();
changes.files[1].path = "src/deep/added.txt".into();
changes.files[2].path = "archive/gone/removed.bin".into();
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
let picker = editor.diff().unwrap().files.as_ref().unwrap();
assert!(picker.focused && !picker.searching);
assert_eq!(
picker.entries().nth(picker.selected).unwrap().path,
std::path::Path::new("src/deep/renamed.txt")
);
assert!(picker.entries().any(|row| row.label == "src/deep"));
editor.bind("tree", "z", Some("core.left".into())).unwrap();
editor.handle(Input::Char('z'));
let picker = editor.diff().unwrap().files.as_ref().unwrap();
assert!(picker.entries().nth(picker.selected).unwrap().is_dir);
editor.handle(Input::Enter);
let picker = editor.diff().unwrap().files.as_ref().unwrap();
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::Char('l'));
editor.handle(Input::Char('l'));
editor
.bind("selection", "ctrl+n", Some("core.down".into()))
.unwrap();
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::Char('i'));
text(&mut editor, "removed.bin");
editor.handle(Input::Enter);
let state = editor.diff().unwrap();
assert_eq!(state.file, 2);
assert_eq!(state.focus, Focus::Before);
assert!(!state.files.as_ref().unwrap().focused);
assert!(state.rows.is_empty());
editor.handle(Input::BackTab);
editor.handle(Input::Escape);
assert!(!editor.diff().unwrap().files.as_ref().unwrap().searching);
editor.handle(Input::Ctrl('u'));
assert!(
editor
.diff()
.unwrap()
.files
.as_ref()
.unwrap()
.query
.is_empty()
);
editor.handle(Input::Ctrl('t'));
assert_eq!(
editor.diff().unwrap().files.as_ref().unwrap().view,
FileView::List
);
editor.handle(Input::Ctrl('t'));
assert_eq!(
editor.diff().unwrap().files.as_ref().unwrap().view,
FileView::Tree
);
editor.handle(Input::Ctrl('r'));
editor.handle(Input::Char('i'));
text(&mut editor, "removed.bin");
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
assert_eq!(editor.context().text, original);
assert_eq!(editor.carets(), carets);
assert_eq!(editor.text_revision(), revision);
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
assert!(editor.diff().is_none());
}
#[test]
fn explorer_actions_preserve_review_and_ctrl_e_uses_normal_bindings() {
let mut editor = editor();
open(&mut editor);
let id = choose_pair(&mut editor);
editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())));
editor
.bind("tree", "ctrl+e", Some("core.down".into()))
.unwrap();
editor.handle(Input::Ctrl('e'));
assert!(editor.diff().unwrap().files_visible);
assert_eq!(
editor
.diff()
.unwrap()
.files
.as_ref()
.unwrap()
.entries()
.nth(editor.diff().unwrap().files.as_ref().unwrap().selected)
.unwrap()
.path,
Path::new("added.txt")
);
editor.handle(Input::Ctrl('w'));
assert!(editor.diff().is_some());
assert!(!editor.diff().unwrap().files_visible);
text(&mut editor, " e");
assert!(editor.diff().unwrap().files_visible);
assert_eq!(editor.diff().unwrap().focus, Focus::Files);
assert_eq!(
editor.diff().unwrap().files.as_ref().unwrap().presentation,
crate::files::FilePresentation::Docked
);
editor.handle(Input::Ctrl('w'));
text(&mut editor, " o");
assert_eq!(
editor.diff().unwrap().files.as_ref().unwrap().presentation,
crate::files::FilePresentation::Floating
);
editor.handle(Input::Escape);
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
assert!(editor.diff().is_none());
}
#[test]
fn explorer_toggle_is_not_a_revision_selector_control() {
let mut editor = editor();
open(&mut editor);
editor.handle(Input::Ctrl('e'));
assert_eq!(editor.diff().unwrap().selected, 0);
editor
.bind("selection", "ctrl+e", Some("core.down".into()))
.unwrap();
for phase in [Phase::Before, Phase::After] {
assert_eq!(editor.diff().unwrap().phase, phase);
editor.handle(Input::Ctrl('e'));
let state = editor.diff().unwrap();
assert!(state.files_visible);
assert_eq!(state.selected, 1);
assert_eq!(state.phase, phase);
editor.handle(Input::Enter);
}
}
#[test]
fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() {
let mut editor = editor();
open(&mut editor);
let id = choose_pair(&mut editor);
assert!(editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))));
assert_eq!(
editor
.diff()
.unwrap()
.rows
.iter()
.map(|row| (row.before, row.after, row.kind))
.collect::<Vec<_>>(),
[
(Some(0), Some(0), RowKind::Equal),
(Some(1), Some(1), RowKind::Changed),
(None, Some(2), RowKind::Added),
(Some(2), Some(3), RowKind::Equal),
(Some(3), None, RowKind::Removed),
(Some(4), Some(4), RowKind::Equal),
]
);
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
editor.handle(Input::Down);
editor.handle(Input::Right);
assert_eq!(editor.cursor().row, 1);
assert_eq!(editor.cursor().column, 1);
editor.scroll_diff(Focus::Before, true);
editor.set_diff_viewport(id, 0, 1, 1, 0);
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().focus, Focus::After);
editor.handle(Input::Down);
editor.handle(Input::Right);
assert_eq!(editor.cursor().row, 2);
assert_eq!(editor.diff().unwrap().top, 1);
editor.set_diff_viewport(id, 0, 2, 1, 1);
assert_eq!(editor.diff().unwrap().left_after, 1);
assert_eq!(editor.diff().unwrap().left_before, 1);
editor.handle(Input::End);
assert_eq!(editor.diff().unwrap().top, 2);
editor.handle(Input::BackTab);
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
editor.handle(Input::BackTab);
editor.handle(Input::Down);
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::Enter);
let state = editor.diff().unwrap();
assert_eq!(state.file, 1);
assert_eq!((state.top, state.left_before, state.left_after), (0, 0, 0));
assert!(
state
.rows
.iter()
.all(|row| row.before.is_none() && row.kind == RowKind::Added)
);
editor.handle(Input::BackTab);
editor.handle(Input::Down);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().file, 2);
assert!(editor.diff().unwrap().rows.is_empty());
assert!(
editor.diff().unwrap().comparison.as_ref().unwrap().files[2]
.notice
.is_some()
);
editor.handle(Input::BackTab);
editor.handle(Input::Down);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().file, 0);
}
#[test]
fn comparison_identity_comes_from_accepted_requests_not_working_labels() {
let mut editor = editor();
open(&mut editor);
let first = choose_pair(&mut editor);
assert!(editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison()))));
assert_eq!(editor.diff().unwrap().comparison_id, Some(first));
editor.execute("diff.close");
open(&mut editor);
assert_eq!(editor.diff().unwrap().comparison_id, None);
let second = choose_pair(&mut editor);
assert!(second > first);
assert!(!editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison()))));
assert_eq!(editor.diff().unwrap().comparison_id, None);
assert!(editor.apply_diff_result(second, Ok(Outcome::Comparison(comparison()))));
assert_eq!(editor.diff().unwrap().comparison_id, Some(second));
}
#[test]
fn closing_diff_rejects_pending_file_details_even_after_reopening() {
let mut editor = editor();
open(&mut editor);
let comparison_id = choose_pair(&mut editor);
let mut catalog = comparison();
let detail = catalog.files.remove(0);
catalog.files.clear();
catalog.files.push(ChangedFile {
review_revisions: None,
stats: None,
path: detail.path.clone(),
old_path: detail.old_path.clone(),
status: detail.status,
loaded: false,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
});
editor.apply_diff_result(comparison_id, Ok(Outcome::Comparison(catalog)));
let (file_request, _) = editor.take_diff_request().unwrap();
editor.execute("diff.close");
assert!(!editor.apply_diff_result(
file_request,
Ok(Outcome::File {
comparison_id,
file_index: 0,
file: detail,
})
));
assert!(editor.diff().is_none());
open(&mut editor);
assert!(editor.diff().unwrap().comparison.is_none());
}
#[test]
fn explicit_remote_ref_loader_preserves_selector_position_without_choosing_revision() {
let mut state = DiffState::new();
let mut initial = catalog();
let loader = initial
.entries
.iter_mut()
.find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_)))
.unwrap();
loader.target = RevisionTarget::ListRemote("origin".into());
loader.label = "Load refs from origin".into();
state.apply_result(1, Ok(Outcome::Catalog(initial)));
state.phase = Phase::After;
state.query = "origin".into();
state.refilter();
state.selected = 1;
state.choose();
assert!(
matches!(state.take_request(), Some(Operation::ListRemote(remote)) if remote == "origin")
);
let mut loaded = catalog();
let loader = loaded
.entries
.iter_mut()
.find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_)))
.unwrap();
loader.target = RevisionTarget::ListRemote("origin".into());
loader.label = "Load refs from origin".into();
loaded.entries.push(revision(
RevisionTarget::Remote {
remote: "origin".into(),
reference: "refs/heads/new".into(),
},
"new@origin",
"",
Category::Branches,
));
state.apply_result(2, Ok(Outcome::Catalog(loaded)));
assert_eq!(state.phase, Phase::After);
assert_eq!(state.query, "origin");
assert_eq!(state.category, 0);
assert_eq!(state.selected, 1);
assert!(state.after.is_none());
assert!(!state.loading);
state.category = 1;
state.refilter();
assert!(
state
.entries()
.any(|entry| matches!(entry.target, RevisionTarget::Remote { .. }))
);
}
#[test]
fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() {
let mut editor = editor();
editor.execute("diff");
let (old, _) = editor.take_diff_request().unwrap();
editor.handle(Input::Escape);
assert!(!editor.apply_diff_result(old, Ok(Outcome::Catalog(catalog()))));
editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap();
assert!(id > old);
assert!(!editor.apply_diff_result(old, Err("stale".into())));
assert!(editor.diff().unwrap().loading);
assert!(editor.apply_diff_result(id, Err("discovery failed".into())));
assert!(editor.diff().unwrap().error.is_some());
editor.handle(Input::Enter);
let (retry, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::Discover));
assert!(retry > id);
assert!(editor.apply_diff_result(retry, Ok(Outcome::Catalog(catalog()))));
text(&mut editor, "origin");
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (fetch, _) = editor.take_diff_request().unwrap();
assert!(editor.apply_diff_result(fetch, Err("fetch failed".into())));
assert_eq!(editor.diff().unwrap().entries().len(), 2);
assert_eq!(editor.diff().unwrap().query, "origin");
editor.handle(Input::Ctrl('u'));
editor.handle(Input::Home);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().phase, Phase::After);
}
#[test]
fn immutable_diff_and_selectors_preserve_dirty_buffers_carets_and_undo() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("original\\nsecond".into())])
.unwrap();
editor.handle(Input::Char('i'));
text(&mut editor, "unsaved");
editor.handle(Input::Escape);
editor.handle(Input::Right);
let original = editor.context().text;
let carets = editor.carets();
let revision = editor.text_revision();
let buffers = editor
.buffers()
.map(|buffer| (buffer.id, buffer.dirty))
.collect::<Vec<_>>();
editor
.bind("normal", "x", Some("core.diff".into()))
.unwrap();
assert!(editor.handle(Input::Char('x')).is_none());
let (id, _) = editor.take_diff_request().unwrap();
assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog()))));
for input in [
Input::Char('i'),
Input::Char('d'),
Input::Delete,
Input::Paste("bad\\n:q!".into()),
] {
assert!(editor.handle(input).is_none());
}
assert!(editor.execute("bdelete!").is_none());
assert!(editor.apply(vec![Effect::Replace("bad".into())]).is_err());
assert_eq!(editor.context().text, original);
assert_eq!(editor.carets(), carets);
assert_eq!(editor.text_revision(), revision);
assert_eq!(
editor
.buffers()
.map(|buffer| (buffer.id, buffer.dirty))
.collect::<Vec<_>>(),
buffers
);
editor.handle(Input::Escape);
assert!(editor.diff().is_none());
assert_eq!(editor.mode(), Mode::Normal);
open(&mut editor);
let id = choose_pair(&mut editor);
editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())));
for input in [
Input::Char('i'),
Input::Char('u'),
Input::Char('d'),
Input::Paste("bad".into()),
Input::Delete,
Input::Backspace,
Input::Enter,
] {
editor.handle(input);
}
editor.handle(Input::Escape);
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
assert_eq!(editor.context().text, original);
assert_eq!(editor.carets(), carets);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "original\\nsecond");
}
#[test]
fn jujutsu_categories_empty_comparison_and_command_mode_entry_are_complete() {
let mut editor = editor();
editor.handle(Input::Char(':'));
text(&mut editor, "diff");
editor.handle(Input::Enter);
assert_eq!(editor.mode(), Mode::Normal);
let (id, _) = editor.take_diff_request().unwrap();
let mut catalog = catalog();
catalog.kind = RepoKind::Jujutsu;
catalog.entries[0].category = Category::Revisions;
catalog.entries[1].category = Category::Revisions;
catalog.entries[4].category = Category::Bookmarks;
assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog))));
assert_eq!(editor.diff().unwrap().entries().len(), 2);
editor.handle(Input::Char('\t'));
assert_eq!(
editor.diff().unwrap().entries().nth(1).unwrap().category,
Category::Bookmarks
);
editor.handle(Input::Home);
editor.handle(Input::Enter);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
assert!(editor.apply_diff_result(
id,
Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["working:snapshot".into(), "working:snapshot".into()],
before: "Working".into(),
after: "Working".into(),
files: vec![],
root: PathBuf::new(),
editable_paths: BTreeSet::new(),
}))
));
assert_eq!(editor.diff().unwrap().phase, Phase::View);
assert!(editor.diff().unwrap().rows.is_empty());
for input in [Input::Down, Input::End, Input::Up, Input::Right, Input::End] {
editor.handle(input);
}
assert_eq!(editor.diff().unwrap().top, 0);
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
assert!(editor.diff().is_none());
}
fn live_state(before: Vec<String>, after: Vec<String>, eof: bool) -> DiffState {
let mut state = DiffState::new();
state.phase = Phase::View;
state.focus = Focus::After;
state.after = Some(revision(
RevisionTarget::Working,
"Working",
"",
Category::Commits,
));
state.comparison = Some(Comparison {
blame: Default::default(),
revisions: ["base".into(), "working:snapshot".into()],
root: PathBuf::new(),
before: "base".into(),
after: "Working".into(),
editable_paths: BTreeSet::from([PathBuf::from("file")]),
files: vec![ChangedFile {
review_revisions: None,
stats: None,
path: "file".into(),
old_path: None,
status: FileStatus::Modified,
hunks: line_hunks(&before, eof, &after, eof),
before,
after,
before_eof: eof,
after_eof: eof,
notice: None,
loaded: true,
}],
});
state.files = Some(FilePicker::from_paths(
PathBuf::new(),
[(0, PathBuf::from("file"))],
));
state.align_file();
state
}
#[test]
fn column_view_preserves_split_gaps_unified_sides_and_folded_coordinates() {
let mut state = live_state(
["head", "tail"].map(str::to_owned).to_vec(),
["head", "added", "tail"].map(str::to_owned).to_vec(),
false,
);
let before = state.column_view(Focus::Before).unwrap();
assert!(matches!(before.row(1), Some(ColumnRow::Gap)));
assert_eq!(before.native_row(1), None);
assert_eq!(before.display_row(1), Some(2));
state.set_view_mode(ViewMode::Unified);
let before = state.column_view(Focus::Before).unwrap();
let ColumnRow::Text(line) = before.row(1).unwrap() else {
panic!("unified insertion must paint the after source");
};
assert_eq!(
(line.text, line.side, line.source_row),
(Some("added"), Focus::After, 1)
);
assert_eq!(line.numbers, [None, Some(1)]);
assert!(line.changed);
assert_eq!(before.native_row(1), None);
assert_eq!(before.review_row(Focus::Before, 1), None);
assert_eq!(before.review_row(Focus::After, 1), Some(1));
let ColumnRow::Text(line) = before.row(2).unwrap() else {
panic!("equal context must contain text");
};
assert_eq!(line.numbers, [Some(1), Some(2)]);
assert_eq!(before.native_row(2), Some(1));
assert_eq!(before.review_row(Focus::Before, 2), Some(1));
assert_eq!(before.review_row(Focus::After, 2), Some(2));
assert_eq!(before.display_row(1), Some(2));
for invalid in [before.info.display_len, usize::MAX] {
assert!(before.row(invalid).is_none());
assert_eq!(before.native_row(invalid), None);
assert_eq!(before.review_row(Focus::After, invalid), None);
}
assert_eq!(before.display_row(usize::MAX), None);
let lines: Vec<_> = (0..30).map(|row| format!("line {row}")).collect();
let mut changed = lines.clone();
changed[15] = "changed".into();
let mut state = live_state(lines, changed, false);
state.set_context_options(0, 1);
for mode in [ViewMode::Split, ViewMode::Unified] {
state.set_view_mode(mode);
let view = state.column_view(Focus::After).unwrap();
assert!(matches!(view.row(0), Some(ColumnRow::Fold { hidden: 15 })));
assert_eq!(view.native_row(0), None);
assert_eq!(view.review_row(Focus::After, 0), None);
assert_eq!(view.display_row(0), None);
let display = view.display_row(15).unwrap();
let ColumnRow::Text(line) = view.row(display).unwrap() else {
panic!("visible changed source must contain text");
};
assert_eq!((line.text, line.source_row), (Some("changed"), 15));
assert_eq!(view.native_row(display), Some(15));
}
}
#[test]
fn column_view_distinguishes_native_terminal_rows_from_missing_ordinary_text() {
for text in ["", "same\n"] {
let lines: Vec<_> = text.split('\n').map(str::to_owned).collect();
let mut state = DiffState::new();
state.set_resolver_text(&lines, &lines);
for focus in [Focus::Before, Focus::After] {
let view = state.column_view(focus).unwrap();
let native = lines.len() - 1;
let display = view.display_row(native).unwrap();
let ColumnRow::Text(line) = view.row(display).unwrap() else {
panic!("native terminal row must contain text");
};
assert_eq!((line.text, line.source_row), (Some(""), native));
assert_eq!(line.newline, text.ends_with('\n'));
assert_eq!(view.native_row(display), Some(native));
assert_eq!(view.info.source_extents, [lines.len(); 2]);
assert_eq!(view.info.stored_lengths, [lines.len() - 1; 2]);
}
}
let mut state = live_state(vec!["old".into()], vec!["new".into()], false);
// A mapped but unavailable ordinary source is not a synthetic terminal row.
state.comparison.as_mut().unwrap().files[0].after.clear();
let view = state.column_view(Focus::After).unwrap();
let ColumnRow::Text(line) = view.row(0).unwrap() else {
panic!("missing source text must retain its mapped row");
};
assert_eq!(line.text, None);
assert_eq!(line.source_row, 0);
}
#[test]
fn column_view_suppresses_whole_file_emphasis_without_losing_change_markers() {
let mut state = live_state(vec!["abc".into()], vec!["aXYbc".into()], false);
for (focus, expected) in [(Focus::Before, 1..1), (Focus::After, 1..3)] {
let view = state.column_view(focus).unwrap();
let ColumnRow::Text(line) = view.row(0).unwrap() else {
panic!("changed row must contain text");
};
assert!(line.changed && line.emphasize);
assert_eq!(line.changes().cloned().collect::<Vec<_>>(), [expected]);
}
for status in [FileStatus::Added, FileStatus::Removed] {
state.comparison.as_mut().unwrap().files[0].status = status;
for focus in [Focus::Before, Focus::After] {
let view = state.column_view(focus).unwrap();
let ColumnRow::Text(line) = view.row(0).unwrap() else {
panic!("whole-file status must retain source text");
};
assert!(line.changed);
assert!(!line.emphasize);
assert_eq!(line.changes().count(), 0);
}
}
}
#[test]
fn working_refresh_preserves_noops_errors_and_view_state_but_invalidates_old_identity() {
let mut state = live_state(vec!["old".into()], vec!["new".into()], false);
state.loading = false;
state.pending = None;
state.request_kind = None;
state.comparison_id = Some(7);
state.word_wrap = true;
state.view_mode = ViewMode::Unified;
state.comparison_mode = ComparisonMode::Snapshots;
state.split_axis = crate::panes::SplitAxis::Horizontal;
state.files_visible = false;
state.reviewed_files.insert(0);
state.error = Some("previous notice".into());
let files = state.files.as_mut().unwrap();
files.set_view(FileView::Tree);
files.start_search();
files.query = "file".into();
files.refilter();
state.queue_checkout_refresh(true);
assert!(matches!(
state.take_request(),
Some(Operation::RefreshCheckout {
comparison_id: 7,
..
})
));
assert!(state.apply_result(8, Ok(Outcome::CheckoutRefresh(None))));
assert!(state.error.is_none());
assert_eq!(state.comparison_id, Some(7));
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
state.queue_checkout_refresh(true);
state.take_request();
assert!(state.apply_result(9, Err("refresh failed".into())));
assert_eq!(state.comparison_id, Some(7));
assert_eq!(state.comparison.as_ref().unwrap().files[0].after, ["new"]);
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
assert_eq!(state.error.as_deref(), Some("refresh failed"));
state.queue_checkout_refresh(true);
state.take_request();
let mut replacement = live_state(vec!["old".into()], vec!["ahead".into()], false)
.comparison
.take()
.unwrap();
replacement.files.insert(0, comparison().files.remove(0));
assert!(state.apply_result(10, Ok(Outcome::CheckoutRefresh(Some(replacement)))));
assert_eq!(state.comparison_id, Some(10));
assert_eq!(state.file, 1);
assert_eq!(state.focus, Focus::After);
assert!(state.word_wrap && !state.files_visible);
assert_eq!(state.split_axis, crate::panes::SplitAxis::Horizontal);
assert_eq!(state.view_mode, ViewMode::Unified);
assert_eq!(state.comparison_mode, ComparisonMode::Snapshots);
assert!(state.reviewed_files.is_empty());
let files = state.files.as_ref().unwrap();
assert_eq!(files.query, "file");
assert_eq!(files.view, FileView::Tree);
assert!(files.searching);
assert_eq!(
files.entries().nth(files.selected).unwrap().path,
Path::new("file")
);
assert!(!state.apply_result(7, Ok(Outcome::CheckoutRefresh(None))));
assert_eq!(state.comparison_id, Some(10));
assert_eq!(state.file, 1);
state.after.as_mut().unwrap().target = RevisionTarget::Commit("historical".into());
state.queue_checkout_refresh(false);
assert!(state.request_queued());
assert!(matches!(
state.take_request(),
Some(Operation::RefreshCheckout {
follow_working: false,
..
})
));
assert!(!state.apply_result(11, Ok(Outcome::CheckoutRefresh(None))));
state.reviewed_files.insert(1);
state.queue_checkout_refresh(false);
state.take_request();
let mut demoted = live_state(vec!["old".into()], vec!["ahead".into()], false)
.comparison
.take()
.unwrap();
demoted.files.insert(0, comparison().files.remove(0));
demoted.editable_paths.clear();
assert!(state.apply_result(12, Ok(Outcome::CheckoutRefresh(Some(demoted)))));
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
assert!(!state.live_after_available());
state
.comparison
.as_mut()
.unwrap()
.editable_paths
.insert("file".into());
state.refresh_after(&["dirty".into()], 1);
assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["dirty"]);
state.queue_checkout_refresh(false);
state.take_request();
assert!(state.apply_result(13, Err("checkout unavailable".into())));
assert!(!state.live_after_available());
assert_eq!(state.comparison_id, Some(12));
assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["ahead"]);
state.after.as_mut().unwrap().target = RevisionTarget::Working;
state.queue_checkout_refresh(false);
assert!(!state.request_queued());
state.load_file(0);
state.queue_checkout_refresh(true);
assert!(matches!(
state.pending,
Some(Operation::LoadFile { file_index: 0, .. })
));
}
#[test]
fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows() {
let mut state = DiffState::new();
let mut changes = comparison();
changes.files[0].path = "nested/z.txt".into();
changes.files[1].path = "nested/a.txt".into();
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(1, Ok(Outcome::Comparison(changes)));
for view in [FileView::List, FileView::Tree] {
let files = state.files.as_mut().unwrap();
files.set_view(view);
files.clear_filter();
files.start_search();
for ch in "nested/a".chars() {
files.handle_filter_input(&Input::Char(ch));
}
files.reveal_path(Path::new("nested/a.txt"));
assert_eq!(state.file, 0);
assert_eq!(state.toggle_reviewed(), Ok(true));
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
assert_eq!(state.toggle_reviewed(), Ok(false));
assert!(state.reviewed_files.is_empty());
}
let files = state.files.as_mut().unwrap();
files.clear_filter();
files.finish_search();
let selected = files.entries().position(|entry| entry.is_dir).unwrap();
files.selected = selected;
assert_eq!(
state.toggle_reviewed(),
Err("Select a changed file, not a directory")
);
assert!(state.reviewed_files.is_empty());
let files = state.files.as_mut().unwrap();
files.start_search();
for ch in "missing".chars() {
files.handle_filter_input(&Input::Char(ch));
}
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
state.files = Some(FilePicker::from_paths(
PathBuf::new(),
[(0, PathBuf::from("ordinary.txt"))],
));
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
state.focus = Focus::Before;
assert_eq!(state.toggle_reviewed(), Ok(true));
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
assert!(state.select_file_index(2));
state.focus = Focus::Before;
assert_eq!(state.toggle_reviewed(), Ok(true));
assert_eq!(state.reviewed_files, BTreeSet::from([0, 2]));
}
#[test]
fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() {
let mut state = DiffState::new();
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
let mut changes = comparison();
changes.files[0].loaded = false;
changes.files[0].before.clear();
changes.files[0].after.clear();
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(1, Ok(Outcome::Comparison(changes)));
assert_eq!(state.toggle_reviewed(), Ok(true));
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
let file = comparison().files.remove(0);
state.apply_result(
2,
Ok(Outcome::File {
comparison_id: 1,
file_index: 0,
file,
}),
);
assert!(state.selected_file_loaded());
state.set_view_mode(ViewMode::Unified);
state.set_view_mode(ViewMode::Split);
state.split_axis = crate::panes::SplitAxis::Horizontal;
assert!(state.select_file_index(1));
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
state.before = Some(revision(
RevisionTarget::Commit("base".into()),
"base",
"",
Category::Commits,
));
state.after = Some(revision(
RevisionTarget::Commit("head".into()),
"head",
"",
Category::Commits,
));
state.toggle_comparison_mode().unwrap();
assert!(state.reviewed_files.is_empty());
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
state.apply_result(3, Ok(Outcome::Comparison(comparison())));
assert!(state.reviewed_files.is_empty());
assert_eq!(state.toggle_reviewed(), Ok(true));
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(4, Ok(Outcome::Comparison(comparison())));
assert!(state.reviewed_files.is_empty());
}
#[test]
fn restored_review_marks_use_file_identities_and_rename_paths_not_catalog_order() {
let mut state = DiffState::new();
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(1, Ok(Outcome::Comparison(comparison())));
state.comparison.as_mut().unwrap().files[0].review_revisions =
Some(["file:before".into(), "file:after".into()]);
assert_eq!(state.toggle_reviewed(), Ok(true));
state.comparison.as_mut().unwrap().files.swap(0, 1);
state.restore_reviewed_files();
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
state.comparison.as_mut().unwrap().files[1].old_path = Some("different.txt".into());
state.restore_reviewed_files();
assert!(state.reviewed_files.is_empty());
state.comparison.as_mut().unwrap().files[1].old_path = Some("old.txt".into());
for side in [0, 1] {
let identities = state.comparison.as_mut().unwrap().files[1]
.review_revisions
.as_mut()
.unwrap();
let original = identities[side].clone();
identities[side].push_str("-different");
state.restore_reviewed_files();
assert!(state.reviewed_files.is_empty());
state.comparison.as_mut().unwrap().files[1]
.review_revisions
.as_mut()
.unwrap()[side] = original;
}
state.comparison.as_mut().unwrap().files[1].loaded = false;
state.restore_reviewed_files();
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
}
#[test]
fn unchanged_file_review_survives_other_files_changing() {
use crate::repository::Repository;
use std::sync::{Arc, atomic::AtomicBool};
let root = std::env::temp_dir().join(format!("vamp-review-files-{}", std::process::id()));
std::fs::create_dir(&root).unwrap();
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.current_dir(&root)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
};
git(&["init", "--initial-branch=local"]);
git(&["config", "user.name", "Review Test"]);
git(&["config", "user.email", "review@example.invalid"]);
for path in ["a.txt", "b.txt"] {
std::fs::write(root.join(path), "base\n").unwrap();
}
git(&["add", "."]);
git(&["commit", "-m", "base"]);
for path in ["a.txt", "b.txt"] {
std::fs::write(root.join(path), "changed\n").unwrap();
}
let before = revision(
RevisionTarget::Commit(git(&["rev-parse", "HEAD"])),
"base",
"",
Category::Commits,
);
let after = revision(RevisionTarget::Working, "Working", "", Category::Commits);
let mut repository = Repository::open(&root, Arc::new(AtomicBool::new(false))).unwrap();
let mut state = DiffState::new();
let changes = repository
.compare(1, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap();
let original_revisions = changes.revisions.clone();
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(1, Ok(Outcome::Comparison(changes)));
state.toggle_reviewed().unwrap();
state.file = 1;
state.focus = Focus::Before;
state.toggle_reviewed().unwrap();
let mut store = crate::review::Store::open(&root.join("reviews")).unwrap();
store.save(&mut state.local_review.document).unwrap();
let id = state.local_review.document.storage_id.unwrap();
std::fs::write(root.join("b.txt"), "edited again\n").unwrap();
let changes = repository
.compare(2, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap();
assert_ne!(changes.revisions, original_revisions);
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(2, Ok(Outcome::Comparison(changes)));
state.local_review.document = store.load(&root, id).unwrap();
state.restore_reviewed_files();
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
let detail = repository.load_file(2, 0).unwrap();
state.apply_result(
3,
Ok(Outcome::File {
comparison_id: 2,
file_index: 0,
file: detail,
}),
);
state.restore_reviewed_files();
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn restored_live_review_marks_require_exact_text_and_final_newline() {
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
state.refresh_after(&["unsaved".into(), String::new()], 1);
assert_eq!(state.toggle_reviewed(), Ok(true));
let document = state.local_review.document.clone();
let mut reopened = live_state(vec!["base".into()], vec!["changed".into()], false);
reopened.local_review.document = document.clone();
reopened.restore_reviewed_files();
assert!(reopened.reviewed_files.is_empty());
reopened.refresh_after(&["unsaved".into(), String::new()], 1);
assert_eq!(reopened.reviewed_files, BTreeSet::from([0]));
reopened.refresh_after(&["unsaved".into()], 2);
assert!(reopened.reviewed_files.is_empty());
assert!(reopened.local_review.document.reviewed_files.is_empty());
reopened.local_review.document = document;
reopened.restore_reviewed_files();
assert!(reopened.reviewed_files.is_empty());
}
#[test]
fn working_review_marks_clear_only_for_changed_bytes_or_eof() {
for (after, eof, lines) in [
(vec!["changed".into()], false, vec!["changed".into()]),
(
vec!["changed".into()],
true,
vec!["changed".into(), String::new()],
),
(Vec::new(), false, vec![String::new()]),
] {
let mut state = live_state(vec!["base".into()], after, eof);
assert_eq!(state.toggle_reviewed(), Ok(true));
state.refresh_after(&lines, 1);
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
state.refresh_after(&lines, 2);
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
state.refresh_after(&["edited".into()], 3);
assert!(state.reviewed_files.is_empty());
assert_eq!(state.toggle_reviewed(), Ok(true));
state.refresh_after(&["edited".into(), String::new()], 4);
assert!(state.reviewed_files.is_empty());
assert_eq!(state.toggle_reviewed(), Ok(true));
state.refresh_after(&["edited".into()], 5);
assert!(state.reviewed_files.is_empty());
}
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
state.comparison.as_mut().unwrap().files[0].loaded = false;
assert_eq!(state.toggle_reviewed(), Ok(true));
state.refresh_after(&["edited".into()], 1);
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
}
#[test]
fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() {
let mut state = live_state(
["head", "old one", "old two", "tail"]
.map(str::to_owned)
.to_vec(),
["head", "new one", "new two", "extra", "tail"]
.map(str::to_owned)
.to_vec(),
false,
);
assert_eq!(state.view_mode, ViewMode::Split);
assert_eq!(state.split_axis, crate::panes::SplitAxis::Vertical);
state.top = state.after_display_row(2).unwrap();
let excerpts = state.excerpt_ranges.clone();
state.set_view_mode(ViewMode::Unified);
let rows: Vec<_> = (0..state.display_rows.len())
.map(|display| {
(
state.display_focus(display),
state.display_source_row(Focus::Before, display),
state.display_source_row(Focus::After, display),
)
})
.collect();
assert_eq!(
rows,
[
(None, Some(0), Some(0)),
(Some(Focus::Before), Some(1), None),
(Some(Focus::Before), Some(2), None),
(Some(Focus::After), None, Some(1)),
(Some(Focus::After), None, Some(2)),
(Some(Focus::After), None, Some(3)),
(None, Some(3), Some(4)),
]
);
assert_eq!(state.top, state.after_display_row(2).unwrap());
assert_eq!(
state.corresponding_row(Focus::Before, Focus::After, 2),
Some(2)
);
assert!(state.after_editable());
for focus in [Focus::Before, Focus::After] {
for source in state.source_rows_in(focus, 0, usize::MAX) {
let display = state.source_display_row(focus, source).unwrap();
assert_eq!(state.display_source_row(focus, display), Some(source));
}
}
state.set_view_mode(ViewMode::Split);
assert_eq!(state.excerpt_ranges, excerpts);
assert_eq!(state.focus, Focus::After);
assert_eq!(state.top, state.after_display_row(2).unwrap());
assert_eq!(state.display_rows.len(), 5);
}
#[test]
fn unified_live_reflow_preserves_removed_top_and_expanded_source_excerpts() {
let base: Vec<_> = (0..40).map(|row| format!("line {row}")).collect();
let mut lines = base.clone();
lines[20] = "new line".into();
let mut state = live_state(base, lines.clone(), false);
state.set_context_options(1, 3);
state.refresh_after(&lines, 1);
state.set_view_mode(ViewMode::Unified);
state.top = state.source_display_row(Focus::Before, 20).unwrap();
state.expand_source_excerpt(Focus::After, false, 20);
assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20));
assert!(state.source_display_row(Focus::After, 16).is_some());
lines.insert(0, "inserted".into());
state.refresh_after(&lines, 2);
assert_eq!(state.display_focus(state.top), Some(Focus::Before));
assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20));
assert!(state.source_display_row(Focus::After, 17).is_some());
state.reveal_source_row(Focus::After, 35);
let visible: Vec<_> = state.source_rows_in(Focus::After, 0, usize::MAX).collect();
state.set_view_mode(ViewMode::Split);
state.set_view_mode(ViewMode::Unified);
assert_eq!(
state
.source_rows_in(Focus::After, 0, usize::MAX)
.collect::<Vec<_>>(),
visible
);
assert_eq!(state.display_source_row(Focus::After, state.top), Some(35));
}
#[test]
fn character_ranges_follow_insertions_deletions_unicode_and_live_edits() {
for (before, after, expected) in [
("ab", "a界b", vec![[1..1, 1..2]]),
("a界b", "ab", vec![[1..2, 1..1]]),
("xe\u{301}👩‍🚀z", "xe\u{300}👩‍🔬z", vec![[1..3, 1..3]]),
] {
let state = live_state(vec![before.into()], vec![after.into()], false);
assert_eq!(state.rows[0].changes, expected);
}
let mut state = live_state(vec!["abc".into()], vec!["aXbc".into()], false);
state.refresh_after(&["aXYbc".into()], 1);
assert_eq!(state.rows[0].changes, vec![[1..1, 1..3]]);
state.refresh_after(&["abYZc".into()], 2);
assert_eq!(state.rows[0].changes, vec![[2..2, 2..4]]);
state.refresh_after(&["abc".into()], 3);
assert_eq!(state.rows[0].kind, RowKind::Equal);
assert!(state.rows[0].changes.is_empty());
}
#[test]
fn character_highlights_merge_incidental_matches_but_preserve_meaningful_gaps() {
let mut cases = vec![
("a..c".into(), "b..d".into(), vec![[0..4, 0..4]]),
("base".into(), "edited".into(), vec![[0..4, 0..6]]),
(
"a...c".into(),
"b...d".into(),
vec![[0..1, 0..1], [4..5, 4..5]],
),
(
"a界e\u{301}c".into(),
"b界e\u{301}d".into(),
vec![[0..4, 0..4]],
),
];
for (size, gap, merge) in [
(60, " = ".to_owned(), false),
(70, " = ".to_owned(), true),
(70, "x".repeat(20), true),
(70, "unchanged_expression".to_owned(), true),
(70, "unchanged_expression_".to_owned(), false),
(70, " ".repeat(500), true),
(70, " ".repeat(501), false),
] {
let end = 2 * size + gap.len();
let second = size + gap.len();
cases.push((
format!("{}{}{}", "a".repeat(size), gap, "c".repeat(size)),
format!("{}{}{}", "b".repeat(size), gap, "d".repeat(size)),
if merge {
vec![[0..end, 0..end]]
} else {
vec![[0..size, 0..size], [second..end, second..end]]
},
));
}
cases.push((
format!("aaa = {} = {}", "c".repeat(70), "e".repeat(70)),
format!("bbb = {} = {}", "d".repeat(70), "f".repeat(70)),
vec![[0..149, 0..149]],
));
for (before, after, expected) in cases {
let mut state = live_state(vec![before], vec![after.clone()], false);
assert_eq!(state.rows[0].changes, expected, "{after:?}");
state.refresh_after(&[format!("{after}!")], 1);
state.refresh_after(std::slice::from_ref(&after), 2);
assert_eq!(state.rows[0].changes, expected, "live edit: {after:?}");
}
}
#[test]
fn reverting_loaded_file_keeps_unloaded_changes_selectable() {
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
state.comparison_id = Some(17);
state.pending = None;
state.comparison.as_mut().unwrap().files.push(ChangedFile {
review_revisions: None,
stats: None,
path: "unloaded.txt".into(),
old_path: None,
status: FileStatus::Modified,
loaded: false,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
});
state.refresh_after(&["base".into()], 1);
assert_eq!(
state
.files
.as_ref()
.unwrap()
.entries()
.map(|entry| entry.path)
.collect::<Vec<_>>(),
[Path::new("unloaded.txt")],
);
assert!(state.select_file(Path::new("unloaded.txt")));
assert!(matches!(
state.take_request(),
Some(Operation::LoadFile {
comparison_id: 17,
file_index: 1
})
));
}
#[test]
fn live_width_cache_scans_only_changed_lines_and_shrinks_after_deletion() {
let base = vec!["original".into(), "medium".into(), "short".into()];
let mut lines = vec!["界\tz".into(), "medium".into(), "short".into()];
let mut state = live_state(base, lines.clone(), false);
state.refresh_after(&lines, 1);
assert_eq!(state.after_widths, [5, 6, 5]);
lines[0] = "界界\t🙂".into();
WIDTH_SCANS.set(0);
state.refresh_after(&lines, 2);
assert_eq!(WIDTH_SCANS.get(), 1);
assert_eq!(state.max_after, 10);
state.left_after = 9;
lines[0] = "界".into();
WIDTH_SCANS.set(0);
state.refresh_after(&lines, 3);
assert_eq!(WIDTH_SCANS.get(), 1);
assert_eq!(state.max_after, 6);
assert_eq!(state.left_after, 6);
lines.remove(1);
WIDTH_SCANS.set(0);
state.refresh_after(&lines, 4);
assert_eq!(WIDTH_SCANS.get(), 0);
assert_eq!(state.after_widths, [2, 5]);
assert_eq!(state.max_after, 5);
lines.insert(1, "巨大\t🙂".into());
WIDTH_SCANS.set(0);
state.refresh_after(&lines, 5);
assert_eq!(WIDTH_SCANS.get(), 1);
assert_eq!(state.after_widths, [2, 10, 5]);
lines = vec!["".into()];
state.refresh_after(&lines, 6);
assert_eq!(state.after_widths, [0]);
assert_eq!(state.max_after, 0);
assert_eq!(state.left_after, 0);
}
#[test]
fn stable_line_edits_keep_fresh_hunks_expansion_and_scroll_anchor_until_revert() {
let base = (0..100)
.map(|row| format!("line {row}"))
.collect::<Vec<_>>();
let mut lines = base.clone();
lines[30] = "first change".into();
let mut state = live_state(base.clone(), lines.clone(), true);
state.set_context_options(2, 5);
lines.push(String::new());
state.refresh_after(&lines, 1);
state.top = state.after_display_row(30).unwrap();
state.handle(Input::Char('['));
let display = state.display_rows.clone();
let excerpts = state.excerpt_ranges.clone();
let top = state.top;
lines[30].push('!');
let file = &state.comparison.as_ref().unwrap().files[0];
assert!(stable_after_edit(
file,
&lines,
changed_line_range(&file.after, &lines)
));
WIDTH_SCANS.set(0);
state.refresh_after(&lines, 2);
assert_eq!(WIDTH_SCANS.get(), 1);
assert_eq!(state.display_rows, display);
assert_eq!(state.excerpt_ranges, excerpts);
assert_eq!(state.top, top);
let file = &state.comparison.as_ref().unwrap().files[0];
let hunk_ranges = |hunks: &[Hunk]| {
hunks
.iter()
.map(|hunk| {
(
hunk.before_start,
hunk.before_len,
hunk.after_start,
hunk.after_len,
)
})
.collect::<Vec<_>>()
};
assert_eq!(
hunk_ranges(&file.hunks),
hunk_ranges(&line_hunks(&base, true, live_content(&lines, true), true))
);
lines[30].clone_from(&base[30]);
assert!(!stable_after_edit(
file,
&lines,
changed_line_range(&file.after, &lines)
));
state.refresh_after(&lines, 3);
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal));
assert!(state.after_display_row(23).is_some());
assert_eq!(state.top, state.after_display_row(30).unwrap());
assert_eq!(state.files.as_ref().unwrap().entries().len(), 0);
lines[50] = "changed again".into();
state.refresh_after(&lines, 4);
assert_eq!(state.files.as_ref().unwrap().entries().len(), 1);
assert!(state.after_display_row(23).is_some());
assert!(state.after_display_row(50).is_some());
}
#[test]
fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() {
let base = (0..100)
.map(|row| format!("line {row}"))
.collect::<Vec<_>>();
let mut changed = base.clone();
changed[30] = "first change".into();
changed[70] = "second change".into();
let mut state = live_state(base.clone(), changed.clone(), true);
state.set_context_options(2, 5);
changed.push(String::new());
state.refresh_after(&changed, 1);
assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 2);
assert!(
state
.rows
.last()
.is_some_and(|row| row.after == Some(100) && row.kind == RowKind::Equal)
);
state.top = state.after_display_row(30).unwrap();
state.handle(Input::Char('['));
assert!(state.after_display_row(23).is_some());
changed.insert(10, "inserted outside excerpts".into());
state.refresh_after(&changed, 2);
assert!(state.after_display_row(24).is_some());
assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 3);
assert!(
state
.display_rows
.iter()
.any(|row| matches!(row, DisplayRow::Fold { start, end } if *start < 60 && *end > 40))
);
let mut reverted = base;
reverted.push(String::new());
state.refresh_after(&reverted, 3);
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal));
assert!(state.after_display_row(23).is_some());
assert!(state.after_display_row(90).is_none());
assert_eq!(state.files.as_ref().unwrap().entries().len(), 0);
assert_eq!(state.file, 0);
reverted[50] = "changed again".into();
state.refresh_after(&reverted, 4);
assert_eq!(state.files.as_ref().unwrap().entries().len(), 1);
assert!(state.after_display_row(50).is_some());
}
#[test]
fn live_empty_and_eof_rows_match_buffer_without_false_eof_highlights() {
let mut state = live_state(vec!["same".into()], vec!["changed".into()], true);
state.refresh_after(&["same".into(), "".into()], 1);
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
assert_eq!(state.rows.len(), 2);
assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal));
assert!(state.after_display_row(0).is_some());
assert!(state.after_display_row(1).is_none());
state.refresh_after(&["same".into()], 2);
assert_eq!(state.rows[0].kind, RowKind::Changed);
assert!(
state.comparison.as_ref().unwrap().files[0]
.notice
.as_ref()
.unwrap()
.contains("After missing")
);
state.refresh_after(&["".into()], 3);
assert!(state.after_display_row(0).is_some());
assert_eq!(state.comparison.as_ref().unwrap().files[0].after, [""]);
let mut empty = live_state(vec![], vec!["text".into()], false);
empty.refresh_after(&["".into()], 1);
assert!(empty.comparison.as_ref().unwrap().files[0].hunks.is_empty());
assert_eq!(empty.rows.len(), 1);
assert_eq!(empty.rows[0].kind, RowKind::Equal);
assert_eq!(empty.after_display_row(0), Some(0));
empty.after.as_mut().unwrap().target = RevisionTarget::Commit("historic".into());
empty.comparison.as_mut().unwrap().editable_paths.clear();
empty.refresh_after(&["must not apply".into()], 2);
assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]);
empty.handle(Input::Escape);
assert_eq!(empty.phase, Phase::View);
}
#[test]
fn lazy_selection_waits_for_details_rejects_stale_files_and_reuses_loaded_cache() {
let mut state = DiffState::new();
state.pending = None;
state.request_kind = Some(RequestKind::Comparison);
let mut catalog = comparison();
catalog.files.truncate(2);
let details: Vec<_> = catalog.files.drain(..).collect();
for detail in &details {
catalog.files.push(ChangedFile {
review_revisions: None,
stats: None,
path: detail.path.clone(),
old_path: detail.old_path.clone(),
status: detail.status,
loaded: false,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
});
}
state.apply_result(41, Ok(Outcome::Comparison(catalog)));
assert!(!state.selected_file_loaded());
assert!(state.rows.is_empty() && state.display_rows.is_empty());
assert_eq!(state.pane_layout(), PaneLayout::Empty);
assert!(!state.after_editable());
assert!(matches!(
state.take_request(),
Some(Operation::LoadFile {
comparison_id: 41,
file_index: 0,
})
));
assert!(state.select_file(Path::new("added.txt")));
assert!(matches!(
state.take_request(),
Some(Operation::LoadFile {
comparison_id: 41,
file_index: 1,
})
));
let mut details = details.into_iter();
let first = details.next().unwrap();
let second = details.next().unwrap();
state.apply_result(
42,
Ok(Outcome::File {
comparison_id: 40,
file_index: 1,
file: first,
}),
);
assert!(state.loading && !state.selected_file_loaded());
state.apply_result(
43,
Ok(Outcome::File {
comparison_id: 41,
file_index: 1,
file: second,
}),
);
assert!(state.selected_file_loaded());
assert_eq!(state.pane_layout(), PaneLayout::After);
assert!(!state.rows.is_empty());
assert!(state.select_file(Path::new("renamed.txt")));
assert!(state.loading);
assert!(state.select_file(Path::new("added.txt")));
assert!(!state.loading && state.selected_file_loaded());
assert!(state.take_request().is_none());
state.apply_result(44, Err("cancelled old load".into()));
assert!(state.error.is_none() && state.selected_file_loaded());
}
#[test]
fn failed_lazy_file_load_can_be_retried_without_reopening_comparison() {
let mut state = DiffState::new();
state.pending = None;
state.request_kind = Some(RequestKind::Comparison);
let mut catalog = comparison();
catalog.files.truncate(1);
catalog.files[0].loaded = false;
state.apply_result(7, Ok(Outcome::Comparison(catalog)));
state.take_request();
state.apply_result(8, Err("cannot read blob".into()));
assert_eq!(state.error.as_deref(), Some("cannot read blob"));
assert!(!state.selected_file_loaded());
assert!(state.select_file(Path::new("renamed.txt")));
assert!(state.loading && state.error.is_none());
assert!(matches!(
state.take_request(),
Some(Operation::LoadFile {
comparison_id: 7,
file_index: 0,
})
));
}
#[test]
fn mode_action_keeps_filter_input_and_rejects_superseded_comparisons() {
let mut editor = editor();
open(&mut editor);
editor
.bind("normal", "m", Some("core.diff.mode.toggle".into()))
.unwrap();
text(&mut editor, "m");
assert_eq!(editor.diff().unwrap().query, "m");
assert_eq!(
editor.diff().unwrap().comparison_mode,
ComparisonMode::BranchChanges
);
editor.handle(Input::Ctrl('u'));
editor.handle(Input::Down);
editor.handle(Input::Enter);
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (old, _) = editor.take_diff_request().unwrap();
editor.execute("diff.mode.toggle");
assert!(!editor.apply_diff_result(old, Err("superseded".into())));
let (current, operation) = editor.take_diff_request().unwrap();
assert!(matches!(
operation,
Operation::Compare {
mode: ComparisonMode::Snapshots,
..
}
));
assert!(editor.apply_diff_result(current, Err("retryable".into())));
assert_eq!(editor.diff().unwrap().phase, Phase::After);
assert_eq!(editor.diff().unwrap().error.as_deref(), Some("retryable"));
editor.execute("diff.mode.toggle");
let (_, operation) = editor.take_diff_request().unwrap();
assert!(matches!(
operation,
Operation::Compare {
mode: ComparisonMode::BranchChanges,
..
}
));
assert!(editor.diff().unwrap().error.is_none());
}
#[test]
fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() {
use crate::review::{Anchor, Side};
let mut state = live_state(vec!["old".into()], vec!["new".into()], true);
let anchor = Anchor {
revision: "working:snapshot".into(),
path: "file".into(),
side: Side::After,
start: crate::plugin::TextPosition { row: 0, column: 0 },
end: crate::plugin::TextPosition { row: 1, column: 0 },
content: "new\n".into(),
};
assert!(state.review_anchor_range(&anchor).is_some());
state.live_files.insert(0);
assert!(state.review_anchor_range(&anchor).is_none());
state.comparison.as_mut().unwrap().files[0]
.after
.push(String::new());
assert!(state.review_anchor_range(&anchor).is_some());
state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into();
assert!(state.review_anchor_range(&anchor).is_none());
state.comparison.as_mut().unwrap().files[0].old_path = Some("original".into());
let before = Anchor {
revision: "base".into(),
path: "original".into(),
side: Side::Before,
content: "old\n".into(),
..anchor
};
assert!(state.review_anchor_range(&before).is_some());
state.comparison.as_mut().unwrap().files[0].loaded = false;
assert!(state.review_anchor_range(&before).is_none());
state.comparison.as_mut().unwrap().files[0].loaded = true;
state.comparison.as_mut().unwrap().revisions[0] = "other".into();
assert!(state.review_anchor_range(&before).is_none());
}
#[test]
fn million_row_additions_and_removals_keep_complete_sources_and_compact_navigation() {
let lines = vec!["static const unsigned int parser_state = 1234567890;".to_owned(); 1_000_000];
let mut state = live_state(Vec::new(), lines, true);
state.comparison.as_mut().unwrap().files[0].status = FileStatus::Added;
state.align_file();
assert_eq!(state.rows.len(), 1_000_000);
assert!(state.rows.iter().all(|row| row.changes.is_empty()));
assert_eq!(state.source_ranges[1], [(0..1_000_000, 0)]);
assert_eq!(state.after_display_row(999_999), Some(999_999));
assert_eq!(
state.clamp_source_row(Focus::After, usize::MAX),
Some(999_999)
);
let mut visible = state.source_rows_in(Focus::After, 999_990, 999_999);
assert_eq!(visible.next(), Some(999_990));
assert_eq!(visible.next_back(), Some(999_998));
drop(visible);
let ColumnRow::Text(last) = state
.column_view(Focus::After)
.unwrap()
.row(999_999)
.unwrap()
else {
panic!("the final added source line must remain available");
};
assert_eq!(
last.text,
Some("static const unsigned int parser_state = 1234567890;")
);
assert_eq!(last.source_row, 999_999);
let file = &mut state.comparison.as_mut().unwrap().files[0];
std::mem::swap(&mut file.before, &mut file.after);
file.status = FileStatus::Removed;
file.hunks = line_hunks(&file.before, true, &file.after, false);
assert_eq!(
(file.hunks[0].before_len, file.hunks[0].after_len),
(1_000_000, 0)
);
state.align_file();
assert_eq!(state.source_ranges[0], [(0..1_000_000, 0)]);
assert_eq!(
state.source_display_row(Focus::Before, 999_999),
Some(999_999)
);
assert_eq!(state.source_display_row(Focus::After, 0), None);
}
#[test]
fn large_localized_modifications_keep_exact_hunks_and_only_diff_visible_characters() {
let before = vec!["unchanged generated parser body".to_owned(); 25_000];
let mut after = before.clone();
after[20_000] = "unchanged generated parser body!".into();
let state = live_state(before, after, true);
let file = &state.comparison.as_ref().unwrap().files[0];
assert_eq!(file.hunks.len(), 1);
let hunk = &file.hunks[0];
assert_eq!(
(
hunk.before_start,
hunk.before_len,
hunk.after_start,
hunk.after_len
),
(20_000, 1, 20_000, 1)
);
assert!(state.rows.iter().all(|row| row.changes.is_empty()));
let display = state.after_display_row(20_000).unwrap();
let ColumnRow::Text(line) = state
.column_view(Focus::After)
.unwrap()
.row(display)
.unwrap()
else {
panic!("the modified source row must remain visible");
};
assert_eq!(line.changes().cloned().collect::<Vec<_>>(), [31..32]);
}
#[test]
fn pathological_modifications_preserve_every_line_when_diff_budget_is_exhausted() {
let before = (0..25_000)
.map(|row| format!("old {row}"))
.collect::<Vec<_>>();
let after = (0..25_000)
.map(|row| format!("new {row}"))
.collect::<Vec<_>>();
let state = live_state(before, after, false);
let file = &state.comparison.as_ref().unwrap().files[0];
let (mut old, mut new) = (0, 0);
for hunk in &file.hunks {
assert_eq!(
&file.before[old..hunk.before_start],
&file.after[new..hunk.after_start]
);
old = hunk.before_start + hunk.before_len;
new = hunk.after_start + hunk.after_len;
}
assert_eq!(&file.before[old..], &file.after[new..]);
assert_eq!(state.rows.len(), 25_000);
assert!(
state
.rows
.iter()
.all(|row| row.kind == RowKind::Changed && row.changes.is_empty())
);
assert_eq!(state.after_display_row(24_999), Some(24_999));
let giant = "界".repeat(64 * 1024);
assert_eq!(
character_changes(&giant, "replacement"),
[[0..usize::MAX, 0..usize::MAX]]
);
}
#[test]
fn indexed_source_ranges_match_projection_with_folds_gaps_and_unified_runs() {
let before = (0..60).map(|row| format!("row {row}")).collect::<Vec<_>>();
let mut after = before.clone();
after.splice(10..14, ["inserted".into()]);
after.splice(35..35, ["extra 1".into(), "extra 2".into()]);
let mut state = live_state(before, after, false);
state.set_context_options(2, 5);
for mode in [ViewMode::Split, ViewMode::Unified] {
state.set_view_mode(mode);
for focus in [Focus::Before, Focus::After] {
let expected = (0..state.display_rows.len())
.filter_map(|display| {
state
.display_source_row(focus, display)
.map(|row| (row, display))
})
.collect::<Vec<_>>();
for source in 0..65 {
assert_eq!(
state.source_display_row(focus, source),
expected
.iter()
.find(|(row, _)| *row == source)
.map(|(_, display)| *display)
);
assert_eq!(
state.clamp_source_row(focus, source),
expected
.iter()
.map(|(row, _)| *row)
.min_by_key(|row| (row.abs_diff(source), *row))
);
let target = if focus == Focus::Before {
Focus::After
} else {
Focus::Before
};
let aligned = state.rows.iter().position(|row| match focus {
Focus::Before => row.before == Some(source),
_ => row.after == Some(source),
});
let nearest = aligned.and_then(|aligned| {
(0..state.display_rows.len())
.filter_map(|display| {
let DisplayRow::Line(index) = state.display_rows[display] else {
return None;
};
state
.display_source_row(target, display)
.map(|row| (index.abs_diff(aligned), row))
})
.min()
.map(|(_, row)| row)
});
assert_eq!(state.corresponding_row(focus, target, source), nearest);
}
assert_eq!(
state.source_rows_in(focus, 9, 40).collect::<Vec<_>>(),
expected
.iter()
.map(|(row, _)| *row)
.filter(|row| (9..40).contains(row))
.collect::<Vec<_>>()
);
}
}
}
#[test]
fn ordinary_hunks_keep_similar_anchoring_for_repeated_lines_and_eof_changes() {
let sources = (0..=3)
.flat_map(|len| {
(0..1 << len).map(move |bits| {
(0..len)
.map(|bit| {
if bits & (1 << bit) == 0 {
"a".into()
} else {
"b".into()
}
})
.collect::<Vec<String>>()
})
})
.collect::<Vec<_>>();
for before in &sources {
for after in &sources {
for before_eof in [false, true] {
for after_eof in [false, true] {
let tokens = |lines: &[String], eof| {
lines
.iter()
.enumerate()
.map(|(index, line)| (line.clone(), index + 1 < lines.len() || eof))
.collect::<Vec<_>>()
};
let expected = similar::capture_diff_slices(
similar::Algorithm::Myers,
&tokens(before, before_eof),
&tokens(after, after_eof),
)
.into_iter()
.filter_map(|op| {
let (tag, old, new) = op.as_tag_tuple();
(tag != similar::DiffTag::Equal).then_some((
old.start,
old.len(),
new.start,
new.len(),
))
})
.collect::<Vec<_>>();
let actual = line_hunks(before, before_eof, after, after_eof)
.iter()
.map(|hunk| {
(
hunk.before_start,
hunk.before_len,
hunk.after_start,
hunk.after_len,
)
})
.collect::<Vec<_>>();
assert_eq!(
actual, expected,
"{before:?} ({before_eof}) -> {after:?} ({after_eof})"
);
}
}
}
}
}