refactor: move embedded tests into dedicated test directory

This commit is contained in:
Valdemar Lorenzen
2026-10-16 16:30:07 +02:00
parent de6a57e67d
commit 2772e6878a
55 changed files with 45752 additions and 45858 deletions
+3 -229
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -15233
View File
File diff suppressed because it is too large Load Diff
+2 -988
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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](file.md)", "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_ ![alt](hidden.md)\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&amp;b&#x20;c.md)", "file").unwrap(),
target(Some("a&b c.md"), None)
);
assert_eq!(
at("[file](&lt;name&gt;)", "file").unwrap(),
target(Some("<name>"), None)
);
assert!(
at("[file](a&copy;b.md)", "file")
.unwrap_err()
.contains("Unsupported Markdown HTML entity")
);
assert_eq!(heading_row("# A &amp; B &#xE9;\n", "a--b-é").unwrap(), 0);
assert!(
heading_row("# &copy;\n", "copy")
.unwrap_err()
.contains("Unsupported Markdown HTML entity")
);
assert_eq!(heading_row("# &copy;\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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -3655
View File
File diff suppressed because it is too large Load Diff
+2 -121
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -75
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -135
View File
@@ -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;