Files
vamp/tests/navigation.rs
2026-10-16 18:53:01 +02:00

632 lines
21 KiB
Rust

use std::{
fs,
path::PathBuf,
process::Command,
sync::atomic::{AtomicU64, Ordering},
thread,
time::{Duration, Instant},
};
use vamp::{
diff::{DisplayRow, Focus, Phase},
editor::{Cursor, Editor, Input, Mode, Selection},
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-navigation-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir_all(&root).unwrap();
Self(root)
}
fn file(&self, name: &str, text: &str) -> PathBuf {
let path = self.0.join(name);
fs::write(&path, text).unwrap();
path
}
fn editor(&self, text: &str) -> Editor {
let mut editor =
Editor::open(Some(self.file("buffer.txt", text)), vec!["text".into()]).unwrap();
editor.set_clipboard_mode("separate").unwrap();
editor
}
fn session(&self, text: &str, bindings: &str) -> EditorSession {
let source = self.file("buffer.txt", text);
let config = self.file(
"config.rb",
&format!(
"Vamp.configure do |config|\n config.review_directory = 'reviews'\n config.clipboard_mode = 'separate'\n{bindings}\nend\n"
),
);
let session =
EditorSession::open(Some(source), vec!["text".into()], Some(&config)).unwrap();
assert!(session.editor().startup_diagnostic().is_none());
session
}
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", "Navigation Fixture")
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
.env("GIT_COMMITTER_NAME", "Navigation 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()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn keys(editor: &mut Editor, text: &str) {
for ch in text.chars() {
editor.handle(Input::Char(ch));
}
}
fn session_keys(session: &mut EditorSession, text: &str) {
for ch in text.chars() {
session.handle(Input::Char(ch));
}
}
fn palette_seek(session: &mut EditorSession, action: &str, target: char) {
session.handle(Input::Ctrl('p'));
session_keys(session, action);
session.handle(Input::Enter);
assert!(session.editor().palette().is_none());
session.handle(Input::Char(target));
}
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));
}
}
#[test]
fn star_activation_moves_onto_the_selected_word_only_when_outside_it() {
let fixture = Fixture::new();
for (text, column, expected) in [
(" word word", 0, 2),
(" word word", 1, 2),
(" word word", 2, 2),
(" word word", 4, 4),
("-- word word", 0, 3),
("\t界e\u{301} 界e\u{301}", 0, 1),
(" .* .*", 0, 1),
] {
let mut editor = fixture.editor(text);
keys(&mut editor, &"l".repeat(column));
keys(&mut editor, "*");
assert_eq!(
editor.cursor(),
Cursor {
row: 0,
column: expected
},
"{text:?}"
);
let first = editor.search_match().unwrap().start;
keys(&mut editor, "*");
assert!(editor.cursor().column > first.column, "{text:?}");
assert!(!editor.dirty());
}
}
#[test]
fn submitting_search_keeps_each_caret_inside_a_current_match() {
let fixture = Fixture::new();
for (text, query, column, end) in [
("jj next jj", "jj", 0, 2),
("jj next jj", "jj", 1, 2),
("界e\u{301} next 界e\u{301}", "界e\u{301}", 1, 2),
("aaaa next aa", "aa", 1, 3),
] {
let mut editor = fixture.editor(text);
keys(&mut editor, &"l".repeat(column));
let cursor = editor.cursor();
keys(&mut editor, &format!("/{query}"));
editor.handle(Input::Enter);
assert_eq!(editor.cursor(), cursor, "{text:?}");
assert_eq!(editor.search_match().unwrap().end_column, end);
keys(&mut editor, "n");
assert_ne!(editor.cursor(), cursor);
assert!(!editor.dirty());
}
let mut editor = fixture.editor("jj next jj\n-- next jj");
editor.handle(Input::Ctrl('e'));
keys(&mut editor, "/jj");
editor.handle(Input::Enter);
assert_eq!(
editor.carets(),
[Cursor::default(), Cursor { row: 1, column: 8 }]
);
}
#[test]
fn normal_escape_hides_highlights_but_retains_search_and_rearms_star() {
let fixture = Fixture::new();
let mut editor = fixture.editor("cat cat");
keys(&mut editor, "*");
assert_eq!(editor.search_matches_on_row(0).count(), 2);
editor.handle(Input::Escape);
assert_eq!(editor.search_matches_on_row(0).count(), 0);
assert_eq!(editor.search_match(), None);
assert_eq!(editor.cursor(), Cursor::default());
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor::default());
assert_eq!(editor.search_matches_on_row(0).count(), 2);
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
editor.handle(Input::Escape);
keys(&mut editor, "n");
assert_eq!(editor.cursor(), Cursor::default());
assert_eq!(editor.search_matches_on_row(0).count(), 2);
keys(&mut editor, "/missing");
editor.handle(Input::Escape);
assert_eq!(editor.search_matches_on_row(0).count(), 2);
editor.handle(Input::Escape);
editor.handle(Input::Char('/'));
editor.handle(Input::Enter);
assert_eq!(editor.cursor(), Cursor::default());
assert_eq!(editor.search_matches_on_row(0).count(), 2);
assert!(!editor.dirty());
}
#[test]
fn star_search_arms_without_moving_then_navigates_and_rearms_for_a_new_word_or_mode() {
let fixture = Fixture::new();
let mut editor = fixture.editor("cat dog cat dog");
keys(&mut editor, "l2*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
assert_eq!(editor.search_match().unwrap().start, Cursor::default());
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
keys(&mut editor, "w*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 12 });
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
keys(&mut editor, "/cat");
editor.handle(Input::Enter);
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor::default());
}
#[test]
fn star_search_keeps_whole_word_counts_history_and_literal_search_distinct() {
let fixture = Fixture::new();
let text = "cat\nscatter\ncat_cat\ncat2\n2cat\nCat\ncat\ncat\n\n";
let mut editor = fixture.editor(text);
keys(&mut editor, "l**");
assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 });
assert_eq!(editor.search_match().unwrap().end_column, 3);
keys(&mut editor, "nN2*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 });
editor.handle(Input::Char('\t'));
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
keys(&mut editor, "/scatter");
editor.handle(Input::Escape);
editor.handle(Input::Char('/'));
editor.handle(Input::Enter);
assert_eq!(editor.cursor(), Cursor::default());
keys(&mut editor, "gb*");
assert_eq!(editor.cursor(), Cursor { row: 9, column: 0 });
keys(&mut editor, "n");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
keys(&mut editor, "/cat");
editor.handle(Input::Enter);
keys(&mut editor, "n");
assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 });
assert_eq!(editor.search_match().unwrap().end_column, 4);
assert_eq!(editor.context().text, text);
assert!(!editor.dirty());
}
#[test]
fn star_search_uses_unicode_words_and_vim_same_line_fallbacks() {
let fixture = Fixture::new();
for (text, positioning, row, length) in [
("e\u{301}lan\ne\u{301}lanx\ne\u{301}lan", "l", 2, 4),
("世界\n世界中\n世界", "l", 2, 2),
("snake_case\nsnake_case2\nsnake_case", "3l", 2, 10),
("\t-- word\nword", "", 1, 4),
("-- word\nword", "", 1, 4),
("word --\n--", "$", 1, 2),
(" .* \n.*", "", 1, 2),
("👩‍🚀\n👩\n👩‍🚀", "", 2, 1),
("only", "l", 0, 4),
] {
let mut editor = fixture.editor(text);
keys(&mut editor, positioning);
editor.handle(Input::Char('*'));
editor.handle(Input::Char('*'));
assert_eq!(editor.cursor(), Cursor { row, column: 0 }, "{text:?}");
assert_eq!(
editor.search_match().unwrap().end_column,
length,
"{text:?}"
);
keys(&mut editor, "Nn");
assert_eq!(editor.cursor(), Cursor { row, column: 0 }, "{text:?}");
assert!(!editor.dirty());
}
}
#[test]
fn star_search_rebinding_moves_all_carets_and_remains_view_local() {
let fixture = Fixture::new();
let mut session = fixture.session(
"cat cat_cat cat cat\ncat cat_cat cat cat",
" config.bind '*', nil\n config.bind 'q', config.core.search_word",
);
session.handle(Input::Ctrl('e'));
session_keys(&mut session, "2q");
assert_eq!(
session.editor().carets(),
[Cursor::default(), Cursor { row: 1, column: 0 }]
);
session_keys(&mut session, "2q");
assert_eq!(
session.editor().carets(),
[Cursor { row: 0, column: 16 }, Cursor { row: 1, column: 16 }]
);
session_keys(&mut session, "|2N");
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 });
session_keys(&mut session, "/cat");
session.handle(Input::Enter);
session_keys(&mut session, "n");
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 4 });
session.handle(Input::Alt('h'));
session_keys(&mut session, "2N");
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 });
assert!(!session.editor().dirty());
}
#[test]
fn counted_character_seeks_move_every_native_caret_independently() {
let fixture = Fixture::new();
let text = "xaxbx tail\nx---x----x tail\nx only\n";
let mut editor = fixture.editor(text);
editor.handle(Input::Ctrl('e'));
editor.handle(Input::Ctrl('e'));
assert_eq!(editor.selection_count(), 3);
keys(&mut editor, "2fx");
assert_eq!(
editor.carets(),
[
Cursor { row: 0, column: 4 },
Cursor { row: 1, column: 9 },
Cursor { row: 2, column: 0 },
]
);
keys(&mut editor, "2Fx");
assert_eq!(
editor.carets(),
[
Cursor { row: 0, column: 0 },
Cursor { row: 1, column: 0 },
Cursor { row: 2, column: 0 },
]
);
assert_eq!(editor.context().text, text);
assert!(!editor.dirty());
}
#[test]
fn visual_character_and_line_seeks_yank_the_selected_text_into_another_buffer() {
for (mode, expected) in [('v', "a x x"), ('V', "a x xx tail\n")] {
let fixture = Fixture::new();
let mut editor = fixture.editor("a x xx tail\n");
editor.handle(Input::Char(mode));
keys(&mut editor, "2fx");
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
assert_eq!(editor.visual_linewise(), mode == 'V');
assert_eq!(
editor.selections(),
[Selection {
start: Cursor { row: 0, column: 0 },
end: if mode == 'V' {
Cursor { row: 1, column: 0 }
} else {
Cursor { row: 0, column: 5 }
},
}]
);
editor.handle(Input::Char('y'));
assert_eq!(editor.mode(), Mode::Normal);
assert!(!editor.dirty());
let destination = fixture.file("destination.txt", "");
editor.execute(&format!("edit {}", destination.display()));
editor.handle(Input::Char('P'));
assert_eq!(editor.context().text, expected, "{mode}");
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"a x xx tail\n"
);
}
}
#[test]
fn typed_ruby_seek_remaps_and_palette_actions_preserve_normal_and_visual_navigation() {
let fixture = Fixture::new();
let text = "a x x tail\n";
let mut session = fixture.session(
text,
r#" config.bind 'leader s', config.core.seek_forward
config.bind 'leader r', config.core.seek_backward
config.bind 'leader s', config.core.seek_forward, mode: config.modes.visual
config.bind 'leader r', config.core.seek_backward, mode: config.modes.visual"#,
);
session_keys(&mut session, "2 sx");
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 });
session_keys(&mut session, " rx");
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 });
session_keys(&mut session, "v sx");
let expanded = [Selection {
start: Cursor { row: 0, column: 2 },
end: Cursor { row: 0, column: 5 },
}];
assert_eq!(session.editor().selections(), expanded);
session_keys(&mut session, " rx");
let contracted = [Selection {
start: Cursor { row: 0, column: 2 },
end: Cursor { row: 0, column: 3 },
}];
assert_eq!(session.editor().selections(), contracted);
palette_seek(&mut session, "core.seek_forward", 'x');
assert_eq!(session.editor().mode(), Mode::Visual);
assert_eq!(session.editor().selections(), expanded);
palette_seek(&mut session, "core.seek_backward", 'x');
assert_eq!(session.editor().selections(), contracted);
session.handle(Input::Escape);
palette_seek(&mut session, "core.seek_forward", 'x');
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 });
palette_seek(&mut session, "core.seek_backward", 'x');
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 });
assert_eq!(session.editor().mode(), Mode::Normal);
assert_eq!(session.editor().context().text, text);
assert!(!session.editor().dirty());
}
#[test]
fn repeating_a_visual_seek_change_resolves_the_counted_character_on_the_new_line() {
let fixture = Fixture::new();
let mut session = fixture.session(
"a x x tail\nlong x gap x tail\n",
" config.bind 'leader s', config.core.seek_forward, mode: config.modes.visual",
);
session_keys(&mut session, "v2 sxc!");
session.handle(Input::Escape);
assert_eq!(
session.editor().context().text,
"! tail\nlong x gap x tail\n"
);
session_keys(&mut session, "j0.");
assert_eq!(session.editor().context().text, "! tail\n! tail\n");
assert_eq!(session.editor().mode(), Mode::Normal);
session.handle(Input::Char('u'));
assert_eq!(
session.editor().context().text,
"! tail\nlong x gap x tail\n"
);
}
#[test]
fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_excerpts() {
let fixture = Fixture::new();
fs::create_dir_all(fixture.0.join("repo")).unwrap();
fixture.git(&["init", "-q"]);
let source = fixture.0.join("repo/context.txt");
let mut lines = (1..=40)
.map(|row| format!("stable_{row:03} [x] [x]"))
.collect::<Vec<_>>();
lines[19] = "before x x tail".into();
let before_text = lines.join("\n") + "\n";
fs::write(&source, &before_text).unwrap();
fixture.git(&["add", "context.txt"]);
fixture.git(&["commit", "-qm", "before"]);
let before = fixture.git(&["rev-parse", "HEAD"]);
lines[19] = "after x x tail".into();
let after_text = lines.join("\n") + "\n";
fs::write(&source, &after_text).unwrap();
fixture.git(&["commit", "-qam", "after"]);
let after = fixture.git(&["rev-parse", "HEAD"]);
fixture.git(&["checkout", "--detach", &before]);
lines[19] = "working x x tail".into();
let working_text = lines.join("\n") + "\n";
fs::write(&source, &working_text).unwrap();
let config = fixture.file(
"config.rb",
"Vamp.configure do |config|\n config.review_directory = 'reviews'\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\nend\n",
);
let mut session =
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
assert!(session.editor().startup_diagnostic().is_none());
session_keys(&mut session, "iunsaved ");
session.handle(Input::Escape);
let unsaved_text = session.editor().context().text;
let original_cursor = session.editor().cursor();
session.execute("diff");
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
});
for revision in [&before, &after] {
session_keys(&mut session, revision);
session.handle(Input::Down);
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 excerpts = session.editor().diff().unwrap().display_rows.clone();
assert!(
excerpts
.iter()
.any(|row| matches!(row, DisplayRow::Fold { .. }))
);
for (focus, text, first_x, second_x) in [
(Focus::Before, &before_text, 7, 9),
(Focus::After, &after_text, 6, 8),
] {
for _ in 0..3 {
if session.editor().diff().unwrap().focus == focus {
break;
}
session.handle(Input::Char('\t'));
}
assert_eq!(session.editor().diff().unwrap().focus, focus);
let revision = session.editor().text_revision();
session_keys(&mut session, "gt02f[");
assert_eq!(
session.editor().cursor(),
Cursor {
row: 18,
column: 15
}
);
session_keys(&mut session, "F[f]");
assert_eq!(
session.editor().cursor(),
Cursor {
row: 18,
column: 13
}
);
session_keys(&mut session, "j02fx");
assert_eq!(
session.editor().cursor(),
Cursor {
row: 19,
column: second_x
}
);
session_keys(&mut session, "*");
assert_eq!(
session.editor().cursor(),
Cursor {
row: 19,
column: second_x
}
);
session_keys(&mut session, "*");
assert_eq!(
session.editor().cursor(),
Cursor {
row: 20,
column: 12
}
);
assert_eq!(session.editor().search_match().unwrap().end_column, 13);
session_keys(&mut session, "3*N");
assert_eq!(
session.editor().cursor(),
Cursor {
row: 18,
column: 12
}
);
session_keys(&mut session, "3n");
assert_eq!(
session.editor().cursor(),
Cursor {
row: 19,
column: second_x
}
);
session_keys(&mut session, "Fxvfx");
assert_eq!(
session.editor().selections(),
[Selection {
start: Cursor {
row: 19,
column: first_x
},
end: Cursor {
row: 19,
column: second_x + 1
},
}]
);
session_keys(&mut session, "yVfx");
assert!(session.editor().visual_linewise());
assert_eq!(
session.editor().cursor(),
Cursor {
row: 19,
column: second_x
}
);
session.handle(Input::Escape);
assert_eq!(session.editor().context().text, *text);
assert_eq!(session.editor().text_revision(), revision);
assert!(!session.editor().dirty());
assert_eq!(session.editor().diff().unwrap().display_rows, excerpts);
}
session.execute("diff.close");
assert_eq!(session.editor().context().text, unsaved_text);
assert_eq!(session.editor().cursor(), original_cursor);
assert!(session.editor().dirty());
assert_eq!(fs::read_to_string(&source).unwrap(), working_text);
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), before);
}