refactor: move embedded tests into dedicated test directory
This commit is contained in:
+3
-229
@@ -137,8 +137,6 @@ mod macos {
|
||||
fn CFBooleanGetValue(boolean: Object) -> u8;
|
||||
fn CFNumberGetTypeID() -> usize;
|
||||
fn CFNumberGetValue(number: Object, kind: isize, value: *mut c_void) -> u8;
|
||||
#[cfg(test)]
|
||||
fn CFNumberCreate(allocator: Object, kind: isize, value: Object) -> Object;
|
||||
}
|
||||
|
||||
// Only Copy/Create results belong here; power source descriptions are borrowed.
|
||||
@@ -219,34 +217,7 @@ mod macos {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_values_are_type_checked_and_numbers_must_be_exact_and_nonnegative() {
|
||||
assert!(Owned::new(std::ptr::null()).is_none());
|
||||
unsafe {
|
||||
assert_eq!(number(std::ptr::null()), None);
|
||||
let text = Owned::string(c"27").unwrap();
|
||||
assert_eq!(number(text.0), None);
|
||||
for value in [-1_i64, 0, 100] {
|
||||
let object = Owned::new(CFNumberCreate(
|
||||
std::ptr::null(),
|
||||
4,
|
||||
(&value as *const i64).cast(),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(number(object.0), u64::try_from(value).ok());
|
||||
}
|
||||
let fraction = 1.5_f64;
|
||||
let object = Owned::new(CFNumberCreate(
|
||||
std::ptr::null(),
|
||||
6,
|
||||
(&fraction as *const f64).cast(),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(number(object.0), None);
|
||||
}
|
||||
}
|
||||
include!("../tests/unit/battery_macos.rs");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,202 +312,5 @@ mod windows {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn percentages_reject_invalid_data_and_include_both_endpoints() {
|
||||
assert_eq!(percentage(0, 100), Some(0));
|
||||
assert_eq!(percentage(100, 100), Some(100));
|
||||
assert_eq!(percentage(1, 3), Some(33));
|
||||
assert_eq!(percentage(u64::MAX, u64::MAX), Some(100));
|
||||
assert_eq!(percentage(0, 0), None);
|
||||
assert_eq!(percentage(101, 100), None);
|
||||
assert_eq!(windows_charge(0, 4), Some(0));
|
||||
assert_eq!(windows_charge(100, 8), Some(100));
|
||||
assert_eq!(windows_charge(101, 0), None);
|
||||
assert_eq!(windows_charge(255, 0), None);
|
||||
assert_eq!(windows_charge(50, 128), None);
|
||||
assert_eq!(windows_charge(50, 255), None);
|
||||
}
|
||||
|
||||
fn worker(samples: Vec<Option<u8>>) -> (Battery, Receiver<()>, Arc<AtomicBool>) {
|
||||
struct ReaderDropped(Arc<AtomicBool>);
|
||||
impl Drop for ReaderDropped {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let reader = ReaderDropped(Arc::clone(&dropped));
|
||||
let mut samples = samples.into_iter();
|
||||
let (ready, published) = mpsc::channel();
|
||||
let battery = Battery::start_with(
|
||||
move || {
|
||||
let _ = &reader;
|
||||
samples
|
||||
.next()
|
||||
.expect("test provides every requested sample")
|
||||
},
|
||||
move |wake| {
|
||||
ready.send(()).unwrap();
|
||||
// Explicit channel messages replace the production clock entirely.
|
||||
wake.recv().is_ok()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
(battery, published, dropped)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_reads_immediately_replaces_stale_samples_and_clears_invalid_data() {
|
||||
let (mut battery, published, dropped) = worker(vec![Some(0), Some(100), None, Some(101)]);
|
||||
published.recv().unwrap();
|
||||
assert_eq!(battery.poll(), Some(Some(0)));
|
||||
assert_eq!(battery.poll(), None);
|
||||
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||
published.recv().unwrap();
|
||||
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||
published.recv().unwrap();
|
||||
assert_eq!(battery.poll(), Some(None));
|
||||
assert_eq!(battery.poll(), None);
|
||||
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||
published.recv().unwrap();
|
||||
assert_eq!(battery.poll(), Some(None));
|
||||
drop(battery);
|
||||
assert!(dropped.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_interrupts_wait_and_joins_even_with_an_unread_sample() {
|
||||
let (battery, published, dropped) = worker(vec![Some(50)]);
|
||||
published.recv().unwrap();
|
||||
drop(battery);
|
||||
assert!(dropped.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"vamp-battery-{}-{}",
|
||||
std::process::id(),
|
||||
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn source(&self, name: &str, fields: &[(&str, &str)]) {
|
||||
let path = self.0.join(name);
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
for (key, value) in fields {
|
||||
fs::write(path.join(key), value).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self) -> Option<u8> {
|
||||
linux::read_charge(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sysfs_reads_present_system_batteries_but_not_external_or_absent_sources() {
|
||||
let fixture = Fixture::new();
|
||||
assert_eq!(fixture.read(), None);
|
||||
fixture.source("AC", &[("type", "Mains"), ("capacity", "100")]);
|
||||
fixture.source(
|
||||
"mouse",
|
||||
&[
|
||||
("type", "Battery"),
|
||||
("scope", "Device"),
|
||||
("present", "1"),
|
||||
("capacity", "100"),
|
||||
],
|
||||
);
|
||||
fixture.source(
|
||||
"absent",
|
||||
&[("type", "Battery"), ("present", "0"), ("capacity", "100")],
|
||||
);
|
||||
assert_eq!(fixture.read(), None);
|
||||
fixture.source(
|
||||
"BAT0",
|
||||
&[
|
||||
("type", "Battery\n"),
|
||||
("scope", "System\n"),
|
||||
("present", "1\n"),
|
||||
("capacity", "27\n"),
|
||||
],
|
||||
);
|
||||
assert_eq!(fixture.read(), Some(27));
|
||||
fixture.source("BAT0", &[("capacity", "0")]);
|
||||
assert_eq!(fixture.read(), Some(0));
|
||||
fixture.source("BAT0", &[("capacity", "100")]);
|
||||
assert_eq!(fixture.read(), Some(100));
|
||||
for invalid in ["101", "-1", "255", "garbage", ""] {
|
||||
fixture.source("BAT0", &[("capacity", invalid)]);
|
||||
assert_eq!(fixture.read(), None);
|
||||
}
|
||||
fs::remove_file(fixture.0.join("BAT0/capacity")).unwrap();
|
||||
assert_eq!(fixture.read(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sysfs_uses_matching_actual_energy_or_charge_totals_only() {
|
||||
let energy = Fixture::new();
|
||||
energy.source(
|
||||
"BAT0",
|
||||
&[
|
||||
("type", "Battery"),
|
||||
("present", "1"),
|
||||
("energy_now", "27"),
|
||||
("energy_full", "100"),
|
||||
],
|
||||
);
|
||||
assert_eq!(energy.read(), Some(27));
|
||||
energy.source("BAT0", &[("energy_full", "0")]);
|
||||
assert_eq!(energy.read(), None);
|
||||
energy.source("BAT0", &[("energy_full", "26")]);
|
||||
assert_eq!(energy.read(), None);
|
||||
energy.source("BAT0", &[("energy_full", "garbage")]);
|
||||
assert_eq!(energy.read(), None);
|
||||
let charge = Fixture::new();
|
||||
charge.source(
|
||||
"BAT0",
|
||||
&[
|
||||
("type", "Battery"),
|
||||
("present", "1"),
|
||||
("charge_now", "15"),
|
||||
("charge_full", "20"),
|
||||
],
|
||||
);
|
||||
assert_eq!(charge.read(), Some(75));
|
||||
charge.source("BAT0", &[("energy_now", "5")]);
|
||||
assert_eq!(charge.read(), None);
|
||||
let design = Fixture::new();
|
||||
design.source(
|
||||
"BAT0",
|
||||
&[
|
||||
("type", "Battery"),
|
||||
("present", "1"),
|
||||
("energy_now", "20"),
|
||||
("energy_full_design", "100"),
|
||||
],
|
||||
);
|
||||
assert_eq!(design.read(), None);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/battery.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-374
@@ -505,377 +505,5 @@ fn parse(bytes: &[u8], rows: usize) -> Result<Vec<Option<Arc<LineBlame>>>, Strin
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||
let mut command = Command::new(program);
|
||||
if program == "jj" {
|
||||
command.args([
|
||||
"--config=user.name=\"Jj Author\"",
|
||||
"--config=user.email=\"jj@example.test\"",
|
||||
]);
|
||||
command.args([
|
||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
]);
|
||||
}
|
||||
let output = command
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("JJ_CONFIG", "/dev/null")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||
}
|
||||
fn fixture() -> (Temporary, PathBuf, String) {
|
||||
let temp = Temporary::new().unwrap();
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
command(&temp.0, "git", &["config", "user.name", "Blame Author"]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["config", "user.email", "blame@example.test"],
|
||||
);
|
||||
let path = temp.0.join("-literal [file].txt");
|
||||
fs::write(&path, "first\nsecond\n").unwrap();
|
||||
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["commit", "--quiet", "-m", "Original summary"],
|
||||
);
|
||||
let commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||
(temp, path, commit)
|
||||
}
|
||||
#[test]
|
||||
fn committed_line_has_native_author_summary_and_commit() {
|
||||
let (_temp, path, commit) = fixture();
|
||||
let rows = read_file(&path, "first\nsecond\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[0].as_ref().unwrap(),
|
||||
rows[1].as_ref().unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
**rows[1].as_ref().unwrap(),
|
||||
LineBlame {
|
||||
commit,
|
||||
author: "Blame Author".into(),
|
||||
summary: "Original summary".into(),
|
||||
uncommitted: false
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn unsaved_changed_line_is_uncommitted_without_metadata() {
|
||||
let (_temp, path, _) = fixture();
|
||||
let rows = read_file(&path, "changed\nsecond\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
**rows[0].as_ref().unwrap(),
|
||||
LineBlame {
|
||||
commit: String::new(),
|
||||
author: String::new(),
|
||||
summary: String::new(),
|
||||
uncommitted: true
|
||||
}
|
||||
);
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), "first\nsecond\n");
|
||||
}
|
||||
#[test]
|
||||
fn inserted_line_preserves_following_original_attribution() {
|
||||
let (_temp, path, commit) = fixture();
|
||||
let contents = "inserted\nfirst\nsecond\n";
|
||||
let rows = read_file(&path, contents, &|| false).unwrap().unwrap();
|
||||
assert!(rows[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(rows[2].as_ref().unwrap().commit, commit);
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(read_file(&path, "", &|| false).unwrap(), None);
|
||||
}
|
||||
#[test]
|
||||
fn unsaved_insertions_and_deletions_keep_shifted_commit_attribution() {
|
||||
let (temp, path, original) = fixture();
|
||||
fs::write(&path, "first\nmiddle\nsecond\ntail\n").unwrap();
|
||||
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Added lines"]);
|
||||
let added = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||
let rows = read_file(
|
||||
&path,
|
||||
"unsaved\nfirst\nsecond\nunsaved tail\ntail\n",
|
||||
&|| false,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(rows[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, original);
|
||||
assert_eq!(rows[2].as_ref().unwrap().commit, original);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[1].as_ref().unwrap(),
|
||||
rows[2].as_ref().unwrap()
|
||||
));
|
||||
assert!(rows[3].as_ref().unwrap().uncommitted);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[0].as_ref().unwrap(),
|
||||
rows[3].as_ref().unwrap()
|
||||
));
|
||||
assert_eq!(rows[4].as_ref().unwrap().commit, added);
|
||||
assert_eq!(rows[4].as_ref().unwrap().summary, "Added lines");
|
||||
assert_eq!(
|
||||
fs::read_to_string(path).unwrap(),
|
||||
"first\nmiddle\nsecond\ntail\n"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
let path = temp.0.join("file");
|
||||
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
||||
fs::write(&path, "text").unwrap();
|
||||
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
||||
let (_committed, tracked, _) = fixture();
|
||||
let untracked = tracked.parent().unwrap().join("untracked");
|
||||
fs::write(&untracked, "text").unwrap();
|
||||
assert_eq!(read_file(&untracked, "text", &|| false).unwrap(), None);
|
||||
let link = temp.0.join("link");
|
||||
std::os::unix::fs::symlink(tracked, &link).unwrap();
|
||||
assert_eq!(
|
||||
read_file(&link, "first\nsecond\n", &|| false).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]);
|
||||
let path = temp.0.join("file");
|
||||
fs::write(&path, "original\n").unwrap();
|
||||
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--config=user.name=\"Jj Author\"",
|
||||
"--config=user.email=\"jj@example.test\"",
|
||||
"describe",
|
||||
"-m",
|
||||
"Jj summary",
|
||||
],
|
||||
);
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--config=user.name=\"Jj Author\"",
|
||||
"--config=user.email=\"jj@example.test\"",
|
||||
"new",
|
||||
],
|
||||
);
|
||||
let parent = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@-",
|
||||
"-T",
|
||||
"commit_id",
|
||||
],
|
||||
);
|
||||
let operation = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
],
|
||||
);
|
||||
let rows = read_file(&path, "original\n", &|| false).unwrap().unwrap();
|
||||
let line = rows[0].as_ref().unwrap();
|
||||
assert_eq!(line.commit, parent);
|
||||
assert_eq!(line.author, "Jj Author");
|
||||
assert_eq!(line.summary, "Jj summary");
|
||||
fs::write(&path, "dirty on disk\n").unwrap();
|
||||
let shifted = read_file(&path, "inserted\noriginal\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(shifted[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(shifted[1].as_ref().unwrap().commit, parent);
|
||||
assert!(
|
||||
read_file(&path, "unsaved\n", &|| false).unwrap().unwrap()[0]
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.uncommitted
|
||||
);
|
||||
assert_eq!(
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id"
|
||||
]
|
||||
),
|
||||
operation
|
||||
);
|
||||
assert!(!temp.0.join(".git").exists());
|
||||
command(&temp.0, "jj", &["new", "root()"]);
|
||||
fs::write(temp.0.join("other"), "sibling\n").unwrap();
|
||||
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
||||
command(&temp.0, "jj", &["new", &parent, "@"]);
|
||||
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
|
||||
}
|
||||
#[test]
|
||||
fn incremental_groups_share_metadata_across_ranges_and_repeated_commits() {
|
||||
let first = "1111111111111111111111111111111111111111";
|
||||
let second = "2222222222222222222222222222222222222222";
|
||||
let output = format!(
|
||||
"{first} 5 3 2\nauthor First\nsummary First summary\nfilename file\n\
|
||||
{second} 8 2 1\nauthor Second\nsummary Second summary\nfilename file\n\
|
||||
{first} 1 1 1\nfilename old-file\n"
|
||||
);
|
||||
let rows = parse(output.as_bytes(), 4).unwrap();
|
||||
assert_eq!(rows[0].as_ref().unwrap().commit, first);
|
||||
assert_eq!(rows[1].as_ref().unwrap().author, "Second");
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, second);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[0].as_ref().unwrap(),
|
||||
rows[2].as_ref().unwrap()
|
||||
));
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[2].as_ref().unwrap(),
|
||||
rows[3].as_ref().unwrap()
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn malformed_incremental_groups_never_echo_metadata() {
|
||||
let commit = "1111111111111111111111111111111111111111";
|
||||
let metadata = "author secret\nsummary secret\nfilename secret\n";
|
||||
let valid = format!("{commit} 1 1 1\n{metadata}");
|
||||
for output in [
|
||||
"secret metadata".into(),
|
||||
"\u{fffd}secret metadata".into(),
|
||||
format!("{commit} 1 2 1\n{metadata}"),
|
||||
format!("{commit} 1 1 2\n{metadata}"),
|
||||
format!("{commit} 0 1 1\n{metadata}"),
|
||||
format!("{commit} 1 1 0\n{metadata}"),
|
||||
format!("{commit} 1 1 1 extra\n{metadata}"),
|
||||
format!("{commit} 1 1 1\nauthor secret\nfilename secret\n"),
|
||||
format!(
|
||||
"{commit} 1 1 1\nauthor secret\nauthor secret\nsummary secret\nfilename secret\n"
|
||||
),
|
||||
format!("{commit} 1 1 1\nauthor secret\nsummary secret\n\tsecret source\n"),
|
||||
format!("{valid}{commit} 2 1 1\nfilename secret\n"),
|
||||
format!(
|
||||
"{commit} 1 1 1\nauthor secret\nsummary secret\n{commit} 2 1 1\nfilename secret\n"
|
||||
),
|
||||
format!("{commit} {} 1 2\n{metadata}", usize::MAX),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse(output.as_bytes(), 1).unwrap_err(),
|
||||
"Invalid Git blame metadata"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
parse(valid.as_bytes(), 2).unwrap_err(),
|
||||
"Invalid Git blame metadata"
|
||||
);
|
||||
assert_eq!(
|
||||
parse(b"\xffsecret metadata", 1).unwrap_err(),
|
||||
"Invalid Git blame metadata"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn cancelled_commands_kill_and_reap_pipe_holding_children() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
let ready = temp.0.join("ready");
|
||||
let cancellation = Arc::new(AtomicBool::new(false));
|
||||
let stop = Arc::clone(&cancellation);
|
||||
let gate = ready.clone();
|
||||
let worker = thread::spawn(move || {
|
||||
let mut command = Command::new("sh");
|
||||
command
|
||||
.args([
|
||||
"-c",
|
||||
"sleep 600 & printf ready > \"$1\"; wait",
|
||||
"vamp-blame-test",
|
||||
])
|
||||
.arg(gate);
|
||||
run(command, &[], &|| stop.load(Ordering::Acquire))
|
||||
});
|
||||
while !ready.exists() && !worker.is_finished() {
|
||||
thread::yield_now();
|
||||
}
|
||||
assert!(ready.exists());
|
||||
cancellation.store(true, Ordering::Release);
|
||||
assert_eq!(worker.join().unwrap().unwrap_err(), "Blame cancelled");
|
||||
}
|
||||
#[test]
|
||||
fn worker_keeps_only_latest_request_and_bounds_input() {
|
||||
let (_temp, path, commit) = fixture();
|
||||
let mut worker = BlameWorker::start().unwrap();
|
||||
for id in 0..20 {
|
||||
worker
|
||||
.request(id, path.clone(), "first\nsecond\n".into())
|
||||
.unwrap();
|
||||
}
|
||||
loop {
|
||||
if let Some((id, result)) = worker.poll() {
|
||||
assert_eq!(id, 19);
|
||||
let rows = result.unwrap().unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, commit);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[0].as_ref().unwrap(),
|
||||
rows[1].as_ref().unwrap()
|
||||
));
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
assert_eq!(
|
||||
worker
|
||||
.request(20, path, "x".repeat(LIMIT + 1))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn subprocess_output_is_bounded() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "dd if=/dev/zero bs=1048576 count=17 2>/dev/null"]);
|
||||
assert_eq!(
|
||||
run(command, &[], &|| false).unwrap_err(),
|
||||
"Blame output exceeds 16 MiB"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/blame.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-182
@@ -363,185 +363,5 @@ pub fn labels<'de, D: Deserializer<'de>>(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn popup_partial_options_keep_command_and_search_independent() {
|
||||
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
|
||||
"command": {"anchor": "top_right"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(popup.command.anchor, PopupAnchor::TopRight);
|
||||
assert_eq!(popup.search.anchor, PopupAnchor::BottomLeft);
|
||||
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
|
||||
"command": {}, "search": {"anchor": "center_left"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(popup.command.anchor, PopupAnchor::BottomLeft);
|
||||
assert_eq!(popup.search.anchor, PopupAnchor::CenterLeft);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_popup_options_are_rejected_at_the_boundary() {
|
||||
for value in [
|
||||
serde_json::json!(null),
|
||||
serde_json::json!([]),
|
||||
serde_json::json!(7),
|
||||
serde_json::json!({"selector": {}}),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<PopupConfig>(value.clone()).is_err(),
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
for options in [
|
||||
serde_json::json!(null),
|
||||
serde_json::json!([]),
|
||||
serde_json::json!("center"),
|
||||
serde_json::json!({"anchor": null}),
|
||||
serde_json::json!({"anchor": 7}),
|
||||
serde_json::json!({"anchor": true}),
|
||||
serde_json::json!({"anchor": []}),
|
||||
serde_json::json!({"anchor": {}}),
|
||||
serde_json::json!({"anchor": "middle"}),
|
||||
serde_json::json!({"anchor": "TopLeft"}),
|
||||
serde_json::json!({"width": 72}),
|
||||
serde_json::json!({"anchor": "center", "callback": "run"}),
|
||||
] {
|
||||
for mode in ["command", "search"] {
|
||||
let mut popup = serde_json::json!({});
|
||||
popup[mode] = options.clone();
|
||||
assert!(
|
||||
serde_json::from_value::<PopupConfig>(popup).is_err(),
|
||||
"{mode}: {options}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Lines {
|
||||
#[serde(deserialize_with = "top")]
|
||||
top: ChromeLine,
|
||||
#[serde(deserialize_with = "bottom")]
|
||||
bottom: ChromeLine,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
|
||||
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||
"top": {"right": ["battery"], "tabs": {"title": "T{index}:{path}{modified}"}, "battery": {"padding": 0}},
|
||||
"bottom": {"left": [], "center": ["workspace"]}
|
||||
})).unwrap();
|
||||
assert_eq!(lines.top.left, ["tabs"]);
|
||||
assert_eq!(lines.top.segment("battery").padding, 0);
|
||||
assert!(lines.bottom.left.is_empty());
|
||||
assert_eq!(lines.bottom.right, ["language", "lsp", "position"]);
|
||||
assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_positions_accept_all_builtins_and_custom_styles() {
|
||||
let names = [
|
||||
"tabs",
|
||||
"mode",
|
||||
"pending",
|
||||
"buffer",
|
||||
"diagnostics",
|
||||
"path",
|
||||
"selections",
|
||||
"count",
|
||||
"language",
|
||||
"lsp",
|
||||
"position",
|
||||
"battery",
|
||||
"workspace",
|
||||
];
|
||||
let options = serde_json::json!({
|
||||
"left": names,
|
||||
"center": ["language"],
|
||||
"right": ["tabs"],
|
||||
"tabs": {"title": "{index}:{name}:{path}{modified}", "padding": 0},
|
||||
"mode": {"leading_edge": "<", "trailing_edge": ">"},
|
||||
"workspace": {"first_leading_edge": ""}
|
||||
});
|
||||
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||
"top": options,
|
||||
"bottom": options,
|
||||
}))
|
||||
.unwrap();
|
||||
for line in [&lines.top, &lines.bottom] {
|
||||
assert_eq!(line.left, names);
|
||||
assert_eq!(line.center, ["language"]);
|
||||
assert_eq!(line.right, ["tabs"]);
|
||||
assert_eq!(line.segment("tabs").padding, 0);
|
||||
assert_eq!(line.segment("mode").leading_edge, "<");
|
||||
assert_eq!(
|
||||
line.segment("workspace").first_leading_edge.as_deref(),
|
||||
Some("")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_placeholder_is_valid_in_every_segment_and_bar() {
|
||||
for name in [
|
||||
"tabs",
|
||||
"mode",
|
||||
"pending",
|
||||
"buffer",
|
||||
"diagnostics",
|
||||
"path",
|
||||
"selections",
|
||||
"count",
|
||||
"language",
|
||||
"lsp",
|
||||
"position",
|
||||
"battery",
|
||||
"workspace",
|
||||
] {
|
||||
let options = serde_json::json!({
|
||||
"left": [name],
|
||||
(name): {"title": "{index}:{view}{name}:{path}{modified}"}
|
||||
});
|
||||
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||
"top": options,
|
||||
"bottom": options,
|
||||
}))
|
||||
.unwrap();
|
||||
for line in [&lines.top, &lines.bottom] {
|
||||
assert_eq!(
|
||||
line.segment(name).title,
|
||||
"{index}:{view}{name}:{path}{modified}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_chrome_is_rejected_at_the_boundary() {
|
||||
for options in [
|
||||
serde_json::json!({"left": ["bad-name"]}),
|
||||
serde_json::json!({"left": null}),
|
||||
serde_json::json!({"tabs": {"padding": 65}}),
|
||||
serde_json::json!({"tabs": {"padding": null}}),
|
||||
serde_json::json!({"tabs": {"margin": 65}}),
|
||||
serde_json::json!({"tabs": {"margin": -1}}),
|
||||
serde_json::json!({"tabs": {"margin": null}}),
|
||||
serde_json::json!({"tabs": {"margin": "1"}}),
|
||||
serde_json::json!({"tabs": {"margin": 1.5}}),
|
||||
serde_json::json!({"tabs": {"leading_edge": "\n"}}),
|
||||
serde_json::json!({"tabs": {"title": "{unknown}"}}),
|
||||
serde_json::json!({"tabs": {"title": "{name"}}),
|
||||
serde_json::json!({"tabs": {"title": "name}"}}),
|
||||
serde_json::json!({"tabs": {"colour": "red"}}),
|
||||
] {
|
||||
for position in ["top", "bottom"] {
|
||||
let mut frame = serde_json::json!({"top": {}, "bottom": {}});
|
||||
frame[position] = options.clone();
|
||||
assert!(serde_json::from_value::<Lines>(frame).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/chrome.rs"]
|
||||
mod tests;
|
||||
|
||||
+3
-279
@@ -324,91 +324,7 @@ mod macos {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
unsafe extern "C" {
|
||||
fn PasteboardSynchronize(pasteboard: Object) -> u32;
|
||||
fn PasteboardGetItemIdentifier(
|
||||
pasteboard: Object,
|
||||
index: isize,
|
||||
item: *mut usize,
|
||||
) -> i32;
|
||||
fn PasteboardCopyItemFlavorData(
|
||||
pasteboard: Object,
|
||||
item: usize,
|
||||
flavor: Object,
|
||||
data: *mut Object,
|
||||
) -> i32;
|
||||
fn CFDataGetBytePtr(data: Object) -> *const u8;
|
||||
fn CFDataGetLength(data: Object) -> isize;
|
||||
}
|
||||
#[test]
|
||||
fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() {
|
||||
// A private pasteboard leaves the user's clipboard untouched.
|
||||
let _pool = unsafe { AutoreleasePool(objc_autoreleasePoolPush()) };
|
||||
let name = unsafe {
|
||||
Owned::new(CFStringCreateWithCString(
|
||||
ptr::null(),
|
||||
c"vamp-native-clipboard-regression".as_ptr(),
|
||||
0x0800_0100,
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let board = unsafe {
|
||||
let mut board = ptr::null();
|
||||
check(PasteboardCreate(name.0, &mut board)).unwrap();
|
||||
Owned::new(board).unwrap()
|
||||
};
|
||||
let reader = unsafe {
|
||||
let send: unsafe extern "C" fn(Object, Object, Object) -> Object =
|
||||
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
|
||||
send(
|
||||
objc_getClass(c"NSPasteboard".as_ptr()),
|
||||
sel_registerName(c"pasteboardWithName:".as_ptr()),
|
||||
name.0,
|
||||
)
|
||||
};
|
||||
for text in [
|
||||
"",
|
||||
"é e\u{301} 👨💻\n\0tail",
|
||||
"{\\rtf1 plain text}",
|
||||
"%!PS-Adobe-3.0\nplain",
|
||||
] {
|
||||
write_to(name.0, text).unwrap();
|
||||
assert_eq!(read_from(reader).unwrap(), text);
|
||||
unsafe {
|
||||
PasteboardSynchronize(board.0);
|
||||
let mut item = 0;
|
||||
check(PasteboardGetItemIdentifier(board.0, 1, &mut item)).unwrap();
|
||||
let flavor = Owned::new(CFStringCreateWithCString(
|
||||
ptr::null(),
|
||||
c"public.utf8-plain-text".as_ptr(),
|
||||
0x0800_0100,
|
||||
))
|
||||
.unwrap();
|
||||
let mut data = ptr::null();
|
||||
check(PasteboardCopyItemFlavorData(
|
||||
board.0, item, flavor.0, &mut data,
|
||||
))
|
||||
.unwrap();
|
||||
let data = Owned::new(data).unwrap();
|
||||
let length = CFDataGetLength(data.0) as usize;
|
||||
let bytes = if length == 0 {
|
||||
&[]
|
||||
} else {
|
||||
std::slice::from_raw_parts(CFDataGetBytePtr(data.0), length)
|
||||
};
|
||||
assert_eq!(bytes, text.as_bytes());
|
||||
}
|
||||
}
|
||||
let maximum = "x".repeat(super::super::MAX_CLIPBOARD_BYTES);
|
||||
write_to(name.0, &maximum).unwrap();
|
||||
assert_eq!(read_from(reader).unwrap(), maximum);
|
||||
write_to(name.0, &(maximum + "x")).unwrap();
|
||||
assert_eq!(
|
||||
read_from(reader).unwrap_err(),
|
||||
"System clipboard text exceeds 4 MiB"
|
||||
);
|
||||
}
|
||||
include!("../tests/unit/clipboard_macos.rs");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,197 +541,5 @@ unsafe extern "C" {
|
||||
}
|
||||
|
||||
#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"vamp-clipboard-{}-{}",
|
||||
std::process::id(),
|
||||
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn command(&self, name: &str, body: &str) -> PathBuf {
|
||||
let path = self.0.join(name);
|
||||
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn clipboard(&self, text: &[u8]) -> SystemClipboard {
|
||||
let state = self.0.join("state");
|
||||
fs::write(&state, text).unwrap();
|
||||
let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''"));
|
||||
SystemClipboard::with_commands(
|
||||
self.command("read", &format!("exec /bin/cat {state}")),
|
||||
self.command("write", &format!("exec /bin/cat > {state}")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_does_not_probe_or_spawn() {
|
||||
assert!(SystemClipboard::new().commands.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_unicode_whitespace_and_large_pipes_exactly() {
|
||||
let fixture = Fixture::new();
|
||||
let mut clipboard = fixture.clipboard(b"");
|
||||
for text in ["", " héllo\t世界🙂\r\n\n", &"é\n".repeat(300_000)] {
|
||||
clipboard.write(text).unwrap();
|
||||
assert_eq!(fs::read(fixture.0.join("state")).unwrap(), text.as_bytes());
|
||||
assert_eq!(clipboard.read().unwrap(), text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversize_and_invalid_utf8_without_leaking_contents() {
|
||||
let fixture = Fixture::new();
|
||||
let mut clipboard = fixture.clipboard(&[0xff, b'\n']);
|
||||
assert_eq!(
|
||||
clipboard.read().unwrap_err(),
|
||||
"System clipboard is not valid UTF-8"
|
||||
);
|
||||
let maximum = "é".repeat(MAX_CLIPBOARD_BYTES / 2);
|
||||
clipboard.write(&maximum).unwrap();
|
||||
assert_eq!(clipboard.read().unwrap(), maximum);
|
||||
let too_large = format!("{maximum}x");
|
||||
assert_eq!(
|
||||
clipboard.write(&too_large).unwrap_err(),
|
||||
"System clipboard text exceeds 4 MiB"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("state")).unwrap(),
|
||||
maximum.as_bytes()
|
||||
);
|
||||
fs::write(fixture.0.join("state"), &too_large).unwrap();
|
||||
assert_eq!(
|
||||
clipboard.read().unwrap_err(),
|
||||
"System clipboard text exceeds 4 MiB"
|
||||
);
|
||||
let mut incomplete = vec![b'x'; MAX_CLIPBOARD_BYTES - 1];
|
||||
incomplete.push(0xc3);
|
||||
fs::write(fixture.0.join("state"), incomplete).unwrap();
|
||||
assert_eq!(
|
||||
clipboard.read().unwrap_err(),
|
||||
"System clipboard is not valid UTF-8"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_failures_and_stderr_cannot_leak_clipboard_text() {
|
||||
let fixture = Fixture::new();
|
||||
let failed = fixture.command(
|
||||
"failed",
|
||||
"printf 'private clipboard payload' >&2\nprintf 'private clipboard payload'\nexit 23",
|
||||
);
|
||||
let mut clipboard = SystemClipboard::with_commands(failed.clone(), failed);
|
||||
let error = clipboard.read().unwrap_err();
|
||||
assert!(error.contains("failed"));
|
||||
assert!(!error.contains("private clipboard payload"));
|
||||
let error = clipboard.write("private clipboard payload").unwrap_err();
|
||||
assert!(!error.contains("private clipboard payload"));
|
||||
let missing = fixture.0.join("not-an-executable");
|
||||
let mut clipboard = SystemClipboard::with_commands(missing.clone(), missing);
|
||||
assert!(clipboard.read().unwrap_err().contains("Cannot start"));
|
||||
assert!(
|
||||
clipboard
|
||||
.write("secret")
|
||||
.unwrap_err()
|
||||
.contains("Cannot start")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_stderr_does_not_block_large_writes() {
|
||||
let fixture = Fixture::new();
|
||||
let mut clipboard = fixture.clipboard(b"");
|
||||
let state = fixture.0.join("state");
|
||||
let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''"));
|
||||
clipboard
|
||||
.commands
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.writer
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.program = fixture.command(
|
||||
"noisy",
|
||||
&format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"),
|
||||
);
|
||||
let text = "λ\n".repeat(300_000);
|
||||
clipboard.write(&text).unwrap();
|
||||
assert_eq!(clipboard.read().unwrap(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_process_cleanup_kills_and_reaps_a_running_child() {
|
||||
use std::io::Read;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
unsafe extern "C" {
|
||||
fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32;
|
||||
}
|
||||
|
||||
let fixture = Fixture::new();
|
||||
let hung = fixture.command("running", "printf R\nwhile :; do :; done");
|
||||
let mut command = Command::new(hung);
|
||||
command
|
||||
.process_group(0)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
let mut child = command.spawn().unwrap();
|
||||
let pid = child.id() as i32;
|
||||
let mut ready = [0];
|
||||
child
|
||||
.stdout
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.read_exact(&mut ready)
|
||||
.unwrap();
|
||||
assert_eq!(ready, [b'R']);
|
||||
drop(ClipboardProcess(Some(child)));
|
||||
let mut status = 0;
|
||||
// SAFETY: waitpid's output pointer is valid; WNOHANG cannot block this check.
|
||||
assert_eq!(unsafe { waitpid(pid, &mut status, 1) }, -1);
|
||||
assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immediate_injected_timeout_terminates_nonterminating_commands() {
|
||||
let fixture = Fixture::new();
|
||||
let hung = fixture.command("hung", "while :; do :; done");
|
||||
let mut clipboard = SystemClipboard::with_commands(hung.clone(), hung);
|
||||
// No assertion depends on wall-clock speed or a deadline remaining in the future.
|
||||
clipboard.timeout = Duration::ZERO;
|
||||
assert_eq!(
|
||||
clipboard.read().unwrap_err(),
|
||||
"System clipboard command timed out"
|
||||
);
|
||||
assert_eq!(
|
||||
clipboard.write("text").unwrap_err(),
|
||||
"System clipboard command timed out"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/clipboard.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-2560
File diff suppressed because it is too large
Load Diff
+2
-15233
File diff suppressed because it is too large
Load Diff
+2
-988
@@ -1068,991 +1068,5 @@ impl FilePicker {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn virtual_catalog_compacts_and_retains_file_directory_collisions() {
|
||||
let mut picker = FilePicker::from_paths(
|
||||
PathBuf::new(),
|
||||
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"]
|
||||
.map(PathBuf::from)
|
||||
.into_iter()
|
||||
.enumerate(),
|
||||
);
|
||||
assert_eq!(
|
||||
picker
|
||||
.entries()
|
||||
.map(|row| row.label.to_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
["src/deep", "x", "x"]
|
||||
);
|
||||
picker.reveal_path(Path::new("x"));
|
||||
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
|
||||
assert_eq!(picker.accept(), Some(PathBuf::from("x")));
|
||||
picker.reveal_path(Path::new("x/y"));
|
||||
assert_eq!(picker.accept(), Some(PathBuf::from("x/y")));
|
||||
picker.reveal_path(Path::new("src/deep/deleted.rs"));
|
||||
assert_eq!(picker.accept(), Some(PathBuf::from("src/deep/deleted.rs")));
|
||||
assert_eq!(picker.entries().filter(|row| !row.is_dir).count(), 3);
|
||||
let expected_index = |row: &FileRow<'_>| {
|
||||
if row.is_dir {
|
||||
None
|
||||
} else {
|
||||
Some(match row.path.to_str().unwrap() {
|
||||
"x" => 0,
|
||||
"x/y" => 1,
|
||||
"src/deep/deleted.rs" => 2,
|
||||
path => panic!("unexpected path {path}"),
|
||||
})
|
||||
}
|
||||
};
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
picker.set_view(view);
|
||||
for query in ["", "x", "deleted"] {
|
||||
picker.query = query.into();
|
||||
picker.refilter();
|
||||
let rows: Vec<_> = picker.entries().collect();
|
||||
assert!(!rows.is_empty());
|
||||
for row in rows {
|
||||
assert_eq!(row.catalog_index, expected_index(&row));
|
||||
assert!(row.status.is_none() && row.old_path.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("vamp-files-test-{}-{id}", std::process::id()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn put(&self, name: &str, text: &str) {
|
||||
let path = self.0.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
fn scan(&self) -> FilePicker {
|
||||
FilePicker::scan(self.0.clone(), FilePresentation::Floating, FileView::List).unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn paths(picker: &FilePicker) -> Vec<PathBuf> {
|
||||
picker
|
||||
.entries()
|
||||
.map(|entry| entry.path.to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_path_tokens_preserve_tree_context_selection_and_open_paths() {
|
||||
let directory = Directory::new();
|
||||
let names = [
|
||||
"src/fuzzy_parser.rs",
|
||||
"src/über_parser.rs",
|
||||
"src/other.rs",
|
||||
"tests/fuzzy_parser.rs",
|
||||
];
|
||||
for name in names {
|
||||
directory.put(name, "");
|
||||
}
|
||||
for mut picker in [
|
||||
directory.scan(),
|
||||
FilePicker::from_paths(
|
||||
directory.0.clone(),
|
||||
names.map(PathBuf::from).into_iter().enumerate(),
|
||||
),
|
||||
] {
|
||||
for view in [FileView::Tree, FileView::List] {
|
||||
picker.set_view(view);
|
||||
for (query, expected) in [
|
||||
("sr/fp RS", "src/fuzzy_parser.rs"),
|
||||
("ÜPR SRC", "src/über_parser.rs"),
|
||||
] {
|
||||
picker.query = query.into();
|
||||
picker.refilter();
|
||||
assert_eq!(
|
||||
picker
|
||||
.entries()
|
||||
.filter(|row| !row.is_dir)
|
||||
.map(|row| row.path)
|
||||
.collect::<Vec<_>>(),
|
||||
[Path::new(expected)]
|
||||
);
|
||||
assert_eq!(selected_path(&picker), Path::new(expected));
|
||||
assert_eq!(picker.accept(), Some(directory.0.join(expected)));
|
||||
if view == FileView::Tree {
|
||||
assert_eq!(paths(&picker), [Path::new("src"), Path::new(expected)]);
|
||||
}
|
||||
}
|
||||
picker.query = "rs/ü".into();
|
||||
picker.refilter();
|
||||
assert!(picker.entries().next().is_none());
|
||||
picker.clear_filter();
|
||||
assert_eq!(selected_path(&picker), Path::new("src/über_parser.rs"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanned_files_and_directories_have_no_catalog_index() {
|
||||
let directory = Directory::new();
|
||||
directory.put("nested/file.txt", "");
|
||||
let mut picker = directory.scan();
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
picker.set_view(view);
|
||||
picker.reveal_path(Path::new("nested/file.txt"));
|
||||
assert!(picker.entries().all(|row| row.catalog_index.is_none()));
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.all(|row| row.status.is_none() && row.old_path.is_none())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_change(
|
||||
path: &str,
|
||||
status: FileStatus,
|
||||
old_path: Option<&str>,
|
||||
) -> WorkingFileChange {
|
||||
WorkingFileChange {
|
||||
path: path.into(),
|
||||
status,
|
||||
old_path: old_path.map(PathBuf::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_metadata_keeps_clean_files_selection_and_filtered_views() {
|
||||
let directory = Directory::new();
|
||||
for name in ["src/clean.rs", "src/modified.rs", "added.rs", "renamed.rs"] {
|
||||
directory.put(name, "");
|
||||
}
|
||||
let mut picker = directory.scan();
|
||||
picker.presentation = FilePresentation::Docked;
|
||||
picker.set_view(FileView::Tree);
|
||||
picker.reveal_path(Path::new("src/clean.rs"));
|
||||
picker.query = "src".into();
|
||||
picker.refilter();
|
||||
picker.focused = false;
|
||||
let changes = vec![
|
||||
repository_change("src/modified.rs", FileStatus::Modified, None),
|
||||
repository_change("added.rs", FileStatus::Added, None),
|
||||
repository_change("renamed.rs", FileStatus::Renamed, Some("old.rs")),
|
||||
repository_change("vanished/deep/deleted.rs", FileStatus::Removed, None),
|
||||
];
|
||||
assert!(picker.set_repository_changes(changes.clone()));
|
||||
assert!(!picker.set_repository_changes(changes));
|
||||
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
|
||||
assert_eq!(picker.query, "src");
|
||||
assert!(!picker.focused);
|
||||
assert_eq!(picker.presentation, FilePresentation::Docked);
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
picker.set_view(view);
|
||||
for query in ["", "src", "modified.rs", "deleted.rs"] {
|
||||
picker.query = query.into();
|
||||
picker.refilter();
|
||||
let rows: Vec<_> = picker.entries().collect();
|
||||
assert!(!rows.is_empty());
|
||||
for row in rows {
|
||||
assert!(row.catalog_index.is_none());
|
||||
if row.is_dir || row.path == Path::new("src/clean.rs") {
|
||||
assert!(row.status.is_none() && row.old_path.is_none());
|
||||
} else {
|
||||
let status = match row.path.to_str().unwrap() {
|
||||
"src/modified.rs" => FileStatus::Modified,
|
||||
"added.rs" => FileStatus::Added,
|
||||
"renamed.rs" => FileStatus::Renamed,
|
||||
"vanished/deep/deleted.rs" => FileStatus::Removed,
|
||||
other => panic!("unexpected {other}"),
|
||||
};
|
||||
assert_eq!(row.status, Some(status));
|
||||
assert_eq!(
|
||||
row.old_path,
|
||||
(status == FileStatus::Renamed).then(|| Path::new("old.rs"))
|
||||
);
|
||||
}
|
||||
}
|
||||
if query.is_empty() || query == "src" {
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.any(|row| row.path == Path::new("src/clean.rs"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
picker.set_view(FileView::List);
|
||||
picker.clear_filter();
|
||||
picker.reveal_path(Path::new("src/clean.rs"));
|
||||
assert!(picker.set_repository_changes(Vec::new()));
|
||||
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
|
||||
assert_eq!(picker.entries().len(), 4);
|
||||
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.all(|row| row.status.is_none() && row.old_path.is_none())
|
||||
);
|
||||
assert!(!picker.set_repository_changes(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_catalog_leaves_survive_refresh_and_restored_files_survive_clear() {
|
||||
let directory = Directory::new();
|
||||
directory.put("keep.txt", "");
|
||||
let mut picker = directory.scan();
|
||||
let deleted = Path::new("lost/deep/deleted.txt");
|
||||
picker.set_repository_changes(vec![repository_change(
|
||||
"lost/deep/deleted.txt",
|
||||
FileStatus::Removed,
|
||||
None,
|
||||
)]);
|
||||
picker.set_view(FileView::Tree);
|
||||
assert_eq!(
|
||||
picker.entries().find(|row| row.is_dir).unwrap().label,
|
||||
"lost/deep"
|
||||
);
|
||||
picker.reveal_path(deleted);
|
||||
picker.query = "deleted".into();
|
||||
picker.refilter();
|
||||
picker.start_search();
|
||||
picker.focused = false;
|
||||
picker.refresh();
|
||||
assert_eq!(selected_path(&picker), deleted);
|
||||
assert!(picker.searching && !picker.focused);
|
||||
assert_eq!(picker.query, "deleted");
|
||||
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
|
||||
assert!(!directory.0.join(deleted).exists());
|
||||
assert_eq!(
|
||||
picker.entries().find(|row| !row.is_dir).unwrap().status,
|
||||
Some(FileStatus::Removed)
|
||||
);
|
||||
directory.put("lost/deep/deleted.txt", "restored");
|
||||
assert!(picker.set_repository_changes(vec![repository_change(
|
||||
"lost/deep/deleted.txt",
|
||||
FileStatus::Removed,
|
||||
None,
|
||||
)]));
|
||||
picker.refresh();
|
||||
assert_eq!(selected_path(&picker), deleted);
|
||||
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
||||
assert!(picker.set_repository_changes(Vec::new()));
|
||||
assert_eq!(selected_path(&picker), deleted);
|
||||
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.0.join(deleted)).unwrap(),
|
||||
"restored"
|
||||
);
|
||||
assert!(picker.entries().all(|row| row.status.is_none()));
|
||||
fs::remove_dir_all(directory.0.join("lost")).unwrap();
|
||||
picker.refresh();
|
||||
picker.set_repository_changes(vec![repository_change(
|
||||
"lost/deep/deleted.txt",
|
||||
FileStatus::Removed,
|
||||
None,
|
||||
)]);
|
||||
picker.reveal_path(deleted);
|
||||
picker.set_repository_changes(Vec::new());
|
||||
assert!(picker.entries().len() == 0);
|
||||
picker.clear_filter();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("keep.txt")]);
|
||||
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_metadata_cannot_introduce_unsafe_or_existing_excluded_paths() {
|
||||
let directory = Directory::new();
|
||||
directory.put(".gitignore", "ignored/\n");
|
||||
directory.put("ignored/hidden.txt", "");
|
||||
directory.put("directory/keep.txt", "");
|
||||
let mut picker = directory.scan();
|
||||
let mut changes: Vec<_> = [
|
||||
"",
|
||||
".",
|
||||
"../outside.txt",
|
||||
"directory/../../outside.txt",
|
||||
".git/secret",
|
||||
"nested/.jj/secret",
|
||||
"/outside.txt",
|
||||
"directory",
|
||||
"ignored/hidden.txt",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|path| repository_change(path, FileStatus::Removed, None))
|
||||
.collect();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&directory.0, directory.0.join("link")).unwrap();
|
||||
changes.push(repository_change(
|
||||
"link/missing.txt",
|
||||
FileStatus::Removed,
|
||||
None,
|
||||
));
|
||||
}
|
||||
changes.push(repository_change("missing.txt", FileStatus::Removed, None));
|
||||
picker.set_repository_changes(changes);
|
||||
assert_eq!(
|
||||
paths(&picker),
|
||||
[".gitignore", "directory/keep.txt", "missing.txt"].map(PathBuf::from)
|
||||
);
|
||||
assert_eq!(
|
||||
picker.entries().filter(|row| row.status.is_some()).count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
picker.entries().last().unwrap().status,
|
||||
Some(FileStatus::Removed)
|
||||
);
|
||||
assert!(!directory.0.join("missing.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
|
||||
let directory = Directory::new();
|
||||
directory.put(".gitignore", "*.log\nignored/\n");
|
||||
directory.put("nested/.gitignore", "!keep.log\nlocal.txt\n");
|
||||
for name in [
|
||||
"gone.log",
|
||||
"nested/gone.log",
|
||||
"nested/keep.log",
|
||||
"nested/local.txt",
|
||||
"nested/ok.txt",
|
||||
"ignored/hidden.txt",
|
||||
".hidden",
|
||||
".git/objects/secret",
|
||||
"nested/.git/secret",
|
||||
".jj/secret",
|
||||
"nested/.jj/secret",
|
||||
] {
|
||||
directory.put(name, "text");
|
||||
}
|
||||
let picker = directory.scan();
|
||||
assert_eq!(
|
||||
paths(&picker),
|
||||
[
|
||||
".gitignore",
|
||||
".hidden",
|
||||
"nested/.gitignore",
|
||||
"nested/keep.log",
|
||||
"nested/ok.txt"
|
||||
]
|
||||
.map(PathBuf::from)
|
||||
);
|
||||
assert!(picker.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_unicode_tokens_grapheme_deletion_and_navigation() {
|
||||
let directory = Directory::new();
|
||||
for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] {
|
||||
directory.put(name, "text");
|
||||
}
|
||||
let mut picker = directory.scan();
|
||||
picker.navigate(false);
|
||||
assert_eq!(picker.selected, 2);
|
||||
picker.navigate(true);
|
||||
assert_eq!(picker.selected, 0);
|
||||
picker.query = "界 SPACE [E\u{301}]".into();
|
||||
picker.refilter();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]);
|
||||
picker.query = "e\u{301}👩💻".into();
|
||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||
assert_eq!(picker.query, "e\u{301}");
|
||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||
assert_eq!(picker.query, "");
|
||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||
assert_eq!(picker.entries().len(), 3);
|
||||
picker.query = "no match".into();
|
||||
picker.refilter();
|
||||
for forward in [true, false] {
|
||||
picker.navigate(forward);
|
||||
assert_eq!(picker.selected, 0);
|
||||
assert_eq!(picker.entries().len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_and_line_deletion_refilter_only_editable_queries() {
|
||||
use crate::editor::Input;
|
||||
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
let mut picker = FilePicker::from_paths(
|
||||
PathBuf::new(),
|
||||
["alpha one.txt", "alpha two.txt", "beta.txt"]
|
||||
.map(PathBuf::from)
|
||||
.into_iter()
|
||||
.enumerate(),
|
||||
);
|
||||
picker.set_view(view);
|
||||
picker.query = "alpha missing".into();
|
||||
picker.refilter();
|
||||
assert_eq!(picker.entries().len(), 0);
|
||||
if view == FileView::Tree {
|
||||
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
||||
assert!(!picker.handle_filter_input(&input));
|
||||
assert_eq!(picker.query, "alpha missing");
|
||||
}
|
||||
picker.start_search();
|
||||
}
|
||||
assert!(picker.handle_filter_input(&Input::AltBackspace));
|
||||
assert_eq!(picker.query, "alpha ");
|
||||
assert_eq!(picker.entries().len(), 2);
|
||||
assert!(picker.handle_filter_input(&Input::SuperBackspace));
|
||||
assert_eq!(picker.query, "");
|
||||
assert_eq!(picker.entries().len(), 3);
|
||||
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
||||
assert!(picker.handle_filter_input(&input));
|
||||
assert_eq!(picker.entries().len(), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_scan_refresh_and_failure_preserve_reachable_state() {
|
||||
let directory = Directory::new();
|
||||
let mut picker = directory.scan();
|
||||
picker.navigate(false);
|
||||
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
||||
assert_eq!(picker.entries().len(), 0);
|
||||
directory.put("new.txt", "text");
|
||||
picker.query = "new".into();
|
||||
picker.refresh();
|
||||
assert_eq!(picker.query, "new");
|
||||
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
|
||||
fs::remove_dir_all(&directory.0).unwrap();
|
||||
picker.refresh();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
|
||||
assert!(picker.error.as_ref().unwrap().contains("Refresh failed"));
|
||||
assert!(
|
||||
FilePicker::scan(
|
||||
directory.0.clone(),
|
||||
FilePresentation::Docked,
|
||||
FileView::Tree
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ignore_warning_survives_query_and_navigation() {
|
||||
let directory = Directory::new();
|
||||
directory.put(".gitignore", "[z-a]\n");
|
||||
directory.put("ok.txt", "text");
|
||||
let mut picker = directory.scan();
|
||||
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
|
||||
picker.query = "ok".into();
|
||||
picker.refilter();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("ok.txt")]);
|
||||
picker.navigate(true);
|
||||
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
|
||||
directory.put(".gitignore", "");
|
||||
picker.refresh();
|
||||
assert!(picker.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_project_ignore_rules_apply_and_non_directory_roots_fail() {
|
||||
let directory = Directory::new();
|
||||
directory.put(".gitignore", "*.txt\n");
|
||||
directory.put("project/visible.txt", "text");
|
||||
let root = directory.0.join("project");
|
||||
let picker = FilePicker::scan(root, FilePresentation::Floating, FileView::List).unwrap();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]);
|
||||
assert!(
|
||||
FilePicker::scan(
|
||||
directory.0.join("project/visible.txt"),
|
||||
FilePresentation::Docked,
|
||||
FileView::Tree,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_loops_and_file_links_are_not_followed_and_raw_paths_survive() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let directory = Directory::new();
|
||||
directory.put("real.txt", "text");
|
||||
symlink(&directory.0, directory.0.join("loop")).unwrap();
|
||||
symlink(directory.0.join("real.txt"), directory.0.join("alias.txt")).unwrap();
|
||||
let raw = PathBuf::from("raw界\n.txt");
|
||||
fs::write(directory.0.join(&raw), "text").unwrap();
|
||||
let picker = directory.scan();
|
||||
assert_eq!(picker.entries().len(), 2);
|
||||
let entry = picker
|
||||
.entries()
|
||||
.find(|entry| entry.path == raw.as_path())
|
||||
.unwrap();
|
||||
assert!(!entry.label.chars().any(char::is_control));
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.0.join(entry.path)).unwrap(),
|
||||
"text"
|
||||
);
|
||||
}
|
||||
|
||||
fn tree(directory: &Directory) -> FilePicker {
|
||||
FilePicker::scan(
|
||||
directory.0.clone(),
|
||||
FilePresentation::Docked,
|
||||
FileView::Tree,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn select(picker: &mut FilePicker, path: &str) {
|
||||
let selected = picker
|
||||
.entries()
|
||||
.position(|row| row.path == Path::new(path))
|
||||
.unwrap();
|
||||
picker.selected = selected;
|
||||
}
|
||||
|
||||
fn selected_path(picker: &FilePicker) -> &Path {
|
||||
picker.entries().nth(picker.selected).unwrap().path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_orders_folders_first_keeps_empty_dirs_and_compacts_only_directory_chains() {
|
||||
let directory = Directory::new();
|
||||
directory.put("a.txt", "");
|
||||
directory.put("folder/only.txt", "");
|
||||
directory.put("chain/one/two/a.txt", "");
|
||||
directory.put("chain/one/two/z.txt", "");
|
||||
fs::create_dir(directory.0.join("empty")).unwrap();
|
||||
let mut picker = tree(&directory);
|
||||
assert_eq!(picker.view, FileView::Tree);
|
||||
assert!(!picker.searching);
|
||||
assert_eq!(
|
||||
picker
|
||||
.entries()
|
||||
.map(|row| (row.label, row.is_dir, row.depth))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
("chain/one/two", true, 0),
|
||||
("empty", true, 0),
|
||||
("folder", true, 0),
|
||||
("a.txt", false, 0)
|
||||
]
|
||||
);
|
||||
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
|
||||
picker.right();
|
||||
assert_eq!(
|
||||
picker
|
||||
.entries()
|
||||
.map(|row| (row.label, row.depth))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
("chain/one/two", 0),
|
||||
("a.txt", 1),
|
||||
("z.txt", 1),
|
||||
("empty", 0),
|
||||
("folder", 0),
|
||||
("a.txt", 0)
|
||||
]
|
||||
);
|
||||
picker.right();
|
||||
assert_eq!(selected_path(&picker), Path::new("chain/one/two/a.txt"));
|
||||
let rows = paths(&picker);
|
||||
picker.right();
|
||||
assert_eq!(paths(&picker), rows);
|
||||
picker.left();
|
||||
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
|
||||
picker.left();
|
||||
assert!(!picker.entries().next().unwrap().expanded);
|
||||
picker.left();
|
||||
assert_eq!(picker.selected, 0);
|
||||
select(&mut picker, "folder");
|
||||
assert!(picker.accept().is_none());
|
||||
assert_eq!(
|
||||
picker.entries().nth(picker.selected + 1).unwrap().label,
|
||||
"only.txt"
|
||||
);
|
||||
picker.right();
|
||||
assert_eq!(picker.accept(), Some(directory.0.join("folder/only.txt")));
|
||||
select(&mut picker, "empty");
|
||||
picker.right();
|
||||
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
||||
picker.right();
|
||||
assert_eq!(selected_path(&picker), Path::new("empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_search_reveals_ancestors_without_mutating_saved_expansion_or_compaction() {
|
||||
let directory = Directory::new();
|
||||
directory.put("src/module/deep/needle.txt", "");
|
||||
directory.put("src/module/sibling.txt", "");
|
||||
directory.put("src/root.txt", "");
|
||||
directory.put("other/keep.txt", "");
|
||||
let mut picker = tree(&directory);
|
||||
select(&mut picker, "other");
|
||||
picker.right();
|
||||
select(&mut picker, "src");
|
||||
let expanded = picker.expanded.clone();
|
||||
picker.start_search();
|
||||
picker.query = "NEEDLE".into();
|
||||
picker.refilter();
|
||||
assert_eq!(
|
||||
picker
|
||||
.entries()
|
||||
.map(|row| (row.label, row.depth))
|
||||
.collect::<Vec<_>>(),
|
||||
[("src", 0), ("module", 1), ("deep", 2), ("needle.txt", 3)]
|
||||
);
|
||||
assert_eq!(
|
||||
selected_path(&picker),
|
||||
Path::new("src/module/deep/needle.txt")
|
||||
);
|
||||
assert_eq!(picker.expanded, expanded);
|
||||
picker.left();
|
||||
let filtered = paths(&picker);
|
||||
picker.finish_search();
|
||||
assert_eq!(picker.query, "NEEDLE");
|
||||
assert_eq!(paths(&picker), filtered);
|
||||
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
|
||||
assert_eq!(picker.expanded, expanded);
|
||||
picker.start_search();
|
||||
picker.query = "deep".into();
|
||||
picker.refilter();
|
||||
select(&mut picker, "src/module/deep");
|
||||
assert!(picker.accept().is_none());
|
||||
assert!(!picker.searching);
|
||||
assert_eq!(picker.query, "deep");
|
||||
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
|
||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||
assert_eq!(picker.expanded, expanded);
|
||||
picker.right();
|
||||
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
||||
assert_eq!(picker.query, "deep");
|
||||
|
||||
let chain = Directory::new();
|
||||
chain.put("one/two/three/a.txt", "");
|
||||
chain.put("one/two/three/b.txt", "");
|
||||
let mut picker = tree(&chain);
|
||||
picker.start_search();
|
||||
picker.query = "a.txt".into();
|
||||
picker.refilter();
|
||||
assert_eq!(
|
||||
picker.entries().map(|row| row.label).collect::<Vec<_>>(),
|
||||
["one/two/three", "a.txt"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_switches_restore_directory_choices_and_reveal_explicit_file_choices() {
|
||||
let directory = Directory::new();
|
||||
directory.put("src/a.txt", "");
|
||||
directory.put("src/b.txt", "");
|
||||
directory.put("z.txt", "");
|
||||
let mut picker = tree(&directory);
|
||||
picker.right();
|
||||
let open = paths(&picker);
|
||||
picker.set_view(FileView::List);
|
||||
assert_eq!(
|
||||
paths(&picker),
|
||||
["src/a.txt", "src/b.txt", "z.txt"].map(PathBuf::from)
|
||||
);
|
||||
picker.set_view(FileView::Tree);
|
||||
assert_eq!(selected_path(&picker), Path::new("src"));
|
||||
assert_eq!(paths(&picker), open);
|
||||
picker.left();
|
||||
picker.toggle_view();
|
||||
picker.navigate(true);
|
||||
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
||||
picker.toggle_view();
|
||||
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
||||
assert!(picker.entries().next().unwrap().expanded);
|
||||
picker.left();
|
||||
picker.toggle_view();
|
||||
picker.toggle_view();
|
||||
assert_eq!(selected_path(&picker), Path::new("src"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtering_and_refresh_preserve_raw_selection_and_surviving_expansion() {
|
||||
let directory = Directory::new();
|
||||
directory.put("src/a.txt", "");
|
||||
directory.put("src/b.txt", "");
|
||||
directory.put("z.txt", "");
|
||||
let mut picker = directory.scan();
|
||||
select(&mut picker, "src/b.txt");
|
||||
picker.query = "absent".into();
|
||||
picker.refilter();
|
||||
assert_eq!(picker.entries().len(), 0);
|
||||
picker.query.clear();
|
||||
picker.refilter();
|
||||
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
||||
picker.set_view(FileView::Tree);
|
||||
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
||||
fs::remove_file(directory.0.join("src/b.txt")).unwrap();
|
||||
directory.put("src/c.txt", "");
|
||||
picker.refresh();
|
||||
assert_eq!(selected_path(&picker), Path::new("src"));
|
||||
assert!(picker.entries().next().unwrap().expanded);
|
||||
assert!(paths(&picker).contains(&PathBuf::from("src/c.txt")));
|
||||
fs::remove_dir_all(directory.0.join("src")).unwrap();
|
||||
picker.refresh();
|
||||
assert_eq!(selected_path(&picker), Path::new("z.txt"));
|
||||
assert!(picker.expanded.is_empty());
|
||||
picker.toggle_view();
|
||||
picker.toggle_view();
|
||||
assert_eq!(selected_path(&picker), Path::new("z.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_search_subfocus_and_expansion_survive_view_toggles_and_refresh() {
|
||||
let directory = Directory::new();
|
||||
directory.put("src/deep/a.txt", "");
|
||||
directory.put("src/other.txt", "");
|
||||
let mut picker = tree(&directory);
|
||||
picker.start_search();
|
||||
assert!(picker.searching);
|
||||
assert_eq!(picker.entries().len(), 4);
|
||||
assert!(picker.expanded.is_empty());
|
||||
picker.toggle_view();
|
||||
assert_eq!(picker.view, FileView::List);
|
||||
picker.toggle_view();
|
||||
assert!(picker.searching);
|
||||
assert!(picker.expanded.is_empty());
|
||||
picker.query = "a.txt".into();
|
||||
picker.refilter();
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
||||
picker.refresh();
|
||||
assert!(picker.searching);
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
||||
let filtered = paths(&picker);
|
||||
picker.finish_search();
|
||||
assert!(!picker.searching);
|
||||
assert_eq!(picker.query, "a.txt");
|
||||
assert_eq!(paths(&picker), filtered);
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
||||
assert!(picker.expanded.is_empty());
|
||||
picker.toggle_view();
|
||||
picker.refresh();
|
||||
picker.toggle_view();
|
||||
assert!(!picker.searching);
|
||||
assert_eq!(picker.query, "a.txt");
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
||||
picker.start_search();
|
||||
assert!(picker.searching);
|
||||
assert_eq!(picker.query, "a.txt");
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_filter_preserves_mode_focus_current_path_and_empty_result_fallback() {
|
||||
let directory = Directory::new();
|
||||
directory.put("src/deep/needle.txt", "");
|
||||
directory.put("src/other.txt", "");
|
||||
directory.put("other/keep.txt", "");
|
||||
for (view, searching) in [
|
||||
(FileView::Tree, false),
|
||||
(FileView::Tree, true),
|
||||
(FileView::List, false),
|
||||
] {
|
||||
let mut picker = tree(&directory);
|
||||
select(&mut picker, "other");
|
||||
picker.right();
|
||||
let expanded = picker.expanded.clone();
|
||||
picker.set_view(view);
|
||||
if view == FileView::Tree {
|
||||
picker.start_search();
|
||||
}
|
||||
picker.query = "needle".into();
|
||||
picker.refilter();
|
||||
if !searching && view == FileView::Tree {
|
||||
picker.finish_search();
|
||||
}
|
||||
let expected = Path::new("src/deep/needle.txt");
|
||||
assert_eq!(selected_path(&picker), expected);
|
||||
picker.clear_filter();
|
||||
assert!(picker.query.is_empty());
|
||||
assert_eq!(picker.searching, searching);
|
||||
assert!(picker.focused);
|
||||
assert_eq!(selected_path(&picker), expected);
|
||||
assert!(picker.expanded.is_superset(&expanded));
|
||||
assert!(paths(&picker).contains(&PathBuf::from("other/keep.txt")));
|
||||
let rows = paths(&picker);
|
||||
let expanded = picker.expanded.clone();
|
||||
picker.clear_filter();
|
||||
assert_eq!(paths(&picker), rows);
|
||||
assert_eq!(picker.expanded, expanded);
|
||||
assert_eq!(selected_path(&picker), expected);
|
||||
assert_eq!(picker.searching, searching);
|
||||
|
||||
picker.query = "absent".into();
|
||||
picker.refilter();
|
||||
picker.navigate(true);
|
||||
picker.navigate(false);
|
||||
assert!(picker.rows.is_empty());
|
||||
picker.refresh();
|
||||
assert!(picker.rows.is_empty());
|
||||
picker.clear_filter();
|
||||
assert_eq!(selected_path(&picker), expected);
|
||||
assert_eq!(picker.searching, searching);
|
||||
assert!(picker.focused);
|
||||
}
|
||||
|
||||
let empty = Directory::new();
|
||||
let mut picker = tree(&empty);
|
||||
picker.start_search();
|
||||
picker.query = "absent".into();
|
||||
picker.refilter();
|
||||
picker.finish_search();
|
||||
picker.clear_filter();
|
||||
assert!(picker.rows.is_empty());
|
||||
assert_eq!(picker.selected, 0);
|
||||
assert!(!picker.searching);
|
||||
assert!(picker.focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_navigation_toggles_context_folders_and_query_edits_reveal_matches() {
|
||||
let directory = Directory::new();
|
||||
directory.put("src/deep/needle.txt", "");
|
||||
directory.put("src/other.txt", "");
|
||||
let mut picker = tree(&directory);
|
||||
picker.start_search();
|
||||
picker.query = "needle".into();
|
||||
picker.refilter();
|
||||
let expanded = picker.expanded.clone();
|
||||
picker.left();
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
||||
picker.right();
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
||||
assert_eq!(picker.expanded, expanded);
|
||||
picker.finish_search();
|
||||
picker.left();
|
||||
picker.left();
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||
assert_eq!(picker.query, "needle");
|
||||
assert!(!paths(&picker).contains(&PathBuf::from("src/deep/needle.txt")));
|
||||
picker.start_search();
|
||||
picker.finish_search();
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||
picker.right();
|
||||
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
||||
picker.right();
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
||||
picker.left();
|
||||
assert!(picker.accept().is_none());
|
||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||
assert!(picker.accept().is_none());
|
||||
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
||||
picker.start_search();
|
||||
assert!(picker.accept().is_none());
|
||||
assert!(!picker.searching);
|
||||
assert_eq!(picker.query, "needle");
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||
picker.start_search();
|
||||
picker.query.push_str(".txt");
|
||||
picker.refilter();
|
||||
assert!(picker.filter_collapsed.is_empty());
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
||||
picker.query.clear();
|
||||
picker.refilter();
|
||||
picker.finish_search();
|
||||
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_collapse_survives_refresh_compaction_and_view_switching_by_raw_path() {
|
||||
let directory = Directory::new();
|
||||
directory.put("one/a.txt", "");
|
||||
directory.put("one/two/needle.txt", "");
|
||||
let mut picker = tree(&directory);
|
||||
picker.start_search();
|
||||
picker.query = "needle".into();
|
||||
picker.refilter();
|
||||
picker.finish_search();
|
||||
picker.left();
|
||||
picker.left();
|
||||
picker.left();
|
||||
picker.left();
|
||||
assert_eq!(selected_path(&picker), Path::new("one"));
|
||||
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
||||
picker.toggle_view();
|
||||
assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt"));
|
||||
picker.toggle_view();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
||||
assert!(!picker.searching);
|
||||
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
|
||||
picker.focused = false;
|
||||
picker.refresh();
|
||||
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
||||
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
|
||||
assert!(!picker.searching && !picker.focused);
|
||||
assert_eq!(picker.query, "needle");
|
||||
directory.put("one/a.txt", "");
|
||||
picker.refresh();
|
||||
assert_eq!(selected_path(&picker), Path::new("one"));
|
||||
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
||||
picker.right();
|
||||
picker.right();
|
||||
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||
picker.right();
|
||||
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
||||
picker.right();
|
||||
assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt"));
|
||||
fs::remove_file(directory.0.join("one/two/needle.txt")).unwrap();
|
||||
picker.refresh();
|
||||
assert!(picker.entries().len() == 0);
|
||||
picker.clear_filter();
|
||||
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
||||
assert!(!picker.searching && !picker.focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_preserves_expansion_when_directory_chains_merge_and_split() {
|
||||
let directory = Directory::new();
|
||||
directory.put("one/a.txt", "");
|
||||
directory.put("one/two/b.txt", "");
|
||||
let mut picker = tree(&directory);
|
||||
picker.right();
|
||||
assert_eq!(
|
||||
paths(&picker),
|
||||
["one", "one/two", "one/a.txt"].map(PathBuf::from)
|
||||
);
|
||||
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
|
||||
picker.refresh();
|
||||
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
||||
assert_eq!(
|
||||
paths(&picker),
|
||||
["one/two", "one/two/b.txt"].map(PathBuf::from)
|
||||
);
|
||||
assert!(picker.entries().next().unwrap().expanded);
|
||||
directory.put("one/a.txt", "");
|
||||
picker.refresh();
|
||||
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
||||
assert_eq!(
|
||||
paths(&picker),
|
||||
["one", "one/two", "one/a.txt"].map(PathBuf::from)
|
||||
);
|
||||
assert!(picker.entries().next().unwrap().expanded);
|
||||
assert!(!picker.entries().nth(1).unwrap().expanded);
|
||||
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
|
||||
picker.refresh();
|
||||
picker.left();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
|
||||
directory.put("one/a.txt", "");
|
||||
picker.refresh();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
||||
assert!(!picker.entries().next().unwrap().expanded);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/files.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-427
@@ -812,430 +812,5 @@ pub(crate) fn resolve(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn parse(url: &str, provider: Option<&str>) -> Result<Forge, String> {
|
||||
Forge::parse(url, provider, |destination, _| {
|
||||
Ok(destination.rsplit('@').next().unwrap().to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
|
||||
for (provider, url, query) in [
|
||||
(
|
||||
Provider::Github,
|
||||
"https://github.com/owner/repo",
|
||||
"state=open&per_page=50&sort=created&direction=desc&page=3",
|
||||
),
|
||||
(
|
||||
Provider::Gitlab,
|
||||
"https://gitlab.com/owner/repo",
|
||||
"state=opened&scope=all&per_page=50&order_by=created_at&sort=desc&page=3",
|
||||
),
|
||||
(
|
||||
Provider::Gitea,
|
||||
"https://codeberg.org/owner/repo",
|
||||
"state=open&limit=50&page=3",
|
||||
),
|
||||
] {
|
||||
let forge = parse(url, None).unwrap();
|
||||
let mut calls = 0;
|
||||
let page = forge.page_with(false, 3, |url| {
|
||||
calls += 1;
|
||||
assert_eq!(url, format!("{}?{query}", forge.endpoint));
|
||||
Ok(json!([
|
||||
{"number": 1, "iid": 1, "title": "Older", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||
"user": null, "author": null, "merged_at": null, "merged": false, "created_at": "2024-01-01T01:00:00+01:00"},
|
||||
{"number": 2, "iid": 2, "title": "Newer", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||
"user": {"login": "author"}, "author": {"username": "author"}, "merged_at": null, "merged": false,
|
||||
"created_at": "2024-01-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}}
|
||||
]))
|
||||
}).unwrap();
|
||||
assert_eq!(calls, 1);
|
||||
assert_eq!(
|
||||
page.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
|
||||
[2, 1]
|
||||
);
|
||||
assert_eq!(page.entries[0].description, "author · open");
|
||||
assert!(page.entries[1].description.contains("deleted user"));
|
||||
assert!(
|
||||
page.has_more,
|
||||
"A short, server-capped page is not exhaustion"
|
||||
);
|
||||
let empty = forge.page_with(false, 4, |_| Ok(json!([]))).unwrap();
|
||||
assert!(empty.entries.is_empty() && !empty.has_more);
|
||||
assert!(
|
||||
forge
|
||||
.page_with(false, 0, |_| panic!("Page zero must not request"))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
|
||||
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
|
||||
let mut all = Vec::new();
|
||||
for page in 1..=3 {
|
||||
let mut states = Vec::new();
|
||||
let result = forge
|
||||
.page_with(true, page, |url| {
|
||||
assert!(url.ends_with(&format!("&page={page}")));
|
||||
let state = if url.contains("?state=closed&") {
|
||||
"closed"
|
||||
} else {
|
||||
assert!(url.contains("?state=merged&"));
|
||||
"merged"
|
||||
};
|
||||
states.push(state);
|
||||
let (id, date) = match (state, page) {
|
||||
("closed", 1) => (1, "2024-01-01T00:00:00Z"),
|
||||
("merged", 1) => (3, "2024-03-01T00:00:00Z"),
|
||||
("merged", 2) => (2, "2024-02-01T00:00:00Z"),
|
||||
_ => return Ok(json!([])),
|
||||
};
|
||||
Ok(json!([{"iid": id, "title": state, "state": state,
|
||||
"author": null, "created_at": date, "diff_refs": null}]))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(states, ["closed", "merged"]);
|
||||
assert_eq!(result.has_more, page != 3);
|
||||
assert!(result.entries.iter().all(|p| p.closed));
|
||||
if page == 1 {
|
||||
assert_eq!(
|
||||
result.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
|
||||
[3, 1]
|
||||
);
|
||||
}
|
||||
// The second merged page still precedes the first closed page.
|
||||
all.extend(result.entries);
|
||||
}
|
||||
all.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
assert_eq!(all.iter().map(|p| p.number).collect::<Vec<_>>(), [3, 2, 1]);
|
||||
let raced = forge.page_with(true, 1, |url| {
|
||||
Ok(json!([{"iid": 7, "title": "Raced merge", "state": if url.contains("?state=merged&") { "merged" } else { "closed" },
|
||||
"author": null, "created_at": "2024-01-01T00:00:00Z"}]))
|
||||
}).unwrap();
|
||||
assert_eq!(raced.entries.len(), 1);
|
||||
assert!(raced.entries[0].description.contains("merged"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_normalize_offsets_precision_and_calendar_boundaries() {
|
||||
for (input, expected) in [
|
||||
(
|
||||
"2024-03-01T00:30:12.5+01:00",
|
||||
"2024-02-29T23:30:12.500000000Z",
|
||||
),
|
||||
(
|
||||
"2023-12-31T23:30:00-01:00",
|
||||
"2024-01-01T00:30:00.000000000Z",
|
||||
),
|
||||
(
|
||||
"2024-01-01T00:00:00.000000001Z",
|
||||
"2024-01-01T00:00:00.000000001Z",
|
||||
),
|
||||
] {
|
||||
assert_eq!(created_at(&json!({"created_at": input})).unwrap(), expected);
|
||||
}
|
||||
for input in [
|
||||
"",
|
||||
"not a date",
|
||||
"2023-02-29T00:00:00Z",
|
||||
"2024-13-01T00:00:00Z",
|
||||
"2024-01-01T24:00:00Z",
|
||||
"2024-01-01T00:00:00",
|
||||
"2024-01-01T00:00:00+24:00",
|
||||
"2024-01-01T00:00:00.Z",
|
||||
"2024-01-01T00:00:00.1234567890Z",
|
||||
"0001-01-01T00:00:00+01:00",
|
||||
"9999-12-31T23:00:00-02:00",
|
||||
] {
|
||||
assert!(
|
||||
created_at(&json!({"created_at": input})).is_err(),
|
||||
"{input}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_reads_only_selected_detail_and_exact_preserved_head() {
|
||||
for (url, provider) in [
|
||||
("https://github.com/owner/repo", Provider::Github),
|
||||
("https://gitlab.com/group/repo", Provider::Gitlab),
|
||||
("https://codeberg.org/owner/repo", Provider::Gitea),
|
||||
] {
|
||||
let forge = parse(url, None).unwrap();
|
||||
let mut calls = Vec::new();
|
||||
let pr = forge.resolve_with(7, |url| {
|
||||
calls.push(url.to_owned());
|
||||
if url == format!("{}/7", forge.endpoint) {
|
||||
Ok(json!({"number": 7, "iid": 7, "title": "Selected", "state": if provider == Provider::Gitlab { "merged" } else { "closed" },
|
||||
"merged_at": "2024-01-01T00:00:00Z", "merged": true, "user": null, "author": null,
|
||||
"base": {"ref": "main", "sha": "1".repeat(40)}, "head": {"ref": "topic", "sha": "2".repeat(40)},
|
||||
"merge_base": "3".repeat(40), "target_branch": "main", "source_branch": "topic",
|
||||
"diff_refs": {"start_sha": null, "base_sha": "4".repeat(40), "head_sha": "5".repeat(40)},
|
||||
"url": "https://evil.example/detail", "references": "https://evil.example/refs"}))
|
||||
} else {
|
||||
assert_eq!(url, format!("{}/git/refs/pull/7/head", forge.endpoint.strip_suffix("/pulls").unwrap()));
|
||||
Ok(json!([{"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "6".repeat(40)}}]))
|
||||
}
|
||||
}).unwrap();
|
||||
assert_eq!(calls.len(), if provider == Provider::Gitea { 2 } else { 1 });
|
||||
let revisions = pr.revisions.unwrap();
|
||||
assert_eq!(
|
||||
revisions.base_sha,
|
||||
match provider {
|
||||
Provider::Github => "1",
|
||||
Provider::Gitlab => "4",
|
||||
Provider::Gitea => "3",
|
||||
}
|
||||
.repeat(40)
|
||||
);
|
||||
assert_eq!(
|
||||
revisions.head_sha,
|
||||
match provider {
|
||||
Provider::Github => "2",
|
||||
Provider::Gitlab => "5",
|
||||
Provider::Gitea => "6",
|
||||
}
|
||||
.repeat(40)
|
||||
);
|
||||
assert!(
|
||||
forge
|
||||
.resolve_with(0, |_| panic!("Zero must not request"))
|
||||
.is_err()
|
||||
);
|
||||
let mut calls = 0;
|
||||
assert!(
|
||||
forge
|
||||
.resolve_with(7, |_| {
|
||||
calls += 1;
|
||||
Ok(json!({"number": 8, "iid": 8}))
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
calls, 1,
|
||||
"Reject wrong detail identity before reading historical refs"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitea_preserved_heads_reject_missing_ambiguous_or_foreign_refs() {
|
||||
let reference =
|
||||
json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "a".repeat(40)}});
|
||||
assert_eq!(gitea_head(&reference, 7).unwrap(), "a".repeat(40));
|
||||
assert_eq!(
|
||||
gitea_head(&json!([reference.clone()]), 7).unwrap(),
|
||||
"a".repeat(40)
|
||||
);
|
||||
for bad in [
|
||||
json!([]),
|
||||
json!([reference.clone(), reference.clone()]),
|
||||
json!({"ref": "refs/pull/8/head", "object": reference["object"]}),
|
||||
json!({"ref": "refs/pull/7/head", "object": {"type": "tag", "sha": "a".repeat(40)}}),
|
||||
json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "0".repeat(40)}}),
|
||||
] {
|
||||
assert!(gitea_head(&bad, 7).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_aliases_use_effective_hostname_and_preserve_user_matching() {
|
||||
use std::{fs, io::Write, path::PathBuf};
|
||||
struct Config(PathBuf);
|
||||
impl Drop for Config {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("vamp-forge-ssh-{}.conf", std::process::id()));
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
let config = Config(path);
|
||||
file.write_all(b"Host gitea-personal\n HostName gitea.example.org\n Port 2222\nHost github-personal\n HostName ssh.github.com\nHost user-match\n Port 2222\nMatch originalhost user-match user git\n HostName gitlab.example.org\n").unwrap();
|
||||
for (url, expected) in [
|
||||
(
|
||||
"git@gitea-personal:owner/repo.git",
|
||||
"https://gitea.example.org/api/v1/repos/owner/repo/pulls",
|
||||
),
|
||||
(
|
||||
"ssh://git@gitea-personal:2200/owner/repo.git",
|
||||
"https://gitea.example.org/api/v1/repos/owner/repo/pulls",
|
||||
),
|
||||
(
|
||||
"git@github-personal:owner/repo.git",
|
||||
"https://api.github.com/repos/owner/repo/pulls",
|
||||
),
|
||||
(
|
||||
"git@user-match:group/sub/repo.git",
|
||||
"https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests",
|
||||
),
|
||||
] {
|
||||
let forge = Forge::parse(url, None, |destination, port| {
|
||||
let mut command = Command::new("ssh");
|
||||
command.arg("-F").arg(&config.0);
|
||||
ssh_hostname(command, destination, port, &AtomicBool::new(false))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(forge.endpoint, expected);
|
||||
}
|
||||
assert!(
|
||||
Forge::parse("git@alias:owner/repo", Some("gitea"), |_, _| Ok(
|
||||
"evil.example/path".into()
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
Forge::parse(
|
||||
"ssh://user:secret@alias/owner/repo",
|
||||
Some("gitea"),
|
||||
|_, _| { panic!("Password-bearing SSH URLs must be rejected before starting SSH") }
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
Forge::parse("https://github.com/owner/repo", None, |_, _| panic!(
|
||||
"HTTPS must not invoke SSH"
|
||||
))
|
||||
.unwrap()
|
||||
.host,
|
||||
"github.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_urls_preserve_namespaces_and_keep_ssh_ports_out_of_https() {
|
||||
let gitlab = parse("ssh://git@gitlab.example.org:2222/group/sub/repo.git", None).unwrap();
|
||||
assert_eq!(
|
||||
gitlab.endpoint,
|
||||
"https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests"
|
||||
);
|
||||
assert_eq!(
|
||||
parse("git@github.com:owner/repo.git", None)
|
||||
.unwrap()
|
||||
.endpoint,
|
||||
"https://api.github.com/repos/owner/repo/pulls"
|
||||
);
|
||||
assert_eq!(
|
||||
parse(
|
||||
"https://user:secret@forge.example:8443/owner/repo.git",
|
||||
Some("gitea")
|
||||
)
|
||||
.unwrap()
|
||||
.endpoint,
|
||||
"https://forge.example:8443/api/v1/repos/owner/repo/pulls"
|
||||
);
|
||||
assert!(
|
||||
parse("https://forge.example/owner/repo", None)
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("git config remote.<name>.vamp-forge github")
|
||||
);
|
||||
for url in [
|
||||
"https://github.com/owner/repo?token=secret",
|
||||
"https://github.com/owner/%2e%2e",
|
||||
"https://github.com/owner/repo%0a",
|
||||
"https://github.com:0/owner/repo",
|
||||
"file:///owner/repo",
|
||||
] {
|
||||
assert!(parse(url, Some("github")).is_err(), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_pull_requests_pin_historical_shas_not_merge_commits_or_response_urls() {
|
||||
let value = json!({"number": 7, "title": "Merged fork", "state": "closed", "merged_at": "2026-01-01T00:00:00Z", "merged": true,
|
||||
"user": {"login": "author"}, "base": {"ref": "release/next", "sha": "1".repeat(40)},
|
||||
"head": {"ref": "feature", "label": "fork:topic", "sha": "2".repeat(40), "repo": null}, "merge_base": "4".repeat(40), "merge_commit_sha": "3".repeat(40), "url": "https://evil.example/token"});
|
||||
for provider in [Provider::Github, Provider::Gitea] {
|
||||
let pr = pull_request(provider, &value, Some("5".repeat(40))).unwrap();
|
||||
let revisions = pr.revisions.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(revisions.base_ref.as_str(), revisions.head_ref.as_str()),
|
||||
("refs/heads/release/next", "refs/pull/7/head")
|
||||
);
|
||||
assert_eq!(
|
||||
revisions.base_sha,
|
||||
if provider == Provider::Gitea {
|
||||
"4"
|
||||
} else {
|
||||
"1"
|
||||
}
|
||||
.repeat(40)
|
||||
);
|
||||
assert_eq!(
|
||||
revisions.head_sha,
|
||||
if provider == Provider::Gitea {
|
||||
"5"
|
||||
} else {
|
||||
"2"
|
||||
}
|
||||
.repeat(40)
|
||||
);
|
||||
if provider == Provider::Gitea {
|
||||
let mut deleted = value.clone();
|
||||
deleted["head"]["sha"] = Value::String(String::new());
|
||||
assert_eq!(
|
||||
pull_request(provider, &deleted, Some("5".repeat(40)))
|
||||
.unwrap()
|
||||
.revisions
|
||||
.unwrap()
|
||||
.head_sha,
|
||||
"5".repeat(40)
|
||||
);
|
||||
let unavailable = pull_request(provider, &deleted, None).unwrap();
|
||||
assert!(unavailable.revisions.is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_uses_target_start_sha_and_rejects_unprepared_or_unsafe_metadata() {
|
||||
let mut value = json!({"iid": 9, "title": "MR", "state": "merged", "author": {"username": "author"},
|
||||
"target_branch": "main", "source_branch": "topic", "diff_refs": {"base_sha": "1".repeat(40), "start_sha": "2".repeat(40), "head_sha": "3".repeat(40)}});
|
||||
let pr = pull_request(Provider::Gitlab, &value, None).unwrap();
|
||||
let revisions = pr.revisions.unwrap();
|
||||
assert_eq!(revisions.base_sha, "2".repeat(40));
|
||||
assert_eq!(revisions.head_ref, "refs/merge-requests/9/head");
|
||||
value["diff_refs"]["start_sha"] = Value::Null;
|
||||
assert_eq!(
|
||||
pull_request(Provider::Gitlab, &value, None)
|
||||
.unwrap()
|
||||
.revisions
|
||||
.unwrap()
|
||||
.base_sha,
|
||||
"1".repeat(40)
|
||||
);
|
||||
value["target_branch"] = json!("main:refs/heads/injected");
|
||||
assert!(
|
||||
pull_request(Provider::Gitlab, &value, None)
|
||||
.unwrap()
|
||||
.revisions
|
||||
.is_err()
|
||||
);
|
||||
value["target_branch"] = json!("main");
|
||||
value["diff_refs"]["head_sha"] = json!("HEAD");
|
||||
assert!(
|
||||
pull_request(Provider::Gitlab, &value, None)
|
||||
.unwrap()
|
||||
.revisions
|
||||
.is_err()
|
||||
);
|
||||
value["diff_refs"] = Value::Null;
|
||||
assert!(
|
||||
pull_request(Provider::Gitlab, &value, None)
|
||||
.unwrap()
|
||||
.revisions
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/forge.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-427
@@ -456,430 +456,5 @@ fn changes(before: &str, after: &str) -> Vec<Option<Change>> {
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
|
||||
static NEXT_TEST: AtomicU64 = AtomicU64::new(0);
|
||||
struct Temporary(PathBuf);
|
||||
impl Temporary {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"vamp-gutter-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEST.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Temporary {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||
let mut command = Command::new(program);
|
||||
if program == "jj" {
|
||||
command.args([
|
||||
"--config=user.name=\"Gutter Author\"",
|
||||
"--config=user.email=\"gutter@example.test\"",
|
||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
]);
|
||||
}
|
||||
let output = command
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("JJ_CONFIG", "/dev/null")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||
}
|
||||
fn git_fixture() -> (Temporary, PathBuf) {
|
||||
let temp = Temporary::new();
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
command(&temp.0, "git", &["config", "user.name", "Gutter Author"]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["config", "user.email", "gutter@example.test"],
|
||||
);
|
||||
let path = temp.0.join("-literal [file]\n\t\".txt");
|
||||
fs::write(&path, "first\nsecond\nthird\n").unwrap();
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
|
||||
);
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
||||
(temp, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_replacements_surplus_deletions_empty_and_eof_rows() {
|
||||
use Change::*;
|
||||
for (before, after, expected) in [
|
||||
("first\nsecond\n", "first\nsecond\n", vec![None, None, None]),
|
||||
(
|
||||
"first\r\nsecond\r\n",
|
||||
"first\nsecond\n",
|
||||
vec![None, None, None],
|
||||
),
|
||||
("first\rsecond\r", "first\nsecond\n", vec![None, None, None]),
|
||||
(
|
||||
"first\r\nsecond\r\nthird\r\n",
|
||||
"first\nchanged\nthird\n",
|
||||
vec![None, Some(Modified), None, None],
|
||||
),
|
||||
(
|
||||
"first\nsecond\nthird\n",
|
||||
"first\rchanged\rthird\r",
|
||||
vec![None, Some(Modified), None, None],
|
||||
),
|
||||
("first\n", "first\nnew\n", vec![None, Some(Added), None]),
|
||||
(
|
||||
"old\n",
|
||||
"changed\nsurplus\n",
|
||||
vec![Some(Modified), Some(Added), None],
|
||||
),
|
||||
(
|
||||
"old\nextra\nlast\n",
|
||||
"changed\nlast\n",
|
||||
vec![Some(Modified), None, None],
|
||||
),
|
||||
(
|
||||
"first\nremoved\nlast\n",
|
||||
"first\nlast\n",
|
||||
vec![None, Some(Removed), None],
|
||||
),
|
||||
("first\nremoved\n", "first\n", vec![None, Some(Removed)]),
|
||||
("first\nremoved", "first", vec![Some(Modified)]),
|
||||
("first\nremoved", "first\n", vec![None, Some(Removed)]),
|
||||
("removed\n", "", vec![Some(Removed)]),
|
||||
("", "new\n", vec![Some(Added), None]),
|
||||
("", "", vec![None]),
|
||||
("same", "same\n", vec![Some(Modified), None]),
|
||||
] {
|
||||
assert_eq!(changes(before, after), expected, "{before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_crlf_baseline_matches_normalized_editor_rows() {
|
||||
let (temp, _) = git_fixture();
|
||||
let path = temp.0.join("crlf");
|
||||
fs::write(&path, "first\r\nsecond\r\nthird\r\n").unwrap();
|
||||
command(&temp.0, "git", &["add", "--", "crlf"]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["commit", "--quiet", "-m", "CRLF baseline"],
|
||||
);
|
||||
assert_eq!(
|
||||
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
|
||||
vec![None, None, None, None]
|
||||
);
|
||||
assert_eq!(
|
||||
read_file(&path, "first\nchanged\nthird\n", &|| false).unwrap(),
|
||||
vec![None, Some(Change::Modified), None, None]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_includes_staged_disk_and_unsaved_edits_without_writes() {
|
||||
let (temp, path) = git_fixture();
|
||||
fs::write(&path, "staged\nsecond\nthird\n").unwrap();
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
|
||||
);
|
||||
fs::write(&path, "staged\ndisk\nthird\n").unwrap();
|
||||
let index = fs::read(temp.0.join(".git/index")).unwrap();
|
||||
let head = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||
assert_eq!(
|
||||
read_file(&path, "staged\ndisk\nunsaved\n", &|| false).unwrap(),
|
||||
vec![
|
||||
Some(Change::Modified),
|
||||
Some(Change::Modified),
|
||||
Some(Change::Modified),
|
||||
None
|
||||
]
|
||||
);
|
||||
assert_eq!(fs::read(temp.0.join(".git/index")).unwrap(), index);
|
||||
assert_eq!(command(&temp.0, "git", &["rev-parse", "HEAD"]), head);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "staged\ndisk\nthird\n");
|
||||
fs::remove_file(&path).unwrap();
|
||||
assert_eq!(
|
||||
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
|
||||
vec![None, None, None, None]
|
||||
);
|
||||
assert_eq!(
|
||||
read_file(&temp.0.join("new/named"), "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
let untracked = temp.0.join("untracked");
|
||||
fs::write(&untracked, "unsaved\n").unwrap();
|
||||
assert_eq!(
|
||||
read_file(&untracked, "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unborn_untracked_missing_ignored_metadata_binary_and_symlink_paths() {
|
||||
let temp = Temporary::new();
|
||||
let path = temp.0.join("new/named");
|
||||
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
assert_eq!(
|
||||
read_file(&path, "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
let disk = temp.0.join("untracked");
|
||||
fs::write(&disk, "disk\n").unwrap();
|
||||
assert_eq!(
|
||||
read_file(&disk, "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
fs::write(temp.0.join(".gitignore"), "new/\nuntracked\n").unwrap();
|
||||
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
|
||||
assert!(read_file(&disk, "unsaved\n", &|| false).unwrap().is_empty());
|
||||
assert!(
|
||||
read_file(&temp.0.join(".git/config"), "text\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
read_file(&disk, "binary\0text", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
std::os::unix::fs::symlink(&disk, temp.0.join("link")).unwrap();
|
||||
assert!(
|
||||
read_file(&temp.0.join("link"), "text\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
std::os::unix::fs::symlink(&temp.0, temp.0.join("linked")).unwrap();
|
||||
assert!(
|
||||
read_file(&temp.0.join("linked/untracked"), "text\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_non_utf8_filename_is_literal() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let (temp, _) = git_fixture();
|
||||
let name = OsString::from_vec(b"non-utf8-\xff".to_vec());
|
||||
let path = temp.0.join(&name);
|
||||
match fs::write(&path, "old\n") {
|
||||
Ok(()) => {}
|
||||
// APFS rejects non-UTF-8 names before repository code can read them.
|
||||
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => return,
|
||||
Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"),
|
||||
}
|
||||
let mut add = git(&temp.0);
|
||||
add.args(["add", "--"]).arg(&name);
|
||||
checked(add, &|| false).unwrap();
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Byte path"]);
|
||||
assert_eq!(
|
||||
read_file(&path, "changed\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Modified), None]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_parent_root_colocation_ignore_and_merge_queries_are_read_only() {
|
||||
for colocate in ["--colocate", "--no-colocate"] {
|
||||
let temp = Temporary::new();
|
||||
command(&temp.0, "jj", &["git", "init", colocate, "."]);
|
||||
let name = "-literal [file]\n\t\".txt";
|
||||
let path = temp.0.join(name);
|
||||
fs::write(&path, "original\n").unwrap();
|
||||
assert_eq!(
|
||||
read_file(&path, "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
fs::write(temp.0.join(".gitignore"), "ignored\n").unwrap();
|
||||
assert!(
|
||||
read_file(&temp.0.join("ignored"), "text\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
command(&temp.0, "jj", &["describe", "-m", "Baseline"]);
|
||||
command(&temp.0, "jj", &["new"]);
|
||||
let operation = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
],
|
||||
);
|
||||
let store = store_for_jj(&temp.0).unwrap();
|
||||
let refs = fs::read(store.join("HEAD")).unwrap();
|
||||
fs::write(&path, "disk edit\n").unwrap();
|
||||
assert_eq!(
|
||||
read_file(&path, "inserted\noriginal\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None, None]
|
||||
);
|
||||
assert_eq!(fs::read(store.join("HEAD")).unwrap(), refs);
|
||||
assert_eq!(
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
]
|
||||
),
|
||||
operation
|
||||
);
|
||||
let parent = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@-",
|
||||
"-T",
|
||||
"commit_id",
|
||||
],
|
||||
);
|
||||
command(&temp.0, "jj", &["new", "root()"]);
|
||||
fs::write(temp.0.join("sibling"), "sibling\n").unwrap();
|
||||
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
||||
command(&temp.0, "jj", &["new", &parent, "@"]);
|
||||
assert!(
|
||||
read_file(&path, "original\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_retains_only_latest_batch_and_file_errors_keep_good_siblings() {
|
||||
let (_temp, path) = git_fixture();
|
||||
let mut worker = Worker::start().unwrap();
|
||||
let oversized: Arc<str> = "x".repeat(FILE_LIMIT + 1).into();
|
||||
for id in 0..20 {
|
||||
worker
|
||||
.request(
|
||||
id,
|
||||
vec![
|
||||
(5, path.clone(), "first\nchanged\nthird\n".into()),
|
||||
(8, path.clone(), Arc::clone(&oversized)),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
loop {
|
||||
if let Some((id, result, picker)) = worker.poll() {
|
||||
assert!(picker.is_none());
|
||||
assert_eq!(id, 19);
|
||||
assert_eq!(result[0].0, 5);
|
||||
assert_eq!(
|
||||
result[0].1.as_ref().unwrap(),
|
||||
&vec![None, Some(Change::Modified), None, None]
|
||||
);
|
||||
assert_eq!(result[1].0, 8);
|
||||
assert_eq!(
|
||||
result[1].1.as_ref().unwrap_err(),
|
||||
"Gutter file exceeds 16 MiB"
|
||||
);
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
assert_eq!(
|
||||
worker
|
||||
.request(
|
||||
20,
|
||||
vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())],
|
||||
None
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_status_is_optional_and_errors_do_not_discard_line_results() {
|
||||
let (temp, path) = git_fixture();
|
||||
let mut worker = Worker::start().unwrap();
|
||||
worker
|
||||
.request(
|
||||
1,
|
||||
vec![],
|
||||
Some((
|
||||
temp.0.clone(),
|
||||
vec![(path.clone(), "dirty buffer\n".into())],
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
loop {
|
||||
if let Some((id, lines, picker)) = worker.poll() {
|
||||
assert_eq!(id, 1);
|
||||
assert!(lines.is_empty());
|
||||
let picker = picker.unwrap();
|
||||
assert_eq!(picker.root, temp.0);
|
||||
let changes = picker.result.unwrap();
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap());
|
||||
assert_eq!(changes[0].status, crate::diff::FileStatus::Modified);
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
worker
|
||||
.request(
|
||||
2,
|
||||
vec![(4, path.clone(), "first\nchanged\nthird\n".into())],
|
||||
Some((
|
||||
temp.0.clone(),
|
||||
vec![(path, "x".repeat(FILE_LIMIT + 1).into())],
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
loop {
|
||||
if let Some((id, lines, picker)) = worker.poll() {
|
||||
assert_eq!(id, 2);
|
||||
assert_eq!(lines[0].0, 4);
|
||||
assert!(lines[0].1.is_ok());
|
||||
assert!(picker.unwrap().result.unwrap_err().contains("16 MiB"));
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/gutter.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-246
@@ -2993,249 +2993,5 @@ fn uri_path(uri: &str) -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_definitions_preserve_snapshot_identity_and_utf16_until_navigation() {
|
||||
let source = Source {
|
||||
buffer_id: 7,
|
||||
revision: 3,
|
||||
row: 0,
|
||||
column: 0,
|
||||
request_id: 11,
|
||||
};
|
||||
let path = Path::new("/vamp-immutable-snapshot/Before/renamed/λ.rs");
|
||||
let span = json!({
|
||||
"start": {"line": 4, "character": 5},
|
||||
"end": {"line": 4, "character": 8}
|
||||
});
|
||||
for location in [
|
||||
json!({"uri": file_uri(path).unwrap(), "range": span}),
|
||||
json!({"targetUri": file_uri(path).unwrap(),
|
||||
"targetRange": span, "targetSelectionRange": span}),
|
||||
] {
|
||||
let effects = Runtime::definition_effects(&source, &location).unwrap();
|
||||
let expected_target_range = location.get("targetUri").map(|_| Utf16Range {
|
||||
start: (4, 5),
|
||||
end: (4, 8),
|
||||
});
|
||||
assert!(matches!(
|
||||
effects.as_slice(),
|
||||
[UpdateEffect::NavigateUtf16 {
|
||||
buffer_id: 7, revision: 3, path: target,
|
||||
range: Utf16Range { start: (4, 5), end: (4, 8) }, target_range,
|
||||
}] if target == path && *target_range == expected_target_range
|
||||
));
|
||||
}
|
||||
for invalid in [
|
||||
json!({"start": {"line": 4, "character": 8},
|
||||
"end": {"line": 4, "character": 5}}),
|
||||
json!({"start": {"line": -1, "character": 0},
|
||||
"end": {"line": 0, "character": 0}}),
|
||||
] {
|
||||
for location in [
|
||||
json!({"uri": file_uri(path).unwrap(), "range": invalid}),
|
||||
json!({"targetUri": file_uri(path).unwrap(),
|
||||
"targetRange": invalid, "targetSelectionRange": span}),
|
||||
json!({"targetUri": file_uri(path).unwrap(),
|
||||
"targetRange": span, "targetSelectionRange": invalid}),
|
||||
] {
|
||||
assert!(Runtime::definition_effects(&source, &location).is_err());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<UpdateEffect>(json!({
|
||||
"type": "navigate_utf16", "buffer_id": 7, "revision": 3,
|
||||
"path": path, "range": {"start": [4,5], "end": [4,8]},
|
||||
"target_range": null
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() {
|
||||
let options = semantic_options(Some(&json!({
|
||||
"full": true,
|
||||
"legend": {"tokenTypes": ["custom", "function"],
|
||||
"tokenModifiers": ["custom", "readonly", "declaration", "async"]}
|
||||
})))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let tokens = decode_semantic_tokens(
|
||||
"A😀e\u{301}Z\r\n\r\nx",
|
||||
&json!({"data": [
|
||||
0, 1, 2, 1, 2,
|
||||
0, 2, 1, 1, 12,
|
||||
0, 1, 1, 1, 0,
|
||||
0, 1, 999, 0, 0,
|
||||
1, 0, 1, 1, 0,
|
||||
1, 0, 0, 1, 0,
|
||||
0, 0, 100, 1, 0
|
||||
]}),
|
||||
&options,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(tokens.len(), 4);
|
||||
assert_eq!(tokens[0].start, TextPosition { row: 0, column: 1 });
|
||||
assert_eq!(tokens[0].end, TextPosition { row: 0, column: 2 });
|
||||
assert_eq!(tokens[0].modifiers, SemanticTokenModifier::Readonly as u16);
|
||||
assert_eq!(tokens[1].start, TextPosition { row: 0, column: 2 });
|
||||
assert_eq!(tokens[1].end, TextPosition { row: 0, column: 3 });
|
||||
assert_eq!(
|
||||
tokens[1].modifiers,
|
||||
SemanticTokenModifier::Declaration as u16 | SemanticTokenModifier::Async as u16
|
||||
);
|
||||
assert_eq!(tokens[2].start, tokens[1].start);
|
||||
assert_eq!(tokens[3].end, TextPosition { row: 2, column: 1 });
|
||||
for data in [
|
||||
json!([0, 0, 1, 1]),
|
||||
json!([-1, 0, 1, 1, 0]),
|
||||
json!([0, 0.5, 1, 1, 0]),
|
||||
json!([0, 0, 1, 1, 2147483648u64]),
|
||||
json!([0, 0, 1, 65536, 0]),
|
||||
json!([0, 0, 1, 2, 0]),
|
||||
json!([0, 0, 1, 1, 16]),
|
||||
json!([0, 2, 1, 0, 0]),
|
||||
json!([0, 1, 1, 1, 0]),
|
||||
json!([0, 7, 1, 1, 0]),
|
||||
json!([2, 0, 1, 1, 0]),
|
||||
json!([0, 1, 2, 1, 0, 0, 1, 1, 1, 0]),
|
||||
] {
|
||||
assert!(
|
||||
decode_semantic_tokens("A😀e\u{301}Z\n", &json!({"data": data}), &options).is_err(),
|
||||
"{data}"
|
||||
);
|
||||
}
|
||||
let count = MAX_BODY / std::mem::size_of::<SemanticToken>() + 1;
|
||||
assert!(
|
||||
decode_semantic_tokens("x", &json!({"data": vec![0; count * 5]}), &options).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_positions_and_local_uri_boundaries() {
|
||||
let text = "A😀e\u{301}Z\n";
|
||||
let (start, end) = range(
|
||||
text,
|
||||
&json!({"start": {"line": 0, "character": 2}, "end": {"line": 0, "character": 4}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(start, TextPosition { row: 0, column: 1 });
|
||||
assert_eq!(end, TextPosition { row: 0, column: 3 });
|
||||
assert_eq!(
|
||||
outbound_position(text, 0, 3).unwrap(),
|
||||
json!({"line": 0, "character": 5})
|
||||
);
|
||||
assert_eq!(
|
||||
position(text, &json!({"line": 1, "character": 999}), false).unwrap(),
|
||||
TextPosition { row: 1, column: 0 }
|
||||
);
|
||||
assert!(
|
||||
range(
|
||||
text,
|
||||
&json!({"start": {"line": 0, "character": 3}, "end": {"line": 0, "character": 2}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let path = Path::new("/tmp/source space λ😀.rs");
|
||||
assert_eq!(uri_path(&file_uri(path).unwrap()).unwrap(), path);
|
||||
assert_eq!(
|
||||
uri_path("file://localhost/tmp/a%23b").unwrap(),
|
||||
Path::new("/tmp/a#b")
|
||||
);
|
||||
for uri in [
|
||||
"https://example.invalid/a",
|
||||
"file://remote/tmp/a",
|
||||
"file:///tmp/%",
|
||||
"file:///tmp/%00",
|
||||
"file:///tmp/%FF",
|
||||
"file:///tmp/a?query",
|
||||
] {
|
||||
assert!(uri_path(uri).is_err(), "{uri}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_contents_keep_lines_and_reject_malformed_values() {
|
||||
let source = "A😀e\u{301}Z\r\n";
|
||||
for kind in ["plaintext", "markdown"] {
|
||||
assert_eq!(
|
||||
hover_text(
|
||||
&json!({
|
||||
"contents": {"kind": kind, "value": "😀e\u{301}\r\n\tcode\rnext\u{1b}\u{85}"},
|
||||
"range": {"start": {"line": 0, "character": 2},
|
||||
"end": {"line": 0, "character": 4}}
|
||||
}),
|
||||
source,
|
||||
)
|
||||
.unwrap(),
|
||||
Some("😀e\u{301}\n code\nnext ".into()),
|
||||
);
|
||||
}
|
||||
for contents in [
|
||||
json!("line\ncode"),
|
||||
json!({"language": "rust", "value": "line\ncode"}),
|
||||
] {
|
||||
assert_eq!(
|
||||
hover_text(&json!({"contents": contents}), source).unwrap(),
|
||||
Some("line\ncode".into()),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
hover_text(
|
||||
&json!({"contents": ["first", {"language": "rust", "value": "fn main() {\n}"}]}),
|
||||
source,
|
||||
)
|
||||
.unwrap(),
|
||||
Some("first\n\nfn main() {\n}".into()),
|
||||
);
|
||||
for value in [
|
||||
Value::Null,
|
||||
json!({"contents": ""}),
|
||||
json!({"contents": []}),
|
||||
] {
|
||||
assert_eq!(hover_text(&value, source).unwrap(), None);
|
||||
}
|
||||
for value in [
|
||||
json!({}),
|
||||
json!({"contents": {"kind": "html", "value": "x"}}),
|
||||
json!({"contents": {"language": "rust", "value": false}}),
|
||||
json!({"contents": [{"kind": "markdown", "value": "x"}]}),
|
||||
json!({"contents": "x", "range": {"start": {"line": 1, "character": 0},
|
||||
"end": {"line": 0, "character": 0}}}),
|
||||
json!({"contents": "\t".repeat(MAX_BODY / 4 + 1)}),
|
||||
] {
|
||||
assert!(hover_text(&value, source).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_text_limit_counts_the_atomic_batch_not_sort_metadata() {
|
||||
let source = Source {
|
||||
buffer_id: 1,
|
||||
revision: 0,
|
||||
row: 0,
|
||||
column: 0,
|
||||
request_id: 1,
|
||||
};
|
||||
let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]);
|
||||
assert_eq!(
|
||||
decode_completion("", &at_limit, &source, None).unwrap()[0].edits[0]
|
||||
.text
|
||||
.len(),
|
||||
MAX_BODY - 1
|
||||
);
|
||||
let over_limit = json!([
|
||||
{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)},
|
||||
{"label": "y"}
|
||||
]);
|
||||
assert!(
|
||||
decode_completion("", &over_limit, &source, None)
|
||||
.unwrap_err()
|
||||
.contains("4 MiB")
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/lsp.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-48
@@ -254,51 +254,5 @@ fn main() {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(args: &[&str]) -> Result<Option<Options>, String> {
|
||||
parse_options(args.iter().map(std::ffi::OsString::from))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_commands_preserve_filenames_and_validate_arguments() {
|
||||
assert_eq!(
|
||||
parse(&["review"]).unwrap().unwrap().file,
|
||||
Some("review".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--", "--review"]).unwrap().unwrap().file,
|
||||
Some("--review".into())
|
||||
);
|
||||
assert!(
|
||||
parse(&["--no-config", "--review", "list", "--repo", ".", "--json"])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.review
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
parse(&["--review", "show", "1", "--all", "--no-config"])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.review
|
||||
.is_some()
|
||||
);
|
||||
for args in [
|
||||
vec!["--review"],
|
||||
vec!["--review", "show"],
|
||||
vec!["--review", "show", "0"],
|
||||
vec!["--review", "show", "-1"],
|
||||
vec!["--review", "show", "abc"],
|
||||
vec!["--review", "list", "--all"],
|
||||
vec!["--review", "list", "--repo"],
|
||||
vec!["--review", "list", "--repo", ".", "--repo", "."],
|
||||
vec!["--review", "list", "extra"],
|
||||
vec!["--review", "list", "--", "extra"],
|
||||
vec!["--review", "list", "--config", "x", "--no-config"],
|
||||
] {
|
||||
assert!(parse(&args).is_err(), "{args:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/cli_args.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-145
@@ -560,148 +560,5 @@ fn slug(title: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(text: &str, needle: &str) -> Result<LinkTarget, String> {
|
||||
let byte = text.find(needle).unwrap();
|
||||
let prefix = &text[..byte];
|
||||
let row = prefix.bytes().filter(|byte| *byte == b'\n').count();
|
||||
let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count();
|
||||
link_at(text, TextPosition { row, column })
|
||||
}
|
||||
|
||||
fn target(path: Option<&str>, fragment: Option<&str>) -> LinkTarget {
|
||||
LinkTarget {
|
||||
path: path.map(PathBuf::from),
|
||||
fragment: fragment.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follows_inline_labels_destinations_and_grapheme_columns() {
|
||||
let text = "👩💻 [go **there**](<../My%20Notes/%E6%97%A5%E6%9C%AC\\(1\\).md#caf%C3%A9>)";
|
||||
let expected = target(Some("../My Notes/日本(1).md"), Some("café"));
|
||||
assert_eq!(at(text, "there").unwrap(), expected);
|
||||
assert_eq!(at(text, "My%20").unwrap(), expected);
|
||||
assert_eq!(
|
||||
at("[absolute](/Tmp/Note.md)", "absolute").unwrap(),
|
||||
target(Some("/Tmp/Note.md"), None)
|
||||
);
|
||||
assert_eq!(
|
||||
at("[here](#title)", "here").unwrap(),
|
||||
target(None, Some("title"))
|
||||
);
|
||||
assert_eq!(at("[top]()", "top").unwrap(), target(None, None));
|
||||
assert_eq!(
|
||||
at("[here](#title)", "[here").unwrap(),
|
||||
target(None, Some("title"))
|
||||
);
|
||||
assert_eq!(
|
||||
at("[file](literal%23name.md)", "file").unwrap(),
|
||||
target(Some("literal#name.md"), None)
|
||||
);
|
||||
assert_eq!(
|
||||
at("[file](My\\[Notes\\].md)", "file").unwrap(),
|
||||
target(Some("My[Notes].md"), None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_all_reference_forms_and_multiline_block_definitions() {
|
||||
let text = "[full][ A B ] [a b][] [A B]\n\n> [a b]:\n> <My%20Notes.md#title>\n> \"A title\"\n\n[a b]: ignored.md\n";
|
||||
let expected = target(Some("My Notes.md"), Some("title"));
|
||||
for needle in ["full", " A B ", "a b][]", "A B]"] {
|
||||
assert_eq!(at(text, needle).unwrap(), expected);
|
||||
}
|
||||
assert_eq!(
|
||||
at("[Straße]\n\n[STRASSE]: file.md", "Straße").unwrap(),
|
||||
target(Some("file.md"), None)
|
||||
);
|
||||
let text = "> [multi\n> line][target]\n\n[target]: other.md\n";
|
||||
assert_eq!(at(text, "line]").unwrap(), target(Some("other.md"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_images_code_plaintext_and_external_urls() {
|
||||
for (text, needle) in [
|
||||
("", "image"),
|
||||
("`[code](file.md)`", "code"),
|
||||
("```md\n[code](file.md)\n```", "file"),
|
||||
(" [code](file.md)", "code"),
|
||||
("plain file.md text", "file"),
|
||||
("[web](https://example.com)", "web"),
|
||||
("[web](//example.com/a)", "web"),
|
||||
("[web](mailto:a@example.com)", "web"),
|
||||
("<https://example.com>", "example"),
|
||||
] {
|
||||
assert!(at(text, needle).is_err(), "{text}");
|
||||
}
|
||||
assert!(
|
||||
at("[missing]", "missing")
|
||||
.unwrap_err()
|
||||
.contains("not defined")
|
||||
);
|
||||
assert!(
|
||||
at("[bad](file%ZZ.md)", "bad")
|
||||
.unwrap_err()
|
||||
.contains("percent")
|
||||
);
|
||||
assert!(
|
||||
at("[bad](file%FF.md)", "bad")
|
||||
.unwrap_err()
|
||||
.contains("UTF-8")
|
||||
);
|
||||
assert!(at("[bad](#)", "bad").unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locates_visible_unicode_headings_setext_and_unique_slugs() {
|
||||
let text = "# **Café** [日本](destination.md) `code` ###\n\n> ## Café 日本 code\n\nCafé 日本 code\n====\n\n# Café 日本 code-1\n\n# Cafe\u{301} _name_ \n\n```md\n# Hidden\n```\n";
|
||||
assert_eq!(heading_row(text, "café-日本-code").unwrap(), 0);
|
||||
assert_eq!(heading_row(text, "café-日本-code-1").unwrap(), 2);
|
||||
assert_eq!(heading_row(text, "café-日本-code-2").unwrap(), 4);
|
||||
assert_eq!(heading_row(text, "café-日本-code-1-1").unwrap(), 7);
|
||||
assert_eq!(heading_row(text, "cafe\u{301}-name-alt").unwrap(), 9);
|
||||
assert!(heading_row(text, "hidden").is_err());
|
||||
assert!(heading_row(text, "destinationmd").is_err());
|
||||
assert!(heading_row(text, "").is_err());
|
||||
assert_eq!(
|
||||
heading_row("# [Heading][ref]\n\n[ref]: ignored.md", "heading").unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
heading_row("# ![Alt][ref]\n\n[ref]: ignored.md", "alt").unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(heading_row("# ![Alt][missing]", "altmissing").unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_entities_or_reports_the_explicit_entity_ceiling() {
|
||||
assert_eq!(
|
||||
at("[file](a&b c.md)", "file").unwrap(),
|
||||
target(Some("a&b c.md"), None)
|
||||
);
|
||||
assert_eq!(
|
||||
at("[file](<name>)", "file").unwrap(),
|
||||
target(Some("<name>"), None)
|
||||
);
|
||||
assert!(
|
||||
at("[file](a©b.md)", "file")
|
||||
.unwrap_err()
|
||||
.contains("Unsupported Markdown HTML entity")
|
||||
);
|
||||
assert_eq!(heading_row("# A & B é\n", "a--b-é").unwrap(), 0);
|
||||
assert!(
|
||||
heading_row("# ©\n", "copy")
|
||||
.unwrap_err()
|
||||
.contains("Unsupported Markdown HTML entity")
|
||||
);
|
||||
assert_eq!(heading_row("# ©\n# Valid\n", "valid").unwrap(), 1);
|
||||
assert_eq!(
|
||||
heading_row("> First\n> Second\n> ------\n", "first-second").unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/markdown.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-125
@@ -248,128 +248,5 @@ pub fn directional_neighbor(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rect(x: u16, y: u16, width: u16, height: u16) -> PaneRect {
|
||||
PaneRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
fn leaves(tree: &PaneTree, bounds: PaneRect) -> Vec<(usize, PaneRect)> {
|
||||
let mut leaves = Vec::new();
|
||||
tree.visit_rects(bounds, |id, rect| leaves.push((id, rect)));
|
||||
leaves
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_layout_partitions_bounds_and_preserves_ids() {
|
||||
let bounds = rect(3, 5, 11, 9);
|
||||
let mut tree = PaneTree::new(10);
|
||||
assert!(tree.split(10, 20, SplitAxis::Vertical));
|
||||
assert!(tree.split(20, 30, SplitAxis::Horizontal));
|
||||
assert!(!tree.split(99, 40, SplitAxis::Horizontal));
|
||||
assert!(!tree.split(10, 30, SplitAxis::Horizontal));
|
||||
assert!(!tree.split(10, 10, SplitAxis::Horizontal));
|
||||
assert_eq!(tree.len(), 3);
|
||||
let panes = leaves(&tree, bounds);
|
||||
assert_eq!(
|
||||
panes,
|
||||
vec![
|
||||
(10, rect(3, 5, 5, 9)),
|
||||
(20, rect(8, 5, 6, 4)),
|
||||
(30, rect(8, 9, 6, 5))
|
||||
]
|
||||
);
|
||||
let mut cells = std::collections::HashSet::new();
|
||||
for (_, pane) in panes {
|
||||
for y in pane.y..pane.y + pane.height {
|
||||
for x in pane.x..pane.x + pane.width {
|
||||
assert!(cells.insert((x, y)), "overlapping pane cell");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
cells.len(),
|
||||
usize::from(bounds.width) * usize::from(bounds.height)
|
||||
);
|
||||
assert!(leaves(&tree, rect(0, 0, 0, 10)).is_empty());
|
||||
assert!(leaves(&tree, rect(0, 0, 10, 0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_expands_nested_sibling_and_refuses_last() {
|
||||
let bounds = rect(0, 0, 20, 12);
|
||||
let mut tree = PaneTree::new(1);
|
||||
tree.split(1, 2, SplitAxis::Vertical);
|
||||
tree.split(2, 3, SplitAxis::Horizontal);
|
||||
assert_eq!(tree.close(1), Some(2));
|
||||
assert_eq!(
|
||||
leaves(&tree, bounds),
|
||||
vec![(2, rect(0, 0, 20, 6)), (3, rect(0, 6, 20, 6))]
|
||||
);
|
||||
assert_eq!(tree.close(99), None);
|
||||
assert_eq!(tree.close(2), Some(3));
|
||||
assert_eq!(leaves(&tree, bounds), vec![(3, bounds)]);
|
||||
assert_eq!(tree.close(3), None);
|
||||
assert_eq!(tree.len(), 1);
|
||||
assert!(!tree.contains(1));
|
||||
assert!(tree.contains(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_focus_handles_unequal_nested_regions() {
|
||||
let bounds = rect(0, 0, 20, 12);
|
||||
let mut tree = PaneTree::new(1);
|
||||
tree.split(1, 2, SplitAxis::Vertical);
|
||||
tree.split(2, 3, SplitAxis::Horizontal);
|
||||
tree.split(3, 4, SplitAxis::Horizontal);
|
||||
assert_eq!(tree.neighbor(1, PaneDirection::Right, bounds), Some(3));
|
||||
assert_eq!(tree.neighbor(2, PaneDirection::Down, bounds), Some(3));
|
||||
assert_eq!(tree.neighbor(3, PaneDirection::Down, bounds), Some(4));
|
||||
assert_eq!(tree.neighbor(4, PaneDirection::Up, bounds), Some(3));
|
||||
assert_eq!(tree.neighbor(4, PaneDirection::Left, bounds), Some(1));
|
||||
assert_eq!(tree.neighbor(1, PaneDirection::Left, bounds), None);
|
||||
assert_eq!(tree.neighbor(99, PaneDirection::Right, bounds), None);
|
||||
assert_eq!(
|
||||
tree.neighbor(1, PaneDirection::Right, rect(0, 0, 0, 0)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_prefers_overlap_then_edge_and_breaks_ties_by_id() {
|
||||
let current = rect(10, 10, 10, 10);
|
||||
let candidates = [
|
||||
(1, rect(20, 0, 5, 5)),
|
||||
(2, rect(25, 10, 5, 10)),
|
||||
(4, rect(22, 10, 5, 10)),
|
||||
(3, rect(22, 10, 5, 10)),
|
||||
(0, rect(20, 10, 0, 10)),
|
||||
];
|
||||
assert_eq!(
|
||||
directional_neighbor(current, PaneDirection::Right, candidates.into_iter()),
|
||||
Some(3)
|
||||
);
|
||||
assert_eq!(
|
||||
directional_neighbor(current, PaneDirection::Right, candidates.into_iter().rev()),
|
||||
Some(3)
|
||||
);
|
||||
assert_eq!(
|
||||
directional_neighbor(
|
||||
current,
|
||||
PaneDirection::Up,
|
||||
[(7, rect(10, 0, 10, 5))].into_iter()
|
||||
),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(
|
||||
directional_neighbor(current, PaneDirection::Left, candidates.into_iter()),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/panes.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-506
@@ -527,15 +527,6 @@ fn working_conflict_baseline(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn working_file_changes(
|
||||
root: &Path,
|
||||
buffers: &[(PathBuf, Arc<str>)],
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<Vec<WorkingFileChange>, String> {
|
||||
WorkingFileReader::default().read(root, buffers, cancelled)
|
||||
}
|
||||
|
||||
fn working_file_changes_cached(
|
||||
root: &Path,
|
||||
buffers: &[(PathBuf, Arc<str>)],
|
||||
@@ -3032,500 +3023,5 @@ fn eof_label(file: Option<&FileData>) -> &'static str {
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture_command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||
let mut command = Command::new(program);
|
||||
if program == "jj" {
|
||||
command.args([
|
||||
"--config=user.name=\"Picker Author\"",
|
||||
"--config=user.email=\"picker@example.test\"",
|
||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
]);
|
||||
}
|
||||
let output = command
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("JJ_CONFIG", "/dev/null")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||
}
|
||||
|
||||
fn picker_git_fixture() -> Temporary {
|
||||
let temp = Temporary::new().unwrap();
|
||||
fixture_command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
fixture_command(&temp.0, "git", &["config", "user.name", "Picker Author"]);
|
||||
fixture_command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["config", "user.email", "picker@example.test"],
|
||||
);
|
||||
temp
|
||||
}
|
||||
|
||||
fn changes_by_path(
|
||||
root: &Path,
|
||||
buffers: &[(PathBuf, Arc<str>)],
|
||||
) -> BTreeMap<PathBuf, WorkingFileChange> {
|
||||
working_file_changes(root, buffers, &|| false)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|change| (change.path.clone(), change))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_git_changes_cover_disk_index_buffers_binary_exact_renames_and_read_only_queries() {
|
||||
use std::os::unix::{ffi::OsStringExt, fs::PermissionsExt};
|
||||
let temp = picker_git_fixture();
|
||||
let root = &temp.0;
|
||||
for (name, contents) in [
|
||||
(".gitignore", "ignored*\n"),
|
||||
("modified", "original\r\n"),
|
||||
("unchanged", "same\n"),
|
||||
("removed", "deleted\n"),
|
||||
("old", "rename content\n"),
|
||||
("duplicate-one", "duplicate\n"),
|
||||
("duplicate-two", "duplicate\n"),
|
||||
("mode", "mode\n"),
|
||||
("binary", "before\0binary"),
|
||||
] {
|
||||
fs::write(root.join(name), contents).unwrap();
|
||||
}
|
||||
let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec()));
|
||||
let non_utf8 = match fs::write(root.join(&non_utf8), "before\n") {
|
||||
Ok(()) => Some(non_utf8),
|
||||
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => None,
|
||||
Err(error) => panic!("cannot create byte filename: {error}"),
|
||||
};
|
||||
fixture_command(root, "git", &["add", "--all"]);
|
||||
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
||||
fs::write(root.join("modified"), "staged\n").unwrap();
|
||||
fixture_command(root, "git", &["add", "--", "modified"]);
|
||||
fs::write(root.join("modified"), "unstaged\n").unwrap();
|
||||
fs::write(root.join("binary"), "after\0binary").unwrap();
|
||||
if let Some(name) = &non_utf8 {
|
||||
fs::write(root.join(name), "after\n").unwrap();
|
||||
}
|
||||
fs::remove_file(root.join("removed")).unwrap();
|
||||
let renamed = "-new [file]\n\t\".txt";
|
||||
fs::rename(root.join("old"), root.join(renamed)).unwrap();
|
||||
fs::remove_file(root.join("duplicate-one")).unwrap();
|
||||
fs::remove_file(root.join("duplicate-two")).unwrap();
|
||||
fs::write(root.join("new-duplicate-one"), "duplicate\n").unwrap();
|
||||
fs::write(root.join("new-duplicate-two"), "duplicate\n").unwrap();
|
||||
fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::write(root.join("added"), "added\n").unwrap();
|
||||
fs::write(root.join("ignored"), "ignored\n").unwrap();
|
||||
fs::write(root.join("ignored-tracked"), "forced\n").unwrap();
|
||||
fixture_command(root, "git", &["add", "-f", "--", "ignored-tracked"]);
|
||||
std::os::unix::fs::symlink(root.join("modified"), root.join("link")).unwrap();
|
||||
let index = fs::read(root.join(".git/index")).unwrap();
|
||||
let head = fs::read(root.join(".git/HEAD")).unwrap();
|
||||
let disk = changes_by_path(root, &[]);
|
||||
for name in ["modified", "binary", "mode"] {
|
||||
assert_eq!(disk[Path::new(name)].status, FileStatus::Modified);
|
||||
}
|
||||
if let Some(name) = &non_utf8 {
|
||||
assert_eq!(disk[name].status, FileStatus::Modified);
|
||||
}
|
||||
assert_eq!(disk[Path::new("removed")].status, FileStatus::Removed);
|
||||
assert_eq!(disk[Path::new(renamed)].status, FileStatus::Renamed);
|
||||
assert_eq!(
|
||||
disk[Path::new(renamed)].old_path.as_deref(),
|
||||
Some(Path::new("old"))
|
||||
);
|
||||
for name in [
|
||||
"added",
|
||||
"ignored-tracked",
|
||||
"new-duplicate-one",
|
||||
"new-duplicate-two",
|
||||
] {
|
||||
assert_eq!(disk[Path::new(name)].status, FileStatus::Added);
|
||||
}
|
||||
for name in ["duplicate-one", "duplicate-two"] {
|
||||
assert_eq!(disk[Path::new(name)].status, FileStatus::Removed);
|
||||
}
|
||||
for name in ["unchanged", "old", "ignored", "link"] {
|
||||
assert!(!disk.contains_key(Path::new(name)), "{name}");
|
||||
}
|
||||
let overlays = changes_by_path(
|
||||
root,
|
||||
&[
|
||||
(root.join("modified"), "original\n".into()),
|
||||
(root.join("unchanged"), "dirty\n".into()),
|
||||
(root.join("removed"), "deleted\n".into()),
|
||||
(root.join(renamed), "dirty renamed\n".into()),
|
||||
(root.join("mode"), "mode\n".into()),
|
||||
(root.join("ignored-buffer"), "ignored\n".into()),
|
||||
],
|
||||
);
|
||||
assert!(!overlays.contains_key(Path::new("modified")));
|
||||
assert!(!overlays.contains_key(Path::new("ignored-buffer")));
|
||||
assert_eq!(
|
||||
overlays[Path::new("unchanged")].status,
|
||||
FileStatus::Modified
|
||||
);
|
||||
assert_eq!(overlays[Path::new("removed")].status, FileStatus::Removed);
|
||||
assert_eq!(overlays[Path::new(renamed)].status, FileStatus::Renamed);
|
||||
assert_eq!(overlays[Path::new("mode")].status, FileStatus::Modified);
|
||||
let aliases = Temporary::new().unwrap();
|
||||
let alias = aliases.0.join("checkout");
|
||||
std::os::unix::fs::symlink(root, &alias).unwrap();
|
||||
let aliased = changes_by_path(root, &[(alias.join("unchanged"), "dirty\n".into())]);
|
||||
assert_eq!(aliased[Path::new("unchanged")].status, FileStatus::Modified);
|
||||
fixture_command(root, "git", &["config", "core.filemode", "0"]);
|
||||
assert!(
|
||||
!changes_by_path(root, &[(root.join("mode"), "mode\n".into())])
|
||||
.contains_key(Path::new("mode"))
|
||||
);
|
||||
fixture_command(root, "git", &["config", "core.autocrlf", "1"]);
|
||||
fs::write(root.join("unchanged"), "same\r\n").unwrap();
|
||||
assert!(!changes_by_path(root, &[]).contains_key(Path::new("unchanged")));
|
||||
fixture_command(root, "git", &["config", "--unset", "core.autocrlf"]);
|
||||
fixture_command(root, "git", &["config", "core.filemode", "true"]);
|
||||
fs::write(root.join("unchanged"), "same\n").unwrap();
|
||||
assert_eq!(fs::read(root.join(".git/index")).unwrap(), index);
|
||||
assert_eq!(fs::read(root.join(".git/HEAD")).unwrap(), head);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("modified")).unwrap(),
|
||||
"unstaged\n"
|
||||
);
|
||||
assert!(!root.join("removed").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_git_subdirectory_cross_boundary_moves_worktrees_and_unsafe_roots() {
|
||||
let temp = picker_git_fixture();
|
||||
let root = &temp.0;
|
||||
fs::create_dir(root.join("sub")).unwrap();
|
||||
fs::write(root.join("sub/outgoing"), "out\n").unwrap();
|
||||
fs::write(root.join("incoming"), "in\n").unwrap();
|
||||
fixture_command(root, "git", &["add", "--all"]);
|
||||
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
||||
fs::rename(root.join("sub/outgoing"), root.join("outgoing")).unwrap();
|
||||
fs::rename(root.join("incoming"), root.join("sub/incoming")).unwrap();
|
||||
let changes = changes_by_path(&root.join("sub"), &[]);
|
||||
assert_eq!(changes.len(), 2);
|
||||
assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Removed);
|
||||
assert_eq!(changes[Path::new("incoming")].status, FileStatus::Added);
|
||||
assert_eq!(changes[Path::new("incoming")].old_path, None);
|
||||
std::os::unix::fs::symlink(root.join("sub"), root.join("linked")).unwrap();
|
||||
assert!(changes_by_path(&root.join("linked"), &[]).is_empty());
|
||||
assert!(changes_by_path(&root.join(".git"), &[]).is_empty());
|
||||
let unrelated = Temporary::new().unwrap();
|
||||
assert!(changes_by_path(&unrelated.0, &[]).is_empty());
|
||||
let worktree = temp.0.join("worktree");
|
||||
fixture_command(
|
||||
root,
|
||||
"git",
|
||||
&["worktree", "add", "--quiet", "--detach", "worktree", "HEAD"],
|
||||
);
|
||||
fs::write(worktree.join("sub/outgoing"), "worktree edit\n").unwrap();
|
||||
let changes = changes_by_path(&worktree.join("sub"), &[]);
|
||||
assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Modified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_unborn_git_never_runs_filters_external_diff_textconv_or_hooks() {
|
||||
let temp = picker_git_fixture();
|
||||
let root = &temp.0;
|
||||
fs::write(root.join(".gitignore"), "ignored\n").unwrap();
|
||||
fs::write(root.join("tracked"), "tracked\n").unwrap();
|
||||
fs::write(root.join("untracked"), "untracked\n").unwrap();
|
||||
fs::write(root.join("ignored"), "ignored\n").unwrap();
|
||||
fixture_command(root, "git", &["add", "--", ".gitignore", "tracked"]);
|
||||
let changes = changes_by_path(root, &[(root.join("named"), "not saved\n".into())]);
|
||||
for name in [".gitignore", "tracked", "untracked", "named"] {
|
||||
assert_eq!(changes[Path::new(name)].status, FileStatus::Added);
|
||||
}
|
||||
assert!(!changes.contains_key(Path::new("ignored")));
|
||||
assert!(!root.join("named").exists());
|
||||
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
||||
fs::write(
|
||||
root.join(".gitattributes"),
|
||||
"tracked filter=evil diff=evil\n",
|
||||
)
|
||||
.unwrap();
|
||||
let evil = "sh -c 'touch executed; cat'";
|
||||
for key in [
|
||||
"filter.evil.clean",
|
||||
"filter.evil.process",
|
||||
"diff.evil.command",
|
||||
"diff.evil.textconv",
|
||||
] {
|
||||
fixture_command(root, "git", &["config", key, evil]);
|
||||
}
|
||||
fixture_command(root, "git", &["config", "filter.evil.required", "true"]);
|
||||
fs::write(root.join("tracked"), "changed\n").unwrap();
|
||||
assert_eq!(
|
||||
changes_by_path(root, &[])[Path::new("tracked")].status,
|
||||
FileStatus::Modified
|
||||
);
|
||||
assert_eq!(
|
||||
changes_by_path(root, &[(root.join("tracked"), "tracked\n".into())])
|
||||
.get(Path::new("tracked")),
|
||||
None
|
||||
);
|
||||
assert!(!root.join("executed").exists());
|
||||
assert!(
|
||||
working_file_changes(root, &[], &|| true)
|
||||
.unwrap_err()
|
||||
.contains("cancelled")
|
||||
);
|
||||
assert!(
|
||||
working_file_changes(
|
||||
root,
|
||||
&[(root.join("tracked"), "x".repeat(FILE_LIMIT + 1).into())],
|
||||
&|| false
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("16 MiB")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_jj_root_parent_colocation_and_merge_queries_do_not_snapshot() {
|
||||
for colocate in ["--colocate", "--no-colocate"] {
|
||||
let temp = Temporary::new().unwrap();
|
||||
let root = &temp.0;
|
||||
fixture_command(root, "jj", &["git", "init", colocate, "."]);
|
||||
fs::write(root.join("file"), "baseline\n").unwrap();
|
||||
assert_eq!(
|
||||
changes_by_path(root, &[])[Path::new("file")].status,
|
||||
FileStatus::Added
|
||||
);
|
||||
fs::write(root.join(".gitignore"), "ignored\n").unwrap();
|
||||
fixture_command(root, "jj", &["describe", "-m", "Baseline"]);
|
||||
fixture_command(root, "jj", &["new"]);
|
||||
let operation = fixture_command(
|
||||
root,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
],
|
||||
);
|
||||
let parent = fixture_command(
|
||||
root,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@-",
|
||||
"-T",
|
||||
"commit_id",
|
||||
],
|
||||
);
|
||||
let store = store_for_jj(root).unwrap();
|
||||
let head = fs::read(store.join("HEAD")).unwrap();
|
||||
let index = fs::read(store.join("index")).ok();
|
||||
fs::write(root.join("file"), "disk\n").unwrap();
|
||||
fs::write(root.join("new"), "new\n").unwrap();
|
||||
fs::write(root.join("ignored"), "ignored\n").unwrap();
|
||||
let changes = changes_by_path(root, &[]);
|
||||
assert_eq!(changes[Path::new("file")].status, FileStatus::Modified);
|
||||
assert_eq!(changes[Path::new("new")].status, FileStatus::Added);
|
||||
assert!(!changes.contains_key(Path::new("ignored")));
|
||||
assert!(
|
||||
!changes_by_path(root, &[(root.join("file"), "baseline\n".into())])
|
||||
.contains_key(Path::new("file"))
|
||||
);
|
||||
assert_eq!(fs::read(store.join("HEAD")).unwrap(), head);
|
||||
assert_eq!(fs::read(store.join("index")).ok(), index);
|
||||
assert_eq!(
|
||||
fixture_command(
|
||||
root,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id"
|
||||
]
|
||||
),
|
||||
operation
|
||||
);
|
||||
fixture_command(root, "jj", &["new", "root()"]);
|
||||
fs::write(root.join("sibling"), "sibling\n").unwrap();
|
||||
fixture_command(root, "jj", &["describe", "-m", "Sibling"]);
|
||||
fixture_command(root, "jj", &["new", &parent, "@"]);
|
||||
assert!(changes_by_path(root, &[]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() {
|
||||
for colocate in ["--colocate", "--no-colocate"] {
|
||||
let temp = Temporary::new().unwrap();
|
||||
let root = &temp.0;
|
||||
fixture_command(root, "jj", &["git", "init", colocate, "."]);
|
||||
fs::write(root.join("conflicted"), "base\n").unwrap();
|
||||
fixture_command(root, "jj", &["describe", "-m", "Base"]);
|
||||
let base = fixture_command(
|
||||
root,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@",
|
||||
"-T",
|
||||
"commit_id",
|
||||
],
|
||||
);
|
||||
fixture_command(root, "jj", &["new"]);
|
||||
fs::write(root.join("conflicted"), "left\n").unwrap();
|
||||
fixture_command(root, "jj", &["describe", "-m", "Left"]);
|
||||
let left = fixture_command(
|
||||
root,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@",
|
||||
"-T",
|
||||
"commit_id",
|
||||
],
|
||||
);
|
||||
fixture_command(root, "jj", &["new", &base]);
|
||||
fs::write(root.join("conflicted"), "right\n").unwrap();
|
||||
fixture_command(root, "jj", &["describe", "-m", "Right"]);
|
||||
let right = fixture_command(
|
||||
root,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@",
|
||||
"-T",
|
||||
"commit_id",
|
||||
],
|
||||
);
|
||||
fixture_command(root, "jj", &["new", &left, &right]);
|
||||
fixture_command(root, "jj", &["new"]);
|
||||
let projected = fs::read_to_string(root.join("conflicted")).unwrap();
|
||||
assert!(projected.contains("<<<<<<<"));
|
||||
let operation = fixture_command(
|
||||
root,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
],
|
||||
);
|
||||
let mut reader = WorkingFileReader::default();
|
||||
assert!(reader.read(root, &[], &|| false).unwrap().is_empty());
|
||||
fs::write(root.join("conflicted"), "resolved in working file\n").unwrap();
|
||||
let changes = reader.read(root, &[], &|| false).unwrap();
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(changes[0].path, Path::new("conflicted"));
|
||||
assert_eq!(changes[0].status, FileStatus::Modified);
|
||||
assert!(
|
||||
reader
|
||||
.read(
|
||||
root,
|
||||
&[(root.join("conflicted"), projected.into())],
|
||||
&|| false
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("conflicted")).unwrap(),
|
||||
"resolved in working file\n"
|
||||
);
|
||||
assert_eq!(
|
||||
fixture_command(
|
||||
root,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id"
|
||||
]
|
||||
),
|
||||
operation
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_kills_pipe_holding_children_and_drains_both_streams() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
let ready = temp.0.join("ready");
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let worker_cancelled = Arc::clone(&cancelled);
|
||||
let gate = ready.clone();
|
||||
let (sent, received) = mpsc::channel();
|
||||
let worker = thread::spawn(move || {
|
||||
let mut command = Command::new("sh");
|
||||
command
|
||||
.args([
|
||||
"-c",
|
||||
"sleep 600 & printf ready > \"$1\"; wait",
|
||||
"vamp-diff-test",
|
||||
])
|
||||
.arg(gate);
|
||||
let result = run(command, vec![], &worker_cancelled).map(|_| ());
|
||||
sent.send(result).unwrap();
|
||||
});
|
||||
// File handshake, not a wall-clock scheduling assumption.
|
||||
while !ready.exists() {
|
||||
if let Ok(result) = received.try_recv() {
|
||||
panic!("Process exited before the child was ready: {result:?}");
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
cancelled.store(true, Ordering::Release);
|
||||
assert!(received.recv().unwrap().unwrap_err().contains("cancelled"));
|
||||
worker.join().unwrap();
|
||||
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "i=0; while [ \"$i\" -lt 10000 ]; do printf 'line\\n'; printf 'error\\n' >&2; i=$((i+1)); done"]);
|
||||
let result = run(command, vec![], &AtomicBool::new(false)).unwrap();
|
||||
assert_eq!(result.code, Some(0));
|
||||
assert_eq!(result.stdout.len(), 50_000);
|
||||
assert_eq!(result.stderr.len(), 60_000);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/repository.rs"]
|
||||
mod tests;
|
||||
|
||||
+4
-473
@@ -1050,478 +1050,9 @@ pub fn project(text: &str, source: &Source) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"vamp-resolver-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn jj(&self, args: &[&str]) -> String {
|
||||
let output = Command::new("jj")
|
||||
.current_dir(&self.0)
|
||||
.env("JJ_CONFIG", "/dev/null")
|
||||
.args([
|
||||
"--config=user.name=Resolver",
|
||||
"--config=user.email=resolver@example.test",
|
||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
])
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap()
|
||||
}
|
||||
fn conflict(&self) {
|
||||
self.jj(&["git", "init", "--no-colocate", "."]);
|
||||
self.jj(&["config", "set", "--repo", "user.name", "Resolver"]);
|
||||
self.jj(&[
|
||||
"config",
|
||||
"set",
|
||||
"--repo",
|
||||
"user.email",
|
||||
"resolver@example.test",
|
||||
]);
|
||||
self.jj(&[
|
||||
"config",
|
||||
"set",
|
||||
"--repo",
|
||||
"debug.commit-timestamp",
|
||||
"\"2001-01-01T00:00:00Z\"",
|
||||
]);
|
||||
self.jj(&[
|
||||
"config",
|
||||
"set",
|
||||
"--repo",
|
||||
"debug.operation-timestamp",
|
||||
"\"2001-01-01T00:00:00Z\"",
|
||||
]);
|
||||
fs::write(self.0.join("file"), "base\n").unwrap();
|
||||
self.jj(&["bookmark", "create", "base"]);
|
||||
self.jj(&["new", "base"]);
|
||||
fs::write(self.0.join("file"), "left\n").unwrap();
|
||||
self.jj(&["bookmark", "create", "left"]);
|
||||
self.jj(&["new", "base"]);
|
||||
fs::write(self.0.join("file"), "right\n").unwrap();
|
||||
self.jj(&["bookmark", "create", "right"]);
|
||||
self.jj(&["new", "left", "right"]);
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn discovery_is_readonly_and_save_uses_jj_authority_and_stale_guards() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.conflict();
|
||||
let operation = fixture.jj(&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
]);
|
||||
let workspace = load(&fixture.0, &|| false).unwrap();
|
||||
assert_eq!(workspace.files.len(), 1);
|
||||
let file = &workspace.files[0];
|
||||
assert!(file.sources.iter().any(|s| s.base && s.bytes == b"base\n"));
|
||||
assert!(file.sources.iter().any(|s| !s.base && s.bytes == b"left\n"));
|
||||
assert!(
|
||||
file.sources
|
||||
.iter()
|
||||
.any(|s| !s.base && s.bytes == b"right\n")
|
||||
);
|
||||
for bookmark in ["base", "left", "right"] {
|
||||
assert!(
|
||||
file.sources
|
||||
.iter()
|
||||
.any(|s| s.label.contains(&format!("[{bookmark}]")))
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
operation,
|
||||
fixture.jj(&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id"
|
||||
])
|
||||
);
|
||||
let blocks = regions(file.result.as_ref().unwrap());
|
||||
assert_eq!(blocks.len(), 1);
|
||||
for source in &file.sources {
|
||||
assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes);
|
||||
}
|
||||
let mut changed = blocks[0].clone();
|
||||
changed.sides.pop();
|
||||
assert!(changed.choose(&file.sources[0]).is_err());
|
||||
fs::write(fixture.0.join("file"), "external\n").unwrap();
|
||||
assert!(
|
||||
save(&workspace, 0, Resolution::Text("lost\n".into()), &|| false)
|
||||
.unwrap_err()
|
||||
.contains("externally")
|
||||
);
|
||||
fs::write(fixture.0.join("file"), file.result.as_ref().unwrap()).unwrap();
|
||||
fixture.jj(&["new", "base"]);
|
||||
let before = fs::read(fixture.0.join("file")).unwrap();
|
||||
assert!(
|
||||
save(
|
||||
&workspace,
|
||||
0,
|
||||
Resolution::Text("wrong checkout\n".into()),
|
||||
&|| false
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("checkout changed")
|
||||
);
|
||||
assert_eq!(fs::read(fixture.0.join("file")).unwrap(), before);
|
||||
}
|
||||
#[test]
|
||||
fn text_choice_is_confirmed_by_jj() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.conflict();
|
||||
let mut workspace = load(&fixture.0, &|| false).unwrap();
|
||||
let cancelled_after_write =
|
||||
|| fs::read(fixture.0.join("file")).is_ok_and(|bytes| bytes == b"draft\n");
|
||||
let saved = save(
|
||||
&workspace,
|
||||
0,
|
||||
Resolution::Text("draft\n".into()),
|
||||
&cancelled_after_write,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved.file.status, Status::Unconfirmed);
|
||||
assert!(saved.confirmation.is_some());
|
||||
assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"draft\n");
|
||||
assert_eq!(
|
||||
entries(&fixture.0, &workspace.checkout.commit, &|| false).unwrap(),
|
||||
[PathBuf::from("file")]
|
||||
);
|
||||
workspace.checkout = saved.checkout;
|
||||
workspace.files[0] = saved.file;
|
||||
let outcome = save(
|
||||
&workspace,
|
||||
0,
|
||||
Resolution::Text("resolved\n".into()),
|
||||
&|| false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
outcome.file.status,
|
||||
Status::Resolved,
|
||||
"{:?}",
|
||||
outcome.confirmation
|
||||
);
|
||||
assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"resolved\n");
|
||||
assert!(
|
||||
entries(&fixture.0, &outcome.checkout.commit, &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn all_three_sides_and_bases_are_loaded_and_directory_children_are_protected() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.conflict();
|
||||
fixture.jj(&["new", "base"]);
|
||||
fs::write(fixture.0.join("file"), "third\n").unwrap();
|
||||
fixture.jj(&["bookmark", "create", "third"]);
|
||||
fixture.jj(&["new", "left", "right", "third"]);
|
||||
let workspace = load(&fixture.0, &|| false).unwrap();
|
||||
let file = &workspace.files[0];
|
||||
assert_eq!(file.sources.iter().filter(|s| !s.base).count(), 3);
|
||||
assert_eq!(file.sources.iter().filter(|s| s.base).count(), 2);
|
||||
assert!(file.sources.iter().any(|s| s.bytes == b"third\n"));
|
||||
let blocks = regions(file.result.as_ref().unwrap());
|
||||
for source in &file.sources {
|
||||
assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes);
|
||||
}
|
||||
fs::remove_file(fixture.0.join("file")).unwrap();
|
||||
fs::create_dir(fixture.0.join("file")).unwrap();
|
||||
fs::write(fixture.0.join("file/untracked"), b"keep my draft").unwrap();
|
||||
let workspace = load(&fixture.0, &|| false).unwrap();
|
||||
assert!(
|
||||
save(&workspace, 0, Resolution::Source(0), &|| false)
|
||||
.unwrap_err()
|
||||
.contains("untracked or modified children")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("file/untracked")).unwrap(),
|
||||
b"keep my draft"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn binary_symlink_and_deleted_file_choices_preserve_types_and_bytes() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.conflict();
|
||||
fixture.jj(&["edit", "left"]);
|
||||
fs::write(fixture.0.join("binary"), b"left\0\xff").unwrap();
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(fixture.0.join("binary"), fs::Permissions::from_mode(0o755))
|
||||
.unwrap();
|
||||
}
|
||||
std::os::unix::fs::symlink("left-target", fixture.0.join("link")).unwrap();
|
||||
fixture.jj(&["new", "right"]);
|
||||
fs::write(fixture.0.join("binary"), b"right\0\xfe").unwrap();
|
||||
std::os::unix::fs::symlink("right-target", fixture.0.join("link")).unwrap();
|
||||
fixture.jj(&["bookmark", "create", "right-extra"]);
|
||||
fixture.jj(&["new", "left", "right-extra"]);
|
||||
let mut workspace = load(&fixture.0, &|| false).unwrap();
|
||||
for (name, kind, bytes) in [
|
||||
("binary", FileKind::Binary, b"left\0\xff".as_slice()),
|
||||
("link", FileKind::Symlink, b"left-target".as_slice()),
|
||||
] {
|
||||
let file = workspace
|
||||
.files
|
||||
.iter()
|
||||
.position(|f| f.path == Path::new(name))
|
||||
.unwrap();
|
||||
assert!(workspace.files[file].result.is_none());
|
||||
let source = workspace.files[file]
|
||||
.sources
|
||||
.iter()
|
||||
.position(|s| !s.base && s.kind == kind && s.bytes == bytes)
|
||||
.unwrap();
|
||||
assert!(
|
||||
save(
|
||||
&workspace,
|
||||
file,
|
||||
Resolution::Text("not allowed".into()),
|
||||
&|| false
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap();
|
||||
assert_eq!(
|
||||
outcome.file.status,
|
||||
Status::Resolved,
|
||||
"{:?}",
|
||||
outcome.confirmation
|
||||
);
|
||||
workspace.checkout = outcome.checkout;
|
||||
workspace.files[file] = outcome.file;
|
||||
if kind == FileKind::Symlink {
|
||||
assert_eq!(
|
||||
fs::read_link(fixture.0.join(name)).unwrap(),
|
||||
Path::new("left-target")
|
||||
);
|
||||
} else {
|
||||
assert_eq!(fs::read(fixture.0.join(name)).unwrap(), bytes);
|
||||
}
|
||||
if kind == FileKind::Binary {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(fixture.0.join(name))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o111,
|
||||
0o111
|
||||
);
|
||||
}
|
||||
}
|
||||
// add/add conflicts retain an absent base as an explicit deletion choice.
|
||||
let file = workspace
|
||||
.files
|
||||
.iter()
|
||||
.position(|f| f.path == Path::new("binary"))
|
||||
.unwrap();
|
||||
let source = workspace.files[file]
|
||||
.sources
|
||||
.iter()
|
||||
.position(|s| s.kind == FileKind::Deleted)
|
||||
.unwrap();
|
||||
let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap();
|
||||
assert_eq!(
|
||||
outcome.file.status,
|
||||
Status::Resolved,
|
||||
"{:?}",
|
||||
outcome.confirmation
|
||||
);
|
||||
assert!(!fixture.0.join("binary").exists());
|
||||
}
|
||||
#[test]
|
||||
fn marker_styles_widths_multisides_and_missing_eol() {
|
||||
let snapshot = "before\n<<<<<<<<<<< conflict 1 of 1\n+++++++++++ a\nleft\n----------- base\nold\n+++++++++++ b\nright\n----------- base2\nold2\n+++++++++++ c\nthird\n>>>>>>>>>>> conflict 1 of 1 ends\nafter\n";
|
||||
let parsed = regions(snapshot);
|
||||
assert_eq!(parsed[0].lines, 1..13);
|
||||
assert_eq!(parsed[0].sides, ["left\n", "right\n", "third\n"]);
|
||||
assert_eq!(parsed[0].bases, ["old\n", "old2\n"]);
|
||||
let prefixed = format!("<<<<<<< literal marker\n{snapshot}");
|
||||
assert_eq!(regions(&prefixed).len(), 1);
|
||||
assert_eq!(regions(&prefixed)[0].lines, 2..14);
|
||||
let diff = "<<<<<<< conflict 1 of 1\n%%%%%%% diff from: base\n^^^^^^^ to: left\n-old\n+left\n+++++++ right\nright\n>>>>>>> conflict 1 of 1 ends\n";
|
||||
assert_eq!(regions(diff)[0].sides, ["left\n", "right\n"]);
|
||||
let git = "<<<<<<< left\nleft\n||||||| base\nold\n=======\nright\n>>>>>>> right";
|
||||
assert_eq!(regions(git)[0].sides, ["left", "right"]);
|
||||
assert!(regions("<<<<<<< unfinished\nleft\n").is_empty());
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/resolve.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod projection_tests {
|
||||
use super::*;
|
||||
|
||||
fn test_sources(sides: usize) -> Vec<Source> {
|
||||
(0..sides * 2 - 1)
|
||||
.map(|index| Source {
|
||||
label: format!("source {index}"),
|
||||
base: index % 2 == 1,
|
||||
kind: FileKind::Text,
|
||||
bytes: b"historical full file; must never replace working text\n".to_vec(),
|
||||
tree_id: String::new(),
|
||||
term: index / 2,
|
||||
sides,
|
||||
bases: sides - 1,
|
||||
node: Node::Absent,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn snapshot(left: &str, base: &str, right: &str) -> String {
|
||||
format!(
|
||||
"<<<<<<< conflict\n+++++++ side 1\n{left}------- base\n{base}+++++++ side 2\n{right}>>>>>>> conflict ends\n"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_working_projections_keep_partial_resolutions_and_multiple_regions() {
|
||||
let sources = test_sources(2);
|
||||
let raw = format!(
|
||||
"unsaved heading\nalready manually resolved\n{}auto-merged middle\n<<<<<<< conflict\n%%%%%%% Changes from base to side 1\n\\\\\\\\\\\\\\ continuation\n-old two\n+left two\n+++++++ side 2\nright two\n>>>>>>> conflict ends\nunsaved tail",
|
||||
snapshot("left one\n", "old one\n", "right one\n")
|
||||
);
|
||||
for (index, first, second) in [
|
||||
(0, "left one", "left two"),
|
||||
(1, "old one", "old two"),
|
||||
(2, "right one", "right two"),
|
||||
] {
|
||||
assert_eq!(
|
||||
project(&raw, &sources[index]).unwrap(),
|
||||
format!(
|
||||
"unsaved heading\nalready manually resolved\n{first}\nauto-merged middle\n{second}\nunsaved tail"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternatives_are_frozen_from_the_same_working_text_as_the_seed() {
|
||||
let sources = test_sources(2);
|
||||
let raw = format!(
|
||||
"unsaved heading\n{}auto-merged middle\n{}unsaved tail",
|
||||
snapshot("left-one\n", "old-one\n", "right-one\n"),
|
||||
snapshot("left-two\n", "old-two\n", "right-two\n")
|
||||
);
|
||||
let projection = Projection::new(&raw, &sources, 0).unwrap();
|
||||
let mut draft = projection.seed().to_owned();
|
||||
draft = draft.replace("unsaved heading", "later draft edit");
|
||||
draft.push_str("\nnew draft ending\n");
|
||||
assert_eq!(
|
||||
projection.alternative(2).unwrap(),
|
||||
"unsaved heading\nright-one\nauto-merged middle\nright-two\nunsaved tail"
|
||||
);
|
||||
assert_eq!(
|
||||
projection.alternative(1).unwrap(),
|
||||
"unsaved heading\nold-one\nauto-merged middle\nold-two\nunsaved tail"
|
||||
);
|
||||
assert_eq!(
|
||||
projection.seed(),
|
||||
"unsaved heading\nleft-one\nauto-merged middle\nleft-two\nunsaved tail"
|
||||
);
|
||||
assert_eq!(
|
||||
draft,
|
||||
"later draft edit\nleft-one\nauto-merged middle\nleft-two\nunsaved tail\nnew draft ending\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_multiway_sides_and_bases_project_the_same_current_context() {
|
||||
let sources = test_sources(3);
|
||||
let raw = "unsaved α\n<<<<<<<<<<< conflict\n+++++++++++ side 1\none\n----------- base 1\nold one\n+++++++++++ side 2\ntwo\n----------- base 2\nold two\n+++++++++++ side 3\nthree\n>>>>>>>>>>> conflict ends\nunsaved ω";
|
||||
let projection = Projection::new(raw, &sources, 0).unwrap();
|
||||
for (index, choice) in ["one", "old one", "two", "old two", "three"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let expected = format!("unsaved α\n{choice}\nunsaved ω");
|
||||
assert_eq!(project(raw, &sources[index]).unwrap(), expected);
|
||||
assert_eq!(projection.alternative(index).unwrap(), expected);
|
||||
}
|
||||
assert_eq!(
|
||||
Projection::new(raw, &sources, 4).unwrap().seed(),
|
||||
"unsaved α\nthree\nunsaved ω"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projections_preserve_mixed_eof_and_crlf_terms() {
|
||||
let sources = test_sources(2);
|
||||
for eol in ["\n", "\r\n"] {
|
||||
let choices = [
|
||||
format!("left{eol}"),
|
||||
"old".to_owned(),
|
||||
format!("right{eol}{eol}"),
|
||||
];
|
||||
// jj appends one separator EOL to *each* term when any lacks an EOL,
|
||||
// and omits the EOL on the final marker. Existing term EOLs remain.
|
||||
let raw = format!(
|
||||
"heading{eol}<<<<<<< conflict{eol}+++++++ side 1{eol}{}{eol}------- base{eol}{}{eol}+++++++ side 2{eol}{}{eol}>>>>>>> conflict ends",
|
||||
choices[0], choices[1], choices[2]
|
||||
);
|
||||
let projection = Projection::new(&raw, &sources, 0).unwrap();
|
||||
for (index, choice) in choices.iter().enumerate() {
|
||||
assert_eq!(
|
||||
project(&raw, &sources[index]).unwrap(),
|
||||
format!("heading{eol}{choice}")
|
||||
);
|
||||
assert_eq!(
|
||||
projection.alternative(index).unwrap(),
|
||||
format!("heading{eol}{choice}")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_choices_markerfree_text_and_invalid_choices_are_handled() {
|
||||
let mut sources = test_sources(2);
|
||||
sources[1].kind = FileKind::Deleted;
|
||||
let raw = format!("before\n{}after\n", snapshot("", "", "new\n"));
|
||||
let projection = Projection::new(&raw, &sources, 0).unwrap();
|
||||
assert_eq!(projection.seed(), "before\nafter\n");
|
||||
assert_eq!(projection.alternative(1).unwrap(), "before\nafter\n");
|
||||
assert_eq!(projection.alternative(2).unwrap(), "before\nnew\nafter\n");
|
||||
let resolved = Projection::new("manually resolved, no final EOL", &sources, 0).unwrap();
|
||||
assert_eq!(
|
||||
resolved.alternative(2).unwrap(),
|
||||
"manually resolved, no final EOL"
|
||||
);
|
||||
assert!(resolved.alternative(sources.len()).is_err());
|
||||
assert!(Projection::new(&raw, &sources, sources.len()).is_err());
|
||||
assert!(Projection::new(&raw, &test_sources(3), 0).is_err());
|
||||
sources[0].kind = FileKind::Binary;
|
||||
assert!(project(&raw, &sources[0]).is_err());
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/resolve_projection.rs"]
|
||||
mod projection_tests;
|
||||
|
||||
+2
-520
@@ -579,523 +579,5 @@ fn check_file(path: &Path, expected: Option<&[u8]>) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Directory(PathBuf);
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn directory() -> Directory {
|
||||
let directory = Directory(std::env::temp_dir().join(format!(
|
||||
"vamp-review-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed),
|
||||
)));
|
||||
fs::create_dir(&directory.0).unwrap();
|
||||
directory
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_restarts_preserve_identity_and_exact_stale_anchors() {
|
||||
let directory = directory();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let storage = root.join("storage");
|
||||
let mut review = Review::new(root.clone());
|
||||
review.summary = "Unicode review\nMore context".into();
|
||||
review.verdict = Some(Verdict::ChangesRequested);
|
||||
review.comments.push(Comment {
|
||||
id: 7,
|
||||
anchor: Anchor {
|
||||
revision: "working:old-snapshot".into(),
|
||||
path: "日本語.rs".into(),
|
||||
side: Side::Before,
|
||||
start: crate::plugin::TextPosition { row: 0, column: 1 },
|
||||
end: crate::plugin::TextPosition { row: 1, column: 1 },
|
||||
content: "a👩🔬e\u{301}\r\n界x\n".into(),
|
||||
},
|
||||
body: "Please preserve 👩🔬".into(),
|
||||
resolved: true,
|
||||
});
|
||||
{
|
||||
let mut store = Store::open(&storage).unwrap();
|
||||
store.save(&mut review).unwrap();
|
||||
assert!(review.storage_id.is_some());
|
||||
assert_eq!(review.storage_revision, 1);
|
||||
}
|
||||
let mut store = Store::open(&storage).unwrap();
|
||||
let id = review.storage_id.unwrap();
|
||||
assert_eq!(
|
||||
store.list(&root).unwrap(),
|
||||
vec![(id, "Unicode review".into())]
|
||||
);
|
||||
let mut loaded = store.load(&root, id).unwrap();
|
||||
assert_eq!(loaded.storage_id, Some(id));
|
||||
assert_eq!(loaded.storage_revision, 1);
|
||||
assert_eq!(loaded.comments, review.comments);
|
||||
assert_eq!(loaded.verdict, review.verdict);
|
||||
assert!(!loaded.comments[0].anchor.matches(
|
||||
"working:new-snapshot",
|
||||
Path::new("日本語.rs"),
|
||||
Side::Before,
|
||||
"changed\n",
|
||||
));
|
||||
let path = root.join("export.json");
|
||||
loaded.save(&path).unwrap();
|
||||
let exported = fs::read(&path).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&exported).unwrap();
|
||||
assert_eq!(json["comments"][0]["resolved"], true);
|
||||
assert_eq!(json["version"], 3);
|
||||
assert!(json.get("storage_id").is_none());
|
||||
assert!(json.get("storage_revision").is_none());
|
||||
loaded.summary = "Database only".into();
|
||||
store.save(&mut loaded).unwrap();
|
||||
assert_eq!(loaded.storage_id, Some(id));
|
||||
assert_eq!(loaded.storage_revision, 2);
|
||||
assert_eq!(fs::read(&path).unwrap(), exported);
|
||||
let mut imported = Review::load(&path, &root).unwrap();
|
||||
assert_eq!(imported.storage_id, None);
|
||||
assert_eq!(imported.storage_revision, 0);
|
||||
store.save(&mut imported).unwrap();
|
||||
assert_ne!(imported.storage_id, Some(id));
|
||||
assert_eq!(store.list(&root).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_repository_identity_is_canonical_and_independent() {
|
||||
let first = directory();
|
||||
let second = directory();
|
||||
let first_root = first.0.canonicalize().unwrap();
|
||||
let second_root = second.0.canonicalize().unwrap();
|
||||
let mut store = Store::open(&first_root.join("storage")).unwrap();
|
||||
let mut a = Review::new(first_root.clone());
|
||||
let mut b = Review::new(second_root.clone());
|
||||
store.save(&mut a).unwrap();
|
||||
store.save(&mut b).unwrap();
|
||||
assert_ne!(a.storage_id, b.storage_id);
|
||||
assert_eq!(store.list(&first_root).unwrap().len(), 1);
|
||||
assert_eq!(store.list(&second_root).unwrap().len(), 1);
|
||||
assert!(store.load(&second_root, a.storage_id.unwrap()).is_err());
|
||||
let id = a.storage_id;
|
||||
let revision = a.storage_revision;
|
||||
a.root = second_root;
|
||||
assert!(store.save(&mut a).is_err());
|
||||
assert_eq!((a.storage_id, a.storage_revision), (id, revision));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let alias = first_root.join("alias");
|
||||
std::os::unix::fs::symlink(&first_root, &alias).unwrap();
|
||||
assert_eq!(
|
||||
store.list(&alias).unwrap(),
|
||||
store.list(&first_root).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_conflicts_and_locked_writes_keep_drafts_and_identity() {
|
||||
let directory = directory();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let storage = root.join("storage");
|
||||
let mut first = Store::open(&storage).unwrap();
|
||||
let mut second = Store::open(&storage).unwrap();
|
||||
let mut review = Review::new(root.clone());
|
||||
first.save(&mut review).unwrap();
|
||||
let id = review.storage_id.unwrap();
|
||||
let mut draft = second.load(&root, id).unwrap();
|
||||
review.summary = "First writer".into();
|
||||
first.save(&mut review).unwrap();
|
||||
draft.summary = "Second writer draft".into();
|
||||
assert!(
|
||||
second
|
||||
.save(&mut draft)
|
||||
.unwrap_err()
|
||||
.contains("another session")
|
||||
);
|
||||
assert_eq!(draft.summary, "Second writer draft");
|
||||
assert_eq!((draft.storage_id, draft.storage_revision), (Some(id), 1));
|
||||
assert_eq!(second.load(&root, id).unwrap().summary, "First writer");
|
||||
first.connection.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
let mut fresh = Review::new(root.clone());
|
||||
assert!(second.save(&mut fresh).unwrap_err().contains("locked"));
|
||||
assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0));
|
||||
first.connection.execute_batch("ROLLBACK").unwrap();
|
||||
second.save(&mut fresh).unwrap();
|
||||
assert!(fresh.storage_id.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_database_schemas_are_preserved() {
|
||||
for schema in [
|
||||
"CREATE TABLE other (value TEXT); PRAGMA user_version = 42",
|
||||
"CREATE TABLE other (value TEXT); PRAGMA user_version = 1",
|
||||
"",
|
||||
] {
|
||||
let directory = directory();
|
||||
let path = directory.0.join("reviews.sqlite3");
|
||||
let connection = Connection::open(&path).unwrap();
|
||||
connection.execute_batch(schema).unwrap();
|
||||
drop(connection);
|
||||
let original = fs::read(&path).unwrap();
|
||||
assert!(Store::open(&directory.0).err().unwrap().contains("schema"));
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_mismatched_database_documents_are_rejected() {
|
||||
let other = directory();
|
||||
let directory = directory();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let mut store = Store::open(&root.join("storage")).unwrap();
|
||||
let mut review = Review::new(root.clone());
|
||||
store.save(&mut review).unwrap();
|
||||
let id = review.storage_id.unwrap();
|
||||
let mut wrong_root = serde_json::to_value(&review).unwrap();
|
||||
wrong_root["root"] = serde_json::json!(other.0.canonicalize().unwrap());
|
||||
let mut invalid_version = serde_json::to_value(&review).unwrap();
|
||||
invalid_version["version"] = 1.into();
|
||||
let mut inherited_identity = serde_json::to_value(&review).unwrap();
|
||||
inherited_identity["storage_id"] = id.into();
|
||||
for document in [
|
||||
"{".to_owned(),
|
||||
serde_json::to_string(&wrong_root).unwrap(),
|
||||
serde_json::to_string(&invalid_version).unwrap(),
|
||||
serde_json::to_string(&inherited_identity).unwrap(),
|
||||
] {
|
||||
store
|
||||
.connection
|
||||
.execute(
|
||||
"UPDATE reviews SET document = ?1 WHERE id = ?2",
|
||||
params![document, id],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(store.load(&root, id).is_err());
|
||||
assert!(store.list(&root).is_err());
|
||||
}
|
||||
store
|
||||
.connection
|
||||
.execute_batch("PRAGMA ignore_check_constraints = ON")
|
||||
.unwrap();
|
||||
store
|
||||
.connection
|
||||
.execute(
|
||||
"UPDATE reviews SET document = CAST(zeroblob(?1) AS TEXT) WHERE id = ?2",
|
||||
params![LIMIT as i64 + 1, id],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(store.load(&root, id).unwrap_err().contains("64 MiB"));
|
||||
assert!(store.list(&root).unwrap_err().contains("64 MiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_database_and_export_writes_preserve_metadata_and_documents() {
|
||||
let directory = directory();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let mut store = Store::open(&root.join("storage")).unwrap();
|
||||
let mut review = Review::new(root.clone());
|
||||
store.save(&mut review).unwrap();
|
||||
let identity = (review.storage_id, review.storage_revision);
|
||||
store
|
||||
.connection
|
||||
.execute_batch("PRAGMA query_only = ON")
|
||||
.unwrap();
|
||||
review.summary = "Unsaved draft".into();
|
||||
assert!(store.save(&mut review).is_err());
|
||||
assert_eq!((review.storage_id, review.storage_revision), identity);
|
||||
assert_eq!(review.summary, "Unsaved draft");
|
||||
assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, "");
|
||||
let mut fresh = Review::new(root.clone());
|
||||
assert!(store.save(&mut fresh).is_err());
|
||||
assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0));
|
||||
store
|
||||
.connection
|
||||
.execute_batch("PRAGMA query_only = OFF")
|
||||
.unwrap();
|
||||
review.summary = "x".repeat(LIMIT);
|
||||
assert!(store.save(&mut review).unwrap_err().contains("64 MiB"));
|
||||
assert!(
|
||||
review
|
||||
.save(&root.join("oversized.json"))
|
||||
.unwrap_err()
|
||||
.contains("64 MiB")
|
||||
);
|
||||
assert!(!root.join("oversized.json").exists());
|
||||
assert_eq!((review.storage_id, review.storage_revision), identity);
|
||||
assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, "");
|
||||
let blocked = root.join("not-a-directory");
|
||||
fs::write(&blocked, b"preserve").unwrap();
|
||||
assert!(Store::open(&blocked).is_err());
|
||||
assert_eq!(fs::read(&blocked).unwrap(), b"preserve");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn database_files_are_private_and_target_symlinks_are_refused() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = directory();
|
||||
let storage = directory.0.join("storage");
|
||||
drop(Store::open(&storage).unwrap());
|
||||
let path = storage.join("reviews.sqlite3");
|
||||
assert_eq!(
|
||||
fs::metadata(&storage).unwrap().permissions().mode() & 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
let original = fs::read(&path).unwrap();
|
||||
let links = directory.0.join("links");
|
||||
fs::create_dir(&links).unwrap();
|
||||
std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap();
|
||||
assert!(Store::open(&links).err().unwrap().contains("symbolic link"));
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_directory_uses_absolute_xdg_then_absolute_home() {
|
||||
let resolve = |xdg: Option<&str>, home: Option<&str>| {
|
||||
directory_from(xdg.map(OsStr::new), home.map(OsStr::new))
|
||||
};
|
||||
assert_eq!(
|
||||
resolve(Some("/data"), Some("/home/me")).unwrap(),
|
||||
Path::new("/data/vamp/reviews")
|
||||
);
|
||||
for xdg in [None, Some("relative"), Some("")] {
|
||||
assert_eq!(
|
||||
resolve(xdg, Some("/home/me")).unwrap(),
|
||||
Path::new("/home/me/.local/share/vamp/reviews")
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
resolve(Some("/data"), None).unwrap(),
|
||||
Path::new("/data/vamp/reviews")
|
||||
);
|
||||
assert!(resolve(None, None).is_err());
|
||||
assert!(resolve(None, Some("relative")).is_err());
|
||||
assert!(resolve(None, Some("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_validates_anchors_and_preserves_files_on_failure() {
|
||||
let directory = directory();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let path = root.join("review.json");
|
||||
let mut review = Review::new(root.clone());
|
||||
review.summary = "Local review".into();
|
||||
review.verdict = Some(Verdict::ChangesRequested);
|
||||
review.comments.push(Comment {
|
||||
id: 1,
|
||||
anchor: Anchor {
|
||||
revision: "working:immutable".into(),
|
||||
path: "source.rs".into(),
|
||||
side: Side::After,
|
||||
start: crate::plugin::TextPosition { row: 0, column: 0 },
|
||||
end: crate::plugin::TextPosition { row: 2, column: 0 },
|
||||
content: "first\nlast\n".into(),
|
||||
},
|
||||
body: "Please adjust this".into(),
|
||||
resolved: false,
|
||||
});
|
||||
review.save(&path).unwrap();
|
||||
let mut loaded = Review::load(&path, &root).unwrap();
|
||||
assert_eq!(loaded.comments, review.comments);
|
||||
assert_eq!(loaded.verdict, review.verdict);
|
||||
let anchor = &loaded.comments[0].anchor;
|
||||
assert!(anchor.matches(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
"first\nlast\n"
|
||||
));
|
||||
assert!(!anchor.matches(
|
||||
"working:other",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
"first\nlast\n"
|
||||
));
|
||||
assert!(anchor.matches_lines(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&["first".into(), "last".into()],
|
||||
true
|
||||
));
|
||||
assert!(!anchor.matches_lines(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&["first".into(), "last".into()],
|
||||
false
|
||||
));
|
||||
loaded.summary = "Updated".into();
|
||||
loaded.save(&path).unwrap();
|
||||
assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated");
|
||||
assert!(Review::new(root.clone()).save(&path).is_err());
|
||||
let valid = fs::read(&path).unwrap();
|
||||
for (field, value) in [
|
||||
("version", serde_json::json!(2)),
|
||||
("root", serde_json::json!(root.join("other"))),
|
||||
(
|
||||
"comments",
|
||||
serde_json::json!([loaded.comments[0], loaded.comments[0]]),
|
||||
),
|
||||
] {
|
||||
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
||||
invalid[field] = value;
|
||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err());
|
||||
}
|
||||
for (field, value) in [
|
||||
("path", serde_json::json!("../outside")),
|
||||
("path", serde_json::json!("/absolute")),
|
||||
("path", serde_json::json!(".git/config")),
|
||||
("start", serde_json::json!({"row": 2, "column": 1})),
|
||||
("start", serde_json::json!({"row": 3, "column": 0})),
|
||||
("end", serde_json::json!({"row": 3, "column": 0})),
|
||||
("revision", serde_json::json!("")),
|
||||
] {
|
||||
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
||||
invalid["comments"][0]["anchor"][field] = value;
|
||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err());
|
||||
}
|
||||
fs::create_dir(root.join("other")).unwrap();
|
||||
fs::write(&path, &valid).unwrap();
|
||||
assert!(Review::load(&path, &root.join("other")).is_err());
|
||||
fs::write(&path, b"external edit").unwrap();
|
||||
assert!(loaded.save(&path).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
||||
assert_eq!(loaded.summary, "Updated");
|
||||
let invalid = root.join("invalid.json");
|
||||
loaded.comments[0].anchor.start.row = usize::MAX;
|
||||
assert!(loaded.save(&invalid).is_err());
|
||||
assert!(!invalid.exists());
|
||||
let large = root.join("large.json");
|
||||
fs::File::create(&large)
|
||||
.unwrap()
|
||||
.set_len(LIMIT as u64 + 1)
|
||||
.unwrap();
|
||||
assert!(Review::load(&large, &root).is_err());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = root.join("link.json");
|
||||
std::os::unix::fs::symlink(&path, &link).unwrap();
|
||||
assert!(Review::load(&link, &root).is_err());
|
||||
assert!(review.save(&link).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_version_two_is_rejected_without_rewriting_it() {
|
||||
let directory = directory();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let mut store = Store::open(&root.join("storage")).unwrap();
|
||||
let mut review = Review::new(root.clone());
|
||||
store.save(&mut review).unwrap();
|
||||
let mut old = serde_json::to_value(&review).unwrap();
|
||||
old["version"] = 2.into();
|
||||
let document = serde_json::to_string(&old).unwrap();
|
||||
store
|
||||
.connection
|
||||
.execute(
|
||||
"UPDATE reviews SET document = ?1 WHERE id = ?2",
|
||||
params![document, review.storage_id.unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.load(&root, review.storage_id.unwrap())
|
||||
.unwrap_err()
|
||||
.contains("Unsupported review version 2")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.list(&root)
|
||||
.unwrap_err()
|
||||
.contains("Unsupported review version 2")
|
||||
);
|
||||
let preserved: String = store
|
||||
.connection
|
||||
.query_row(
|
||||
"SELECT document FROM reviews WHERE id = ?1",
|
||||
[review.storage_id.unwrap()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(preserved, document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() {
|
||||
use crate::plugin::TextPosition;
|
||||
let directory = directory();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let path = root.join("review.json");
|
||||
let mut review = Review::new(root.clone());
|
||||
review.comments.push(Comment {
|
||||
id: 1,
|
||||
anchor: Anchor {
|
||||
revision: "working:immutable".into(),
|
||||
path: "source.rs".into(),
|
||||
side: Side::After,
|
||||
start: TextPosition { row: 0, column: 1 },
|
||||
end: TextPosition { row: 1, column: 1 },
|
||||
content: "a👩🔬e\u{301}\n界x\n".into(),
|
||||
},
|
||||
body: "Exact text".into(),
|
||||
resolved: false,
|
||||
});
|
||||
review.save(&path).unwrap();
|
||||
let mut loaded = Review::load(&path, &root).unwrap();
|
||||
assert_eq!(loaded.comments, review.comments);
|
||||
assert_eq!(loaded.comments[0].anchor.rows(), 0..=1);
|
||||
let valid = serde_json::to_value(&review).unwrap();
|
||||
let mut missing_resolution = valid.clone();
|
||||
missing_resolution["comments"][0]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("resolved");
|
||||
for version in [2, 3] {
|
||||
missing_resolution["version"] = version.into();
|
||||
let bytes = serde_json::to_vec(&missing_resolution).unwrap();
|
||||
fs::write(&path, &bytes).unwrap();
|
||||
let error = Review::load(&path, &root).unwrap_err();
|
||||
assert!(error.contains(if version == 2 {
|
||||
"Unsupported review version 2"
|
||||
} else {
|
||||
"missing field `resolved`"
|
||||
}));
|
||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||
}
|
||||
for (field, value) in [
|
||||
("start", serde_json::json!({"row": 0, "column": 4})),
|
||||
("end", serde_json::json!({"row": 1, "column": 3})),
|
||||
("end", serde_json::json!({"row": 2, "column": 1})),
|
||||
("end", serde_json::json!({"row": 3, "column": 0})),
|
||||
("end", serde_json::json!({"row": 0, "column": 0})),
|
||||
("start", serde_json::json!({"row": -1, "column": 0})),
|
||||
(
|
||||
"start",
|
||||
serde_json::json!({"row": 0, "column": 0, "extra": 0}),
|
||||
),
|
||||
("start_line", serde_json::json!(1)),
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
invalid["comments"][0]["anchor"][field] = value;
|
||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err(), "{field}");
|
||||
}
|
||||
let mut wrong_version = valid;
|
||||
wrong_version["version"] = 1.into();
|
||||
fs::write(&path, serde_json::to_vec(&wrong_version).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err());
|
||||
loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 };
|
||||
assert!(loaded.save(&root.join("backwards.json")).is_err());
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/review.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-110
@@ -194,113 +194,5 @@ fn print_review(review: &Value) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vamp::{
|
||||
plugin::TextPosition,
|
||||
review::{Comment, Side},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn output_filters_resolution_and_extracts_exact_graphemes() {
|
||||
let mut review = Review::new(PathBuf::new());
|
||||
review.storage_id = Some(7);
|
||||
review.storage_revision = 2;
|
||||
review.comments = [false, true]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(id, resolved)| Comment {
|
||||
id: id as u64 + 1,
|
||||
body: "fix".into(),
|
||||
resolved,
|
||||
anchor: Anchor {
|
||||
revision: "commit".into(),
|
||||
path: "a.rs".into(),
|
||||
side: Side::After,
|
||||
start: TextPosition { row: 0, column: 1 },
|
||||
end: TextPosition { row: 1, column: 1 },
|
||||
content: "xe\u{301}👩💻\n界z\n".into(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let output = envelope(&review, false, true);
|
||||
assert_eq!(output["comments"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(output["comments"][0]["excerpt"], "e\u{301}👩💻\n界");
|
||||
assert!(output["comments"][0]["anchor"].get("content").is_none());
|
||||
assert_eq!(output["counts"]["resolved"], 1);
|
||||
assert_eq!(
|
||||
envelope(&review, true, true)["comments"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_storage_is_not_created_and_incompatible_storage_is_preserved() {
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("vamp-review-cli-{}", std::process::id()));
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
let absent = directory.join("absent");
|
||||
assert!(Store::open_read_only(&absent).unwrap().is_none());
|
||||
assert!(!absent.exists());
|
||||
let path = directory.join("reviews.sqlite3");
|
||||
std::fs::write(&path, b"incompatible").unwrap();
|
||||
assert!(Store::open_read_only(&directory).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"incompatible");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let connection = rusqlite::Connection::open(&path).unwrap();
|
||||
connection
|
||||
.execute_batch("CREATE TABLE incompatible (value TEXT); PRAGMA user_version = 42")
|
||||
.unwrap();
|
||||
drop(connection);
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
assert!(
|
||||
Store::open_read_only(&directory)
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("schema")
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_storage_is_read_only_and_unchanged() {
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("vamp-review-cli-existing-{}", std::process::id()));
|
||||
let mut store = Store::open(&directory).unwrap();
|
||||
let mut review = Review::new(directory.clone());
|
||||
review.summary = "stored".into();
|
||||
store.save(&mut review).unwrap();
|
||||
drop(store);
|
||||
let path = directory.join("reviews.sqlite3");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
|
||||
}
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let mut store = Store::open_read_only(&directory).unwrap().unwrap();
|
||||
let mut loaded = store.load(&directory, review.storage_id.unwrap()).unwrap();
|
||||
assert_eq!(loaded.summary, "stored");
|
||||
loaded.summary = "changed".into();
|
||||
assert!(store.save(&mut loaded).is_err());
|
||||
drop(store);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o640
|
||||
);
|
||||
let links = directory.join("links");
|
||||
std::fs::create_dir(&links).unwrap();
|
||||
std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap();
|
||||
assert!(Store::open_read_only(&links).is_err());
|
||||
}
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/review_cli.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-2299
File diff suppressed because it is too large
Load Diff
+2
-3655
File diff suppressed because it is too large
Load Diff
+2
-121
@@ -362,124 +362,5 @@ impl Render<'_> {
|
||||
#[cfg(test)]
|
||||
// These fixtures intentionally contain ranges, not lists of indices.
|
||||
#[allow(clippy::single_range_in_vec_init)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grammar_expands_nested_mirrors_choices_variables_and_normalized_offsets() {
|
||||
let (text, snippet) = expand(
|
||||
"call($1, ${1:λ${2:x}}, ${3|a\\,b,c|}, ${UNKNOWN:${TM_LINE_NUMBER}})\r\n$0\\$",
|
||||
|name| (name == "TM_LINE_NUMBER").then(|| "4".to_owned()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(text, "call(λx, λx, a,b, 4)\n$");
|
||||
assert_eq!(
|
||||
snippet.tabstops,
|
||||
vec![
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![5..8, 10..13],
|
||||
ancestors: vec![vec![], vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 2,
|
||||
ranges: vec![7..8, 12..13],
|
||||
ancestors: vec![vec![1], vec![1]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 3,
|
||||
ranges: vec![15..18],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: vec![23..23],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
]
|
||||
);
|
||||
let (text, snippet) = expand("call($1, ${2})", |_| None).unwrap();
|
||||
assert_eq!(text, "call(, )");
|
||||
assert_eq!(
|
||||
snippet.tabstops,
|
||||
vec![
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![5..5],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 2,
|
||||
ranges: vec![7..7],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: vec![8..8],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
expand("${KNOWN:${1:hidden}}$1", |name| {
|
||||
(name == "KNOWN").then(|| "shown".to_owned())
|
||||
})
|
||||
.unwrap()
|
||||
.0,
|
||||
"shown"
|
||||
);
|
||||
let (text, snippet) = expand("call(${CTX})$1", |name| {
|
||||
(name == "CTX").then(|| "a\r\nb\rc".to_owned())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(text, "call(a\nb\nc)");
|
||||
assert_eq!(snippet.tabstops[0].ranges, [11..11]);
|
||||
assert_eq!(
|
||||
expand("${1|a\\|b,c|}\\}\\\\", |_| None).unwrap().0,
|
||||
"a|b}\\"
|
||||
);
|
||||
for input in ["${1", "${1:$1}", "${1:a}${1:b}$1", "${1:e}\u{301}"] {
|
||||
if input == "${1:a}${1:b}$1" {
|
||||
assert_eq!(expand(input, |_| None).unwrap().0, "aaa");
|
||||
} else {
|
||||
assert_eq!(expand(input, |_| None), Err(Error::Invalid));
|
||||
}
|
||||
}
|
||||
let (text, snippet) = expand("${1}$1", |_| None).unwrap();
|
||||
assert!(text.is_empty());
|
||||
assert_eq!(
|
||||
snippet.tabstops[0],
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![0..0, 0..0],
|
||||
ancestors: vec![vec![], vec![]],
|
||||
}
|
||||
);
|
||||
for input in [
|
||||
"$UNKNOWN",
|
||||
"${TM_FILENAME/(.*)/$1/}",
|
||||
"${1/a/b/}",
|
||||
"${1:a$0b}",
|
||||
"$0$0",
|
||||
"${0:arg}",
|
||||
"${0:$1}",
|
||||
] {
|
||||
assert_eq!(expand(input, |_| None), Err(Error::Unsupported));
|
||||
}
|
||||
assert_eq!(
|
||||
expand(&"$1".repeat(MAX_STOPS + 1), |_| None),
|
||||
Err(Error::Limit)
|
||||
);
|
||||
assert_eq!(
|
||||
expand(
|
||||
&format!(
|
||||
"{}x{}",
|
||||
"${1:".repeat(MAX_DEPTH + 1),
|
||||
"}".repeat(MAX_DEPTH + 1)
|
||||
),
|
||||
|_| None
|
||||
),
|
||||
Err(Error::Limit)
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/snippet.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-1130
File diff suppressed because it is too large
Load Diff
+2
-75
@@ -407,78 +407,5 @@ fn detect_shebang(line: &str) -> Option<&'static LanguageSpec> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selected_files_and_auxiliary_grammars_are_registered() {
|
||||
for (filename, language) in [
|
||||
("main.rs", "Rust"),
|
||||
("Gemfile", "Ruby"),
|
||||
("main.py", "Python"),
|
||||
("package.json", "JSON"),
|
||||
(".bashrc", "Bash"),
|
||||
("GNUmakefile", "Makefile"),
|
||||
("README.md", "Markdown"),
|
||||
("main.jsx", "JavaScript"),
|
||||
("main.ts", "TypeScript"),
|
||||
("index.html", "HTML"),
|
||||
("style.css", "CSS"),
|
||||
("config.yml", "YAML"),
|
||||
("Cargo.toml", "TOML"),
|
||||
("Cargo.lock", "TOML"),
|
||||
("main.c", "C"),
|
||||
("main.cpp", "C++"),
|
||||
("main.C", "C++"),
|
||||
("main.go", "Go"),
|
||||
("main.zig", "Zig"),
|
||||
] {
|
||||
assert_eq!(
|
||||
detect(Some(Path::new(filename)), None).map(|spec| spec.name),
|
||||
Some(language)
|
||||
);
|
||||
}
|
||||
let ts = detect(Some(Path::new("main.ts")), None).unwrap();
|
||||
let tsx = detect(Some(Path::new("main.tsx")), None).unwrap();
|
||||
assert_eq!(ts.name, tsx.name);
|
||||
assert!(!std::ptr::eq(ts, tsx));
|
||||
assert_ne!(ts.language, tsx.language);
|
||||
assert!(std::ptr::eq(tsx, by_name("TSX").unwrap()));
|
||||
assert!(by_name("markdown_inline").is_some());
|
||||
for filename in [
|
||||
"notes.txt",
|
||||
"main.hs",
|
||||
"main.scala",
|
||||
"main.lua",
|
||||
"Dockerfile",
|
||||
] {
|
||||
assert!(detect(Some(Path::new(filename)), None).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shebangs_support_bom_env_options_and_filename_precedence() {
|
||||
for (line, language) in [
|
||||
("#!/usr/bin/python3", "Python"),
|
||||
("\u{feff}#!/usr/bin/env -S python3.12 -u", "Python"),
|
||||
("#!/usr/bin/env -u PYTHONHOME FOO=bar python3", "Python"),
|
||||
("#!/usr/bin/ruby3.3", "Ruby"),
|
||||
("#!/bin/bash", "Bash"),
|
||||
("#! /usr/bin/env node", "JavaScript"),
|
||||
] {
|
||||
assert_eq!(
|
||||
detect(None, Some(line)).map(|spec| spec.name),
|
||||
Some(language)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
detect(Some(Path::new("main.rs")), Some("#!/bin/bash"))
|
||||
.unwrap()
|
||||
.name,
|
||||
"Rust"
|
||||
);
|
||||
assert!(detect(None, Some("#!/usr/bin/python-not-real")).is_none());
|
||||
assert!(detect(None, Some("#!/usr/bin/perl")).is_none());
|
||||
assert!(detect(None, Some("plain text")).is_none());
|
||||
}
|
||||
}
|
||||
#[path = "../../tests/unit/syntax_languages.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-275
@@ -295,278 +295,5 @@ fn hex_value(component: &[u8]) -> Option<u32> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind};
|
||||
|
||||
fn key(code: KeyCode, modifiers: KeyModifiers) -> Event {
|
||||
Event::Key(KeyEvent::new(code, modifiers))
|
||||
}
|
||||
|
||||
fn bytes(collector: &mut TerminalColors, text: &str, now: Instant) {
|
||||
for ch in text.chars() {
|
||||
let modifiers = if ch.is_ascii_uppercase() {
|
||||
KeyModifiers::SHIFT
|
||||
} else {
|
||||
KeyModifiers::NONE
|
||||
};
|
||||
collector.push(key(KeyCode::Char(ch), modifiers), now);
|
||||
}
|
||||
}
|
||||
|
||||
fn report(collector: &mut TerminalColors, body: &str, now: Instant) {
|
||||
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), now);
|
||||
bytes(collector, body, now);
|
||||
collector.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL), now);
|
||||
}
|
||||
|
||||
fn drain(collector: &mut TerminalColors) -> Vec<Event> {
|
||||
std::iter::from_fn(|| collector.pop()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragmented_triple_accepts_bel_and_both_st_encodings() {
|
||||
let now = Instant::now();
|
||||
let mut collector = TerminalColors::default();
|
||||
collector.query(&mut Vec::new(), now).unwrap();
|
||||
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), now);
|
||||
collector.push(
|
||||
key(KeyCode::Char(']'), KeyModifiers::NONE),
|
||||
now + Duration::from_millis(1),
|
||||
);
|
||||
bytes(
|
||||
&mut collector,
|
||||
"11;rgb:0/12/345",
|
||||
now + Duration::from_millis(2),
|
||||
);
|
||||
collector.push(
|
||||
key(KeyCode::Esc, KeyModifiers::NONE),
|
||||
now + Duration::from_millis(3),
|
||||
);
|
||||
collector.push(
|
||||
key(KeyCode::Char('\\'), KeyModifiers::NONE),
|
||||
now + Duration::from_millis(4),
|
||||
);
|
||||
assert_eq!(collector.colors, None);
|
||||
report(
|
||||
&mut collector,
|
||||
"4;2;rgb:f/ffff/ABCD",
|
||||
now + Duration::from_millis(5),
|
||||
);
|
||||
collector.push(
|
||||
key(KeyCode::Char(']'), KeyModifiers::ALT),
|
||||
now + Duration::from_millis(6),
|
||||
);
|
||||
bytes(
|
||||
&mut collector,
|
||||
"4;1;#FF1234",
|
||||
now + Duration::from_millis(7),
|
||||
);
|
||||
collector.push(
|
||||
key(KeyCode::Char('\\'), KeyModifiers::ALT),
|
||||
now + Duration::from_millis(8),
|
||||
);
|
||||
assert_eq!(
|
||||
collector.colors,
|
||||
Some(ThemeColors {
|
||||
background: [0, 18, 52],
|
||||
red: [255, 18, 52],
|
||||
green: [255, 255, 171],
|
||||
})
|
||||
);
|
||||
assert!(drain(&mut collector).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_refresh_atomically_replaces_light_and_dark_triples() {
|
||||
let now = Instant::now();
|
||||
let mut collector = TerminalColors::default();
|
||||
let palettes = [
|
||||
("11;#FDF6E3", [253, 246, 227]),
|
||||
("11;rgb:0000/0000/0000", [0, 0, 0]),
|
||||
];
|
||||
for (cycle, (background, expected)) in palettes.into_iter().enumerate() {
|
||||
let time = now + Duration::from_secs(cycle as u64);
|
||||
let focus = Event::FocusGained;
|
||||
collector.push(focus.clone(), time);
|
||||
assert_eq!(collector.pop(), Some(focus));
|
||||
let previous = collector.colors;
|
||||
collector.query(&mut Vec::new(), time).unwrap();
|
||||
report(&mut collector, "4;1;rgb:ff/00/00", time);
|
||||
report(&mut collector, background, time);
|
||||
assert_eq!(collector.colors, previous);
|
||||
report(&mut collector, "4;2;rgb:00/ff/00", time);
|
||||
assert_eq!(
|
||||
collector.colors,
|
||||
Some(ThemeColors {
|
||||
background: expected,
|
||||
red: [255, 0, 0],
|
||||
green: [0, 255, 0],
|
||||
})
|
||||
);
|
||||
assert!(drain(&mut collector).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_refresh_detects_silent_change_without_overlapping_queries() {
|
||||
let now = Instant::now();
|
||||
let mut collector = TerminalColors::default();
|
||||
let mut output = Vec::new();
|
||||
assert!(collector.refresh_due(now));
|
||||
collector.query(&mut output, now).unwrap();
|
||||
report(&mut collector, "11;#101010", now);
|
||||
report(&mut collector, "4;1;#FF0000", now);
|
||||
report(&mut collector, "4;2;#00FF00", now);
|
||||
let previous = collector.colors;
|
||||
assert!(previous.is_some());
|
||||
|
||||
// The terminal changes its palette without emitting a notification.
|
||||
let refresh = now + Duration::from_secs(1);
|
||||
assert!(!collector.refresh_due(refresh - Duration::from_nanos(1)));
|
||||
assert!(collector.refresh_due(refresh));
|
||||
collector.query(&mut output, refresh).unwrap();
|
||||
report(&mut collector, "11;#EEEEEE", refresh);
|
||||
report(&mut collector, "4;1;#AA1122", refresh);
|
||||
assert_eq!(collector.colors, previous);
|
||||
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
|
||||
collector.push(typed.clone(), refresh);
|
||||
assert_eq!(drain(&mut collector), vec![typed]);
|
||||
|
||||
// Even an overdue probe must wait until the incomplete query expires.
|
||||
let retry = refresh + Duration::from_secs(1);
|
||||
assert!(!collector.refresh_due(retry));
|
||||
collector.expire(retry);
|
||||
assert_eq!(collector.colors, None);
|
||||
assert!(collector.refresh_due(retry));
|
||||
collector.query(&mut output, retry).unwrap();
|
||||
report(&mut collector, "11;#EEEEEE", retry);
|
||||
report(&mut collector, "4;1;#AA1122", retry);
|
||||
assert_eq!(collector.colors, None);
|
||||
report(&mut collector, "4;2;#22BB33", retry);
|
||||
assert_eq!(
|
||||
collector.colors,
|
||||
Some(ThemeColors {
|
||||
background: [238, 238, 238],
|
||||
red: [170, 17, 34],
|
||||
green: [34, 187, 51],
|
||||
})
|
||||
);
|
||||
assert!(drain(&mut collector).is_empty());
|
||||
assert!(!collector.refresh_due(retry + Duration::from_millis(999)));
|
||||
assert!(collector.refresh_due(retry + Duration::from_secs(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() {
|
||||
let now = Instant::now();
|
||||
let mut collector = TerminalColors::default();
|
||||
collector.query(&mut Vec::new(), now).unwrap();
|
||||
report(&mut collector, "11;#101010", now);
|
||||
report(&mut collector, "4;1;#FF0000", now);
|
||||
report(&mut collector, "4;2;#00FF00", now);
|
||||
assert!(collector.colors.is_some());
|
||||
let refresh = now + Duration::from_secs(1);
|
||||
collector.query(&mut Vec::new(), refresh).unwrap();
|
||||
report(&mut collector, "11;#EEEEEE", refresh);
|
||||
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), refresh);
|
||||
bytes(&mut collector, "4;1;rgb:ffff/", refresh);
|
||||
collector.expire(refresh + QUERY_TIMEOUT);
|
||||
assert_eq!(collector.colors, None);
|
||||
let late = refresh + Duration::from_secs(1);
|
||||
bytes(&mut collector, "0000/0000", late);
|
||||
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), late);
|
||||
collector.expire(late + Duration::from_secs(1));
|
||||
collector.push(
|
||||
key(KeyCode::Char('\\'), KeyModifiers::NONE),
|
||||
late + Duration::from_secs(1),
|
||||
);
|
||||
report(&mut collector, "4;2;#00FF00", late + Duration::from_secs(1));
|
||||
assert_eq!(collector.colors, None);
|
||||
assert!(drain(&mut collector).is_empty());
|
||||
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
|
||||
collector.push(typed.clone(), late + Duration::from_secs(2));
|
||||
assert_eq!(collector.pop(), Some(typed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_events_and_rejected_candidates_replay_exactly() {
|
||||
let now = Instant::now();
|
||||
let mut collector = TerminalColors::default();
|
||||
collector.query(&mut Vec::new(), now).unwrap();
|
||||
let events = vec![
|
||||
key(KeyCode::Char(']'), KeyModifiers::ALT),
|
||||
key(KeyCode::Char('4'), KeyModifiers::NONE),
|
||||
key(KeyCode::Char(';'), KeyModifiers::NONE),
|
||||
key(KeyCode::Char('3'), KeyModifiers::NONE),
|
||||
key(KeyCode::Char('g'), KeyModifiers::CONTROL),
|
||||
key(KeyCode::Char(']'), KeyModifiers::ALT | KeyModifiers::SHIFT),
|
||||
Event::Key(KeyEvent::new_with_kind(
|
||||
KeyCode::Char(']'),
|
||||
KeyModifiers::ALT,
|
||||
KeyEventKind::Release,
|
||||
)),
|
||||
Event::Key(KeyEvent::new_with_kind(
|
||||
KeyCode::Char('A'),
|
||||
KeyModifiers::SHIFT,
|
||||
KeyEventKind::Repeat,
|
||||
)),
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
state: KeyEventState::CAPS_LOCK,
|
||||
}),
|
||||
Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Moved,
|
||||
column: 2,
|
||||
row: 3,
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
}),
|
||||
Event::FocusLost,
|
||||
Event::Resize(80, 24),
|
||||
key(KeyCode::Char(']'), KeyModifiers::ALT),
|
||||
Event::Paste("\x1b]11;rgb:ff/ff/ff\x07".into()),
|
||||
Event::FocusGained,
|
||||
];
|
||||
for event in &events {
|
||||
collector.push(event.clone(), now);
|
||||
}
|
||||
assert_eq!(drain(&mut collector), events);
|
||||
assert_eq!(collector.colors, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_oversized_reports_and_lone_escape_do_not_eat_input() {
|
||||
let now = Instant::now();
|
||||
let mut collector = TerminalColors::default();
|
||||
for body in [
|
||||
"11;rgb:12345/0/0",
|
||||
"11;rgb:1//3",
|
||||
"11;#12345",
|
||||
"11;rgb:1/2/3/4",
|
||||
] {
|
||||
let mut expected = vec![key(KeyCode::Char(']'), KeyModifiers::ALT)];
|
||||
expected.extend(
|
||||
body.chars()
|
||||
.map(|ch| key(KeyCode::Char(ch), KeyModifiers::NONE)),
|
||||
);
|
||||
expected.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL));
|
||||
for event in &expected {
|
||||
collector.push(event.clone(), now);
|
||||
}
|
||||
assert_eq!(drain(&mut collector), expected);
|
||||
}
|
||||
let escape = key(KeyCode::Esc, KeyModifiers::NONE);
|
||||
collector.push(escape.clone(), now);
|
||||
assert_eq!(collector.pop(), None);
|
||||
collector.expire(now + ESCAPE_TIMEOUT);
|
||||
assert_eq!(collector.pop(), Some(escape));
|
||||
assert_eq!(collector.pop(), None);
|
||||
let start = key(KeyCode::Char(']'), KeyModifiers::ALT);
|
||||
collector.push(start.clone(), now + Duration::from_secs(1));
|
||||
collector.expire(now + Duration::from_secs(1) + QUERY_TIMEOUT);
|
||||
assert_eq!(collector.pop(), Some(start));
|
||||
assert_eq!(collector.pop(), None);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/terminal_colors.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-15293
File diff suppressed because it is too large
Load Diff
+2
-135
@@ -92,138 +92,5 @@ pub(crate) fn caret_segment(segments: &[Segment], column: usize) -> usize {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn layout(text: &str, width: usize) -> Vec<Segment> {
|
||||
let mut cache = WrapCache::default();
|
||||
cache.prepare((1, 0), width);
|
||||
cache.segments(0, text).to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_and_long_words_preserve_every_source_cell() {
|
||||
assert_eq!(
|
||||
layout("one two three", 8),
|
||||
vec![Segment { start: 0, end: 8 }, Segment { start: 8, end: 13 },]
|
||||
);
|
||||
let segments = layout("abcdefgh", 3);
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![
|
||||
Segment { start: 0, end: 3 },
|
||||
Segment { start: 3, end: 6 },
|
||||
Segment { start: 6, end: 8 },
|
||||
]
|
||||
);
|
||||
assert_eq!(caret_segment(&segments, 3), 1);
|
||||
assert_eq!(caret_segment(&segments, 8), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_words_do_not_wrap_indentation_onto_an_empty_visual_row() {
|
||||
for source in [
|
||||
" abcdefghijk",
|
||||
"\tabcdefghijk",
|
||||
"head abcdefghijk",
|
||||
] {
|
||||
let expanded = source.replace('\t', " ");
|
||||
let segments = layout(source, 8);
|
||||
assert_eq!(segments.first().unwrap().start, 0);
|
||||
assert_eq!(segments.last().unwrap().end, expanded.len());
|
||||
assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start));
|
||||
for segment in segments
|
||||
.iter()
|
||||
.filter(|segment| segment.start < segment.end)
|
||||
{
|
||||
assert!(
|
||||
!expanded[segment.start..segment.end].trim().is_empty(),
|
||||
"whitespace-only visual row {segment:?} in {source:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_use_source_stops_and_unicode_graphemes_remain_intact() {
|
||||
assert_eq!(
|
||||
layout("ab\t界e\u{301} z", 4),
|
||||
vec![
|
||||
Segment { start: 0, end: 4 },
|
||||
Segment { start: 4, end: 8 },
|
||||
Segment { start: 8, end: 9 },
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout("界界界", 3),
|
||||
vec![
|
||||
Segment { start: 0, end: 2 },
|
||||
Segment { start: 2, end: 4 },
|
||||
Segment { start: 4, end: 6 },
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(" \t ", 3),
|
||||
vec![Segment { start: 0, end: 3 }, Segment { start: 3, end: 5 },]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_tabs_preserve_all_expanded_cells_at_original_stops() {
|
||||
assert_eq!(
|
||||
layout("\tX", 2),
|
||||
[
|
||||
Segment { start: 0, end: 2 },
|
||||
Segment { start: 2, end: 4 },
|
||||
Segment { start: 4, end: 5 },
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout("a\tX", 2),
|
||||
[
|
||||
Segment { start: 0, end: 1 },
|
||||
Segment { start: 1, end: 3 },
|
||||
Segment { start: 3, end: 5 },
|
||||
Segment { start: 5, end: 5 },
|
||||
]
|
||||
);
|
||||
for source in ["\t", "a\t", "ab\tX", "\t\tX"] {
|
||||
let segments = layout(source, 1);
|
||||
assert!(
|
||||
segments
|
||||
.iter()
|
||||
.all(|segment| segment.end - segment.start <= 1)
|
||||
);
|
||||
assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start));
|
||||
let end = source.graphemes(true).fold(0, |column, grapheme| {
|
||||
column
|
||||
+ if grapheme == "\t" {
|
||||
TAB_WIDTH - column % TAB_WIDTH
|
||||
} else {
|
||||
1
|
||||
}
|
||||
});
|
||||
assert_eq!(segments.last().unwrap(), &Segment { start: end, end });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_lines_have_an_insertion_cell_and_cache_invalidation_is_lazy() {
|
||||
let mut cache = WrapCache::default();
|
||||
cache.prepare((7, 2), 4);
|
||||
assert_eq!(
|
||||
cache.segments(90, "abcd"),
|
||||
[Segment { start: 0, end: 4 }, Segment { start: 4, end: 4 },]
|
||||
);
|
||||
assert_eq!(caret_segment(cache.segments(90, "abcd"), 4), 1);
|
||||
assert_eq!(cache.rows.len(), 1);
|
||||
cache.prepare((7, 2), 4);
|
||||
assert_eq!(cache.rows.len(), 1);
|
||||
cache.prepare((7, 2), 8);
|
||||
assert!(cache.rows.is_empty());
|
||||
cache.segments(90, "abcd");
|
||||
cache.prepare((7, 3), 8);
|
||||
assert!(cache.rows.is_empty());
|
||||
assert_eq!(layout("", 0), [Segment { start: 0, end: 0 }]);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/wrap.rs"]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user