Files
vamp/tests/diff.rs
T
2026-10-16 18:52:49 +02:00

685 lines
24 KiB
Rust

use std::{
fs,
path::{Path, PathBuf},
process::Command,
sync::atomic::{AtomicU64, Ordering},
thread,
time::{Duration, Instant},
};
use vamp::{
diff::{ComparisonMode, Phase},
editor::Input,
session::EditorSession,
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"vamp-diff-session-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir_all(root.join("repo")).unwrap();
Self(root)
}
fn git(&self, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(self.0.join("repo"))
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Diff Fixture")
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
.env("GIT_COMMITTER_NAME", "Diff Fixture")
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
.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()
}
fn jj(&self, args: &[&str]) -> String {
let output = Command::new("jj")
.args(args)
.current_dir(self.0.join("repo"))
.env("JJ_CONFIG", "/dev/null")
.env("JJ_USER", "Diff Fixture")
.env("JJ_EMAIL", "fixture@example.invalid")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
while !ready(session) {
session.poll();
assert!(
Instant::now() < deadline,
"diff state: {:?}",
session.editor().diff()
);
if let Some(diff) = session.editor().diff() {
assert!(diff.error.is_none(), "{:?}", diff.error);
}
thread::sleep(Duration::from_millis(1));
}
}
fn type_query(session: &mut EditorSession, text: &str) {
for ch in text.chars() {
session.handle(Input::Char(ch));
}
}
#[test]
fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close() {
let fixture = Fixture::new();
fixture.git(&["init", "-q"]);
let source = fixture.0.join("repo/buffer.rs");
fs::write(&source, "before\n").unwrap();
fixture.git(&["add", "buffer.rs"]);
fixture.git(&["commit", "-qm", "base"]);
let base = fixture.git(&["rev-parse", "HEAD"]);
fs::write(&source, "after\n").unwrap();
let config = fixture.0.join("config.rb");
fs::write(
&config,
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'leader d', config.core.diff\nend\n",
)
.unwrap();
let mut session =
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
assert!(session.editor().startup_diagnostic().is_none());
for input in [Input::Char('i'), Input::Char('u'), Input::Escape] {
session.handle(input);
}
let cursor = session.editor().cursor();
session.handle(Input::Char(' '));
session.handle(Input::Char('d'));
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
});
type_query(&mut session, &base);
session.handle(Input::Down);
session.handle(Input::Enter);
assert_eq!(session.editor().diff().unwrap().phase, Phase::After);
session.handle(Input::Enter);
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::Snapshots
);
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
});
let comparison = session
.editor()
.diff()
.unwrap()
.comparison
.as_ref()
.unwrap();
assert_eq!(comparison.files.len(), 1);
let file = &comparison.files[0];
assert_eq!(file.path, Path::new("buffer.rs"));
assert_eq!(file.before, ["before"]);
assert_eq!(file.after, ["uafter", ""]);
session.handle(Input::Char('\t'));
assert_eq!(
session.editor().diff().unwrap().focus,
vamp::diff::Focus::Before
);
for input in [
Input::Char('i'),
Input::Char('x'),
Input::Paste("overwrite".into()),
] {
session.handle(input);
}
session.handle(Input::Escape);
assert!(session.editor().diff().is_some());
assert_eq!(session.editor().context().text, "before\n");
assert_eq!(fs::read_to_string(&source).unwrap(), "after\n");
session.handle(Input::Char('\t'));
assert_eq!(
session.editor().diff().unwrap().focus,
vamp::diff::Focus::After
);
assert_eq!(session.editor().context().text, "uafter\n");
assert_eq!(session.editor().cursor(), cursor);
for input in [Input::Char('i'), Input::Char('x'), Input::Escape] {
session.handle(input);
}
assert_eq!(session.editor().context().text, "xuafter\n");
assert!(session.editor().diff().is_some());
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "uafter\n");
session.handle(Input::Char('U'));
assert_eq!(session.editor().context().text, "xuafter\n");
session.execute("w");
assert_eq!(fs::read_to_string(&source).unwrap(), "xuafter\n");
assert!(!session.editor().dirty());
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "uafter\n");
assert!(session.editor().dirty());
session.execute("diff.close");
assert!(session.editor().diff().is_none());
assert_eq!(session.editor().context().text, "uafter\n");
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "after\n");
assert_eq!(fs::read_to_string(&source).unwrap(), "xuafter\n");
session.execute(":diff");
assert!(session.editor().diff().is_some());
session.execute("diff.close");
session.poll();
assert!(session.editor().diff().is_none());
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), base);
assert_eq!(fixture.git(&["status", "--porcelain"]), "M buffer.rs");
}
#[test]
fn tabs_keep_repository_workers_and_hidden_comparisons_independent() {
let first = Fixture::new();
let second = Fixture::new();
for (fixture, before, after) in [
(&first, "first before\n", "first after\n"),
(&second, "second before\n", "second after\n"),
] {
fixture.git(&["init", "-q"]);
fs::write(fixture.0.join("repo/source.txt"), before).unwrap();
fixture.git(&["add", "source.txt"]);
fixture.git(&["commit", "-qm", "base"]);
fs::write(fixture.0.join("repo/source.txt"), after).unwrap();
}
let first_path = first.0.join("repo/source.txt");
let second_path = second.0.join("repo/source.txt");
let config = first.0.join("config.rb");
fs::write(
&config,
"Vamp.configure do |config|\n config.bind 'ctrl+option+n', config.core.tab.new\n config.bind 'ctrl+option+w', config.core.tab.close\nend\n",
)
.unwrap();
let mut session =
EditorSession::open(Some(first_path.clone()), vec!["text".into()], Some(&config)).unwrap();
assert!(session.editor().startup_diagnostic().is_none());
session.execute("diff");
session.handle(Input::CtrlAlt('n'));
session.execute(&format!("edit {}", second_path.display()));
session.execute("diff");
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
});
session.handle(Input::CtrlAlt('h'));
assert_eq!(session.editor().tab_index(), 0);
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
});
type_query(&mut session, &first.git(&["rev-parse", "HEAD"]));
session.handle(Input::Down);
session.handle(Input::Enter);
session.handle(Input::Enter);
session.handle(Input::CtrlAlt('l'));
assert_eq!(session.editor().tab_index(), 1);
type_query(&mut session, &second.git(&["rev-parse", "HEAD"]));
session.handle(Input::Down);
session.handle(Input::Enter);
session.handle(Input::Enter);
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
});
let comparison = session
.editor()
.diff()
.unwrap()
.comparison
.as_ref()
.unwrap();
assert_eq!(
comparison.root,
second.0.join("repo").canonicalize().unwrap()
);
assert_eq!(comparison.files[0].before, ["second before"]);
assert_eq!(comparison.files[0].after, ["second after", ""]);
session.handle(Input::CtrlAlt('h'));
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
});
let comparison = session
.editor()
.diff()
.unwrap()
.comparison
.as_ref()
.unwrap();
assert_eq!(
comparison.root,
first.0.join("repo").canonicalize().unwrap()
);
assert_eq!(comparison.files[0].before, ["first before"]);
assert_eq!(comparison.files[0].after, ["first after", ""]);
session.handle(Input::CtrlAlt('l'));
session.handle(Input::CtrlAlt('w'));
assert_eq!(session.editor().tab_count(), 1);
assert!(session.editor().diff().is_some());
session.execute("diff.close");
assert_eq!(session.editor().path(), Some(first_path.as_path()));
assert_eq!(session.editor().lines(), ["first after", ""]);
}
#[test]
fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() {
use vamp::diff::DisplayRow;
let fixture = Fixture::new();
fixture.git(&["init", "-q"]);
let source = fixture.0.join("repo/context.txt");
let mut lines = (1..=100)
.map(|line| format!("line_{line:03} words"))
.collect::<Vec<_>>();
lines[49].clear(); // Hidden short lines must not clamp the visible caret column.
fs::write(&source, format!("{}\n", lines.join("\n"))).unwrap();
fixture.git(&["add", "context.txt"]);
fixture.git(&["commit", "-qm", "base"]);
let base = fixture.git(&["rev-parse", "HEAD"]);
lines[19] = "changed_020 words".into();
lines[79] = "changed_080 words".into();
let text = format!("{}\n", lines.join("\n"));
fs::write(&source, &text).unwrap();
let config = fixture.0.join("config.rb");
fs::write(&config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\n config.bind 'ctrl+f', config.core.down\n config.bind 'ctrl+b', config.core.up\nend\n").unwrap();
let mut session =
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
session.execute("diff");
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
});
type_query(&mut session, &base);
session.handle(Input::Down);
session.handle(Input::Enter);
session.handle(Input::Enter);
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
});
session.handle(Input::BackTab);
let shown = |session: &EditorSession| {
let diff = session.editor().diff().unwrap();
diff.display_rows
.iter()
.filter_map(|row| match *row {
DisplayRow::Line(index) => diff.rows[index].after,
DisplayRow::Fold { .. } => None,
})
.collect::<Vec<_>>()
};
let excerpts = vec![18, 19, 20, 78, 79, 80];
assert_eq!(shown(&session), excerpts);
assert_eq!(session.editor().cursor().row, 18);
type_query(&mut session, "5l");
for (keys, row) in [("2j", 20), ("j", 78), ("k", 20), ("3j", 80), ("5k", 18)] {
type_query(&mut session, keys);
assert_eq!(session.editor().cursor().row, row, "{keys}");
assert_eq!(session.editor().cursor().column, 5, "{keys}");
assert_eq!(shown(&session), excerpts, "{keys}");
}
for (keys, row) in [
("gb", 80),
("gt", 18),
("2j$w", 78),
("b", 20),
("$2w", 78),
("0B", 20),
] {
type_query(&mut session, keys);
assert_eq!(session.editor().cursor().row, row, "{keys}");
assert_eq!(shown(&session), excerpts, "{keys}");
}
session.handle(Input::Ctrl('f'));
assert_eq!(session.editor().cursor().row, 78);
session.handle(Input::Ctrl('b'));
assert_eq!(session.editor().cursor().row, 20);
type_query(&mut session, "Vj");
assert_eq!(session.editor().cursor().row, 78);
session.handle(Input::Escape);
session.handle(Input::Ctrl('k'));
assert_eq!(
session
.editor()
.carets()
.iter()
.map(|caret| caret.row)
.collect::<Vec<_>>(),
[20, 78]
);
type_query(&mut session, "j");
assert_eq!(
session
.editor()
.carets()
.iter()
.map(|caret| caret.row)
.collect::<Vec<_>>(),
[78, 79]
);
session.handle(Input::Escape);
type_query(&mut session, "gt2j$v");
session.handle(Input::Ctrl('a'));
assert_eq!(
session
.editor()
.carets()
.iter()
.map(|caret| caret.row)
.collect::<Vec<_>>(),
excerpts
);
assert_eq!(shown(&session), excerpts);
session.handle(Input::Escape);
session.handle(Input::Escape);
type_query(&mut session, "/line_040");
session.handle(Input::Enter);
assert_eq!(session.editor().cursor().row, 20);
type_query(&mut session, "/line_079");
session.handle(Input::Enter);
assert_eq!(session.editor().cursor().row, 78);
assert_eq!(shown(&session), excerpts);
type_query(&mut session, "gt2ji");
session.handle(Input::Down);
assert_eq!(session.editor().cursor().row, 78);
session.handle(Input::Up);
assert_eq!(session.editor().cursor().row, 20);
session.handle(Input::Escape);
assert_eq!(shown(&session), excerpts);
assert_eq!(session.editor().context().text, text);
assert!(!session.editor().dirty());
assert_eq!(fs::read_to_string(&source).unwrap(), text);
type_query(&mut session, "[");
assert_eq!(shown(&session), [16, 17, 18, 19, 20, 78, 79, 80]);
type_query(&mut session, "]");
assert_eq!(shown(&session), [16, 17, 18, 19, 20, 21, 22, 78, 79, 80]);
}
#[test]
fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() {
let fixture = Fixture::new();
fixture.git(&["init", "-q"]);
let root = fixture.0.join("repo");
for name in ["first", "second"] {
let mut lines = (1..=40)
.map(|row| format!("// {name}_stable_{row:03}"))
.collect::<Vec<_>>();
lines[19] = format!("// {name}_before");
fs::write(root.join(format!("{name}.rs")), lines.join("\n") + "\n").unwrap();
}
fixture.git(&["add", "."]);
fixture.git(&["commit", "-qm", "base"]);
for name in ["first", "second"] {
let path = root.join(format!("{name}.rs"));
let text = fs::read_to_string(&path).unwrap();
fs::write(path, text.replace("_before", "_after")).unwrap();
}
let mut session = EditorSession::open(Some(root.join("first.rs")), vec![], None).unwrap();
session.execute("diff");
wait_until(&mut session, |session| {
!session.editor().diff().unwrap().loading
});
for input in [Input::Down, Input::Enter, Input::Enter] {
session.handle(input);
}
wait_until(&mut session, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
session.handle(Input::Char('\t'));
let first_origin = session.editor().cursor();
type_query(&mut session, "/first_before");
session.handle(Input::Enter);
for input in [
Input::Char(' '),
Input::Char('e'),
Input::Home,
Input::Down,
Input::Enter,
] {
session.handle(input);
}
wait_until(&mut session, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
let second_origin = session.editor().cursor();
type_query(&mut session, "/second_before");
session.handle(Input::Enter);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().cursor(), second_origin);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().diff().unwrap().file, 0);
assert_eq!(session.editor().cursor(), first_origin);
assert!(session.editor().context().text.contains("first_before"));
assert!(!session.editor().context().text.contains("second_before"));
session.execute("diff.close");
assert_eq!(
session.editor().context().text,
fs::read_to_string(root.join("first.rs")).unwrap()
);
}
#[test]
fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips() {
let fixture = Fixture::new();
let root = fixture.0.join("repo");
fixture.git(&["init", "-q"]);
fs::write(root.join("common"), "common\n").unwrap();
fixture.git(&["add", "."]);
fixture.git(&["commit", "-qm", "common"]);
let base = fixture.git(&["rev-parse", "HEAD"]);
fs::write(root.join("base-only"), "base branch\n").unwrap();
fixture.git(&["add", "."]);
fixture.git(&["commit", "-qm", "base only"]);
let before = fixture.git(&["rev-parse", "HEAD"]);
fixture.git(&["checkout", "-qb", "feature", &base]);
fs::write(root.join("feature-only"), "feature branch\n").unwrap();
fixture.git(&["add", "."]);
fixture.git(&["commit", "-qm", "feature only"]);
let after = fixture.git(&["rev-parse", "HEAD"]);
let config = fixture.0.join("config.rb");
fs::write(&config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'ctrl+b', config.core.diff.mode.toggle\nend\n").unwrap();
let mut session =
EditorSession::open(Some(root.join("common")), vec![], Some(&config)).unwrap();
session.execute("diff");
wait_until(&mut session, |session| {
!session.editor().diff().unwrap().loading
});
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::BranchChanges
);
session.handle(Input::Ctrl('b'));
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::Snapshots
);
session.handle(Input::Ctrl('p'));
type_query(&mut session, "diff.mode.toggle");
session.handle(Input::Enter);
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::BranchChanges
);
for id in [&before, &after] {
type_query(&mut session, id);
session.handle(Input::Down);
session.handle(Input::Enter);
}
wait_until(&mut session, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
let diff = session.editor().diff().unwrap();
let comparison = diff.comparison.as_ref().unwrap();
assert_eq!(comparison.files.len(), 1);
assert_eq!(comparison.files[0].path, Path::new("feature-only"));
assert_eq!(session.editor().context().text, "feature branch\n");
assert!(diff.after_editable());
assert_eq!(
comparison.before,
format!("merge base of {before} and {after} · {base}")
);
assert_eq!(diff.before.as_ref().unwrap().label, before);
session.execute("diff.mode.toggle");
wait_until(&mut session, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
let comparison = session
.editor()
.diff()
.unwrap()
.comparison
.as_ref()
.unwrap();
assert_eq!(comparison.before, before);
assert_eq!(comparison.after, after);
assert_eq!(
comparison
.files
.iter()
.map(|file| file.path.as_path())
.collect::<Vec<_>>(),
[Path::new("base-only"), Path::new("feature-only")]
);
// Superseding an in-flight recomparison must not restore the old mode's result.
session.execute("diff.mode.toggle");
session.execute("diff.mode.toggle");
session.execute("diff.mode.toggle");
wait_until(&mut session, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
let diff = session.editor().diff().unwrap();
assert_eq!(diff.comparison_mode, ComparisonMode::BranchChanges);
assert_eq!(diff.comparison.as_ref().unwrap().files.len(), 1);
assert_eq!(diff.before.as_ref().unwrap().label, before);
assert_eq!(diff.after.as_ref().unwrap().label, after);
session.execute("diff.close");
session.execute("diff");
wait_until(&mut session, |session| {
!session.editor().diff().unwrap().loading
});
session.handle(Input::Enter);
let diff = session.editor().diff().unwrap();
assert_eq!(diff.phase, Phase::After);
assert_eq!(diff.comparison_mode, ComparisonMode::Snapshots);
session.execute("diff.mode.toggle");
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::Snapshots
);
assert_eq!(
session.editor().message(),
"Working checkout requires Snapshots"
);
}
#[test]
fn jj_bookmark_picker_keeps_symbolic_targets_searchable_by_resolved_commit_id() {
use vamp::diff::RevisionTarget;
let fixture = Fixture::new();
fixture.jj(&["git", "init", "--no-colocate"]);
let source = fixture.0.join("repo/source.txt");
fs::write(&source, "base\n").unwrap();
fixture.jj(&["describe", "-m", "base"]);
let before = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new"]);
fs::write(&source, "topic\n").unwrap();
fixture.jj(&["describe", "-m", "topic"]);
fixture.jj(&["bookmark", "create", "topic"]);
let after = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new"]);
let mut session = EditorSession::open(Some(source), vec![], None).unwrap();
session.execute("diff");
wait_until(&mut session, |session| {
!session.editor().diff().unwrap().loading
});
type_query(&mut session, &before);
session.handle(Input::Down);
session.handle(Input::Enter);
session.handle(Input::Char('\t'));
type_query(&mut session, &after);
let target = RevisionTarget::Bookmark {
name: "topic".into(),
remote: None,
};
assert_eq!(
session
.editor()
.diff()
.unwrap()
.entries()
.nth(1)
.unwrap()
.target,
target
);
session.handle(Input::Down);
session.handle(Input::Enter);
wait_until(&mut session, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
let diff = session.editor().diff().unwrap();
assert_eq!(
diff.before.as_ref().unwrap().target,
RevisionTarget::Commit(before)
);
assert_eq!(diff.after.as_ref().unwrap().target, target);
assert_eq!(diff.comparison.as_ref().unwrap().files[0].after, ["topic"]);
assert!(!diff.after_editable());
}