feat: rank fuzzy revision and reference searches in diff selectors
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use vamp::{
|
||||
diff::{Category, Phase, RevisionTarget},
|
||||
editor::Input,
|
||||
session::EditorSession,
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture {
|
||||
root: PathBuf,
|
||||
target: String,
|
||||
decoy: String,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-diff-search-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(root.join("repo")).unwrap();
|
||||
let mut fixture = Self {
|
||||
root,
|
||||
target: String::new(),
|
||||
decoy: String::new(),
|
||||
};
|
||||
fixture.git(&["init", "-q", "--initial-branch=fixture-main"]);
|
||||
fixture.commit("foundation", "base");
|
||||
fixture.target = fixture.commit("Über parser target", "target");
|
||||
fixture.decoy = fixture.commit(
|
||||
&format!("release archaeology mentioning {}", fixture.target),
|
||||
"decoy",
|
||||
);
|
||||
for name in [
|
||||
"a-r_e_l_e_a_s_e",
|
||||
"release-candidate",
|
||||
"zrelease",
|
||||
"tie-a",
|
||||
"tie-b",
|
||||
] {
|
||||
fixture.git(&["branch", name, &fixture.decoy]);
|
||||
fixture.git(&["tag", name, &fixture.decoy]);
|
||||
}
|
||||
for name in ["release", "feature/parser-rewrite", "feature/Über-parser"] {
|
||||
fixture.git(&["branch", name, &fixture.target]);
|
||||
fixture.git(&["tag", name, &fixture.target]);
|
||||
}
|
||||
fixture
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(self.root.join("repo"))
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_AUTHOR_NAME", "Diff Search Fixture")
|
||||
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
|
||||
.env("GIT_COMMITTER_NAME", "Diff Search 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(),
|
||||
"git {args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
}
|
||||
|
||||
fn commit(&self, message: &str, content: &str) -> String {
|
||||
fs::write(self.root.join("repo/source.txt"), content).unwrap();
|
||||
self.git(&["add", "source.txt"]);
|
||||
self.git(&["commit", "-qm", message]);
|
||||
self.git(&["rev-parse", "HEAD"])
|
||||
}
|
||||
|
||||
fn session(&self) -> EditorSession {
|
||||
let config = self.root.join("config.rb");
|
||||
fs::write(
|
||||
&config,
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut session = EditorSession::open(
|
||||
Some(self.root.join("repo/source.txt")),
|
||||
vec![],
|
||||
Some(&config),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session.execute(":diff");
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||
});
|
||||
session
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
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, "{:?}", session.editor().diff());
|
||||
if let Some(diff) = session.editor().diff() {
|
||||
assert!(diff.error.is_none(), "{:?}", diff.error);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
fn query(session: &mut EditorSession, text: &str) {
|
||||
session.handle(Input::Ctrl('u'));
|
||||
for ch in text.chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
fn category(session: &mut EditorSession, category: Category) {
|
||||
query(session, "");
|
||||
for _ in 0..3 {
|
||||
let diff = session.editor().diff().unwrap();
|
||||
if diff.catalog.as_ref().unwrap().kind.categories()[diff.category] == category {
|
||||
return;
|
||||
}
|
||||
session.handle(Input::Char('\t'));
|
||||
}
|
||||
panic!("category unavailable: {category:?}");
|
||||
}
|
||||
|
||||
fn labels(session: &EditorSession) -> Vec<String> {
|
||||
let diff = session.editor().diff().unwrap();
|
||||
assert_eq!(
|
||||
diff.entries().next().unwrap().target,
|
||||
RevisionTarget::Working
|
||||
);
|
||||
diff.entries()
|
||||
.skip(1)
|
||||
.map(|entry| entry.label.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_and_tag_fuzzy_ranking_and_empty_order_match_in_both_selectors() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session();
|
||||
for phase in [Phase::Before, Phase::After] {
|
||||
assert_eq!(session.editor().diff().unwrap().phase, phase);
|
||||
for kind in [Category::Branches, Category::Tags] {
|
||||
category(&mut session, kind);
|
||||
let catalogue_order = labels(&session);
|
||||
query(&mut session, " ");
|
||||
assert_eq!(labels(&session), catalogue_order);
|
||||
|
||||
query(&mut session, "release");
|
||||
assert_eq!(
|
||||
labels(&session),
|
||||
[
|
||||
"release",
|
||||
"release-candidate",
|
||||
"zrelease",
|
||||
"a-r_e_l_e_a_s_e"
|
||||
]
|
||||
);
|
||||
query(&mut session, "ftprw");
|
||||
assert_eq!(labels(&session), ["feature/parser-rewrite"]);
|
||||
query(&mut session, "tie");
|
||||
let ties: Vec<_> = catalogue_order
|
||||
.iter()
|
||||
.filter(|label| label.starts_with("tie-"))
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_eq!(&labels(&session)[..ties.len()], ties);
|
||||
query(&mut session, "no-such-reference-anywhere");
|
||||
assert!(labels(&session).is_empty());
|
||||
assert_eq!(session.editor().diff().unwrap().selected, 0);
|
||||
}
|
||||
if phase == Phase::Before {
|
||||
// Even an unmatched query leaves Working selectable and pinned first.
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_tokens_and_commit_ids_search_fields_without_cross_field_subsequences() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session();
|
||||
for phase in [Phase::Before, Phase::After] {
|
||||
assert_eq!(session.editor().diff().unwrap().phase, phase);
|
||||
category(&mut session, Category::Branches);
|
||||
for text in [
|
||||
format!("{} ÜBPR", &fixture.target[..10]),
|
||||
format!("übpr {}", &fixture.target[..10]),
|
||||
] {
|
||||
query(&mut session, &text);
|
||||
assert_eq!(labels(&session), ["feature/Über-parser"]);
|
||||
}
|
||||
|
||||
category(&mut session, Category::Commits);
|
||||
for text in [&fixture.target[..], &fixture.target[..10]] {
|
||||
query(&mut session, text);
|
||||
assert_eq!(
|
||||
labels(&session),
|
||||
[fixture.target.clone(), fixture.decoy.clone()]
|
||||
);
|
||||
}
|
||||
query(&mut session, "ÜB PRSR");
|
||||
assert_eq!(labels(&session), [fixture.target.clone()]);
|
||||
// The ID and description can satisfy separate tokens, but not one token
|
||||
// that would require a subsequence crossing between unrelated fields.
|
||||
query(&mut session, &format!("{} übpr", &fixture.target[..10]));
|
||||
assert_eq!(labels(&session), [fixture.target.clone()]);
|
||||
query(&mut session, &format!("{}über", fixture.target));
|
||||
assert!(labels(&session).is_empty());
|
||||
if phase == Phase::Before {
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_ranked_branch_uses_its_actual_commit_in_before_and_after() {
|
||||
let fixture = Fixture::new();
|
||||
for ranked_phase in [Phase::Before, Phase::After] {
|
||||
let mut session = fixture.session();
|
||||
if ranked_phase == Phase::After {
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
category(&mut session, Category::Branches);
|
||||
query(&mut session, "release");
|
||||
assert_eq!(labels(&session)[0], "release");
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
if ranked_phase == Phase::Before {
|
||||
// Working selects snapshot mode rather than merge-base mode.
|
||||
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 diff = session.editor().diff().unwrap();
|
||||
let ranked = if ranked_phase == Phase::Before {
|
||||
diff.before.as_ref().unwrap()
|
||||
} else {
|
||||
diff.after.as_ref().unwrap()
|
||||
};
|
||||
assert_eq!(ranked.label, "release");
|
||||
assert_eq!(
|
||||
ranked.target,
|
||||
RevisionTarget::Commit(fixture.target.clone())
|
||||
);
|
||||
let comparison = diff.comparison.as_ref().unwrap();
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
assert_eq!(comparison.files[0].path, Path::new("source.txt"));
|
||||
let (before, after) = if ranked_phase == Phase::Before {
|
||||
(["target"], ["decoy"])
|
||||
} else {
|
||||
(["decoy"], ["target"])
|
||||
};
|
||||
assert_eq!(comparison.files[0].before, before);
|
||||
assert_eq!(comparison.files[0].after, after);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user