mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 06:27:53 -06:00
Merge pull request #207 from voshart/pr/tests-windows
Tests: pass on Windows (path separators, temp folder inside home, one GPU edge pixel)
This commit is contained in:
@@ -292,7 +292,9 @@ fn devices_are_listed_and_imported_from() {
|
||||
assert_eq!(dev["name"], "CARD", "{line}");
|
||||
// the import review of its DCIM folder finds the photo
|
||||
let dcim = dev["path"].as_str().unwrap().to_string();
|
||||
let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths=[\"{dcim}\"]")]).output().unwrap();
|
||||
// JSON-escaped: a Windows path has backslashes
|
||||
let paths = serde_json::to_string(&[&dcim]).unwrap();
|
||||
let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths={paths}")]).output().unwrap();
|
||||
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
|
||||
assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}");
|
||||
}
|
||||
|
||||
+16
-10
@@ -768,8 +768,12 @@ mod tests {
|
||||
fail: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
/// The model volume spells paths with `/`; the code under test joins them with the host's separator.
|
||||
fn norm(p: &Path) -> String {
|
||||
p.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
fn idx(&self, p: &Path) -> Option<usize> {
|
||||
let p = p.to_string_lossy();
|
||||
let p = Self::norm(p);
|
||||
self.files.borrow().iter().position(|(f, _)| if self.ci { f.to_lowercase() == p.to_lowercase() } else { *f == p })
|
||||
}
|
||||
/// The listing: (exact path, contents), sorted.
|
||||
@@ -791,14 +795,14 @@ mod tests {
|
||||
self.idx(a).is_some() && self.idx(a) == self.idx(b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.fail.borrow().iter().any(|f| a.to_string_lossy().contains(f.as_str())) {
|
||||
if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
|
||||
return Err(std::io::Error::other("injected failure"));
|
||||
}
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists"));
|
||||
}
|
||||
let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
self.files.borrow_mut()[i].0 = b.to_string_lossy().to_string();
|
||||
self.files.borrow_mut()[i].0 = Self::norm(b);
|
||||
Ok(())
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
@@ -807,7 +811,7 @@ mod tests {
|
||||
}
|
||||
let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
let c = self.files.borrow()[i].1.clone();
|
||||
self.files.borrow_mut().push((b.to_string_lossy().to_string(), c));
|
||||
self.files.borrow_mut().push((Self::norm(b), c));
|
||||
Ok(())
|
||||
}
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
@@ -816,17 +820,19 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
|
||||
let stem = sidecar.with_extension("").to_string_lossy().to_string();
|
||||
let stem = Self::norm(&sidecar.with_extension(""));
|
||||
self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| {
|
||||
let p = Path::new(f);
|
||||
p.with_extension("").to_string_lossy() == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp"))
|
||||
Self::norm(&p.with_extension("")) == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn file_photo(id: u64, path: &str) -> Photo {
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
|
||||
// the host's separator, so `dir.join(name)` in the planner spells the same path the same way
|
||||
let path = path.replace('/', std::path::MAIN_SEPARATOR_STR);
|
||||
Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
|
||||
}
|
||||
|
||||
/// Issue #95: a case-only rename is a real change on a case-insensitive volume and never
|
||||
@@ -884,7 +890,7 @@ mod tests {
|
||||
let plans = s.plan_rename_with(&fs, &ids, "Trip-{seq}", 1);
|
||||
// c can't be renamed (the share went away), and neither can Trip-1 be moved back
|
||||
fs.fail.borrow_mut().extend(["c.jpg".to_string(), "Trip-1".to_string()]);
|
||||
let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string();
|
||||
let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string().replace('\\', "/");
|
||||
assert!(err.contains("rename /p/c.jpg"), "{err}");
|
||||
assert!(err.contains("/p/Trip-1.jpg could not be moved back"), "{err}");
|
||||
assert!(err.contains("/p/a.jpg → /p/Trip-1.jpg"), "lists what stayed renamed: {err}");
|
||||
@@ -899,7 +905,7 @@ mod tests {
|
||||
);
|
||||
// the catalog matches the disk
|
||||
let path = |s: &Session, id: u64| match &s.catalog.photo(PhotoId(id)).unwrap().source {
|
||||
Source::File { path } => path.clone(),
|
||||
Source::File { path } => path.replace('\\', "/"),
|
||||
Source::Demo { .. } => String::new(),
|
||||
};
|
||||
assert_eq!(path(&s, 1), "/p/Trip-1.jpg");
|
||||
@@ -913,7 +919,7 @@ mod tests {
|
||||
fs.fail.borrow_mut().extend(["d.jpg.xmp".to_string(), "/p/e.jpg".to_string()]);
|
||||
let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err();
|
||||
assert!(e.moved, "{e}");
|
||||
assert!(e.message.contains("it is now /p/e.jpg with its sidecar /p/e.xmp"), "{e}");
|
||||
assert!(e.message.replace('\\', "/").contains("it is now /p/e.jpg with its sidecar /p/e.xmp"), "{e}");
|
||||
assert_eq!(fs.listing(), vec![("/p/d.jpg.xmp".into(), "Df".into()), ("/p/e.jpg".into(), "D".into()), ("/p/e.xmp".into(), "Ds".into())]);
|
||||
// … and when the file does go back, its sidecars follow it
|
||||
let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]);
|
||||
|
||||
@@ -181,8 +181,12 @@ fn find_missing_matches_renamed_files_by_content() {
|
||||
let to = |name: &str| {
|
||||
found.iter().find(|f| f["from"].as_str().unwrap().ends_with(name)).map(|f| (f["to"].as_str().unwrap().to_string(), f["by"].clone()))
|
||||
};
|
||||
assert_eq!(to("a.png"), Some((dir.join("moved/Trip-001.png").to_string_lossy().to_string(), json!("content"))));
|
||||
assert_eq!(to("b.png"), Some((dir.join("moved/real/Trip-002.png").to_string_lossy().to_string(), json!("content"))), "not the impostor");
|
||||
assert_eq!(to("a.png"), Some((dir.join("moved").join("Trip-001.png").to_string_lossy().to_string(), json!("content"))));
|
||||
assert_eq!(
|
||||
to("b.png"),
|
||||
Some((dir.join("moved").join("real").join("Trip-002.png").to_string_lossy().to_string(), json!("content"))),
|
||||
"not the impostor"
|
||||
);
|
||||
assert_eq!(r["missing"], 1);
|
||||
assert_eq!(r["ambiguous"][0]["candidates"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -582,7 +586,7 @@ fn folders_rename_and_move_with_their_photos() {
|
||||
assert!(s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": b.join("deeper").to_string_lossy()})).is_err(), "not into itself");
|
||||
let r = s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": root.join("Archive").to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["relinked"], 1);
|
||||
assert_eq!(path(&s), root.join("Archive/Italy 2026/one.png").to_string_lossy());
|
||||
assert_eq!(path(&s), root.join("Archive").join("Italy 2026").join("one.png").to_string_lossy());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ fn import_review_preview_and_options() {
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
// c.png is already in the library
|
||||
s.execute("library.import", &json!({"paths": [src.join("sub/c.png").to_string_lossy()]})).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("sub").join("c.png").to_string_lossy()]})).unwrap();
|
||||
let n0 = s.catalog.len();
|
||||
let undo0 = s.undo.len();
|
||||
let pv = s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
|
||||
@@ -73,7 +73,23 @@ fn check(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings,
|
||||
let gpu = lightcraft_gpu::render(src, info, s, req, None).expect("gpu render");
|
||||
let (mean, max, over) = diff(&cpu, &gpu.image);
|
||||
eprintln!("{name:<28} {}x{} mean {mean:.4} max {max} >1: {:.4}%", cpu.width, cpu.height, over * 100.0);
|
||||
assert!(mean < MEAN_LSB && max <= MAX_LSB, "{name}: mean {mean:.4} LSB, max {max} LSB");
|
||||
// A pixel on the very edge of the picture can fall either side of the sampling boundary (the
|
||||
// lens warp with a shifted red plane lands exactly outside it): the CPU and a GPU's rounding
|
||||
// may then disagree on one edge pixel. Tolerate a couple of those, nothing in the interior.
|
||||
let (w, h) = (cpu.width, cpu.height);
|
||||
let strays: Vec<(usize, usize)> = cpu
|
||||
.data
|
||||
.iter()
|
||||
.zip(&gpu.image.data)
|
||||
.enumerate()
|
||||
.filter(|(_, (p, q))| (0..3).any(|c| p[c].abs_diff(q[c]) > MAX_LSB))
|
||||
.map(|(i, _)| (i % w, i / w))
|
||||
.collect();
|
||||
let on_the_edge = |&(x, y): &(usize, usize)| x == 0 || y == 0 || x + 1 == w || y + 1 == h;
|
||||
assert!(
|
||||
mean < MEAN_LSB && (max <= MAX_LSB || (strays.len() <= 2 && strays.iter().all(on_the_edge))),
|
||||
"{name}: mean {mean:.4} LSB, max {max} LSB, pixels over {MAX_LSB}: {strays:?}"
|
||||
);
|
||||
(mean, max)
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,18 @@ impl Headless {
|
||||
self.paint()
|
||||
}
|
||||
|
||||
/// Tests that browse a folder in the temp directory expect it to be a Local location of its
|
||||
/// own. Where the temp directory lies inside the home folder (Windows), the sidebar lists it
|
||||
/// inside Home's tree instead, so hide Home for the test; elsewhere this does nothing.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn hide_home_above(&mut self, path: &std::path::Path) {
|
||||
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default();
|
||||
if !home.is_empty() && lightcraft_catalog::query::folder_within(&path.to_string_lossy(), &home) {
|
||||
let r = self.request("engine.execute", json!({"command": "local.hide", "params": {"path": home}}), Duration::from_secs(10));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a control-protocol request (see [`crate::control`]) and run frames until it is
|
||||
/// answered, then until its input has been consumed. Returns `{"ok": …, "result"|"error": …}`.
|
||||
pub fn request(&mut self, method: &str, params: Value, timeout: Duration) -> Value {
|
||||
@@ -845,10 +857,13 @@ mod tests {
|
||||
w["result"].as_array().unwrap().iter().filter_map(|x| x["id"].as_str().map(String::from)).collect()
|
||||
};
|
||||
h.request("ui.set", json!({"leftPanel": true}), t);
|
||||
h.hide_home_above(&dir);
|
||||
h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t);
|
||||
h.settle(SETTLE);
|
||||
assert!(ids(&mut h).contains(&format!("source:local:{path}")));
|
||||
assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"));
|
||||
// the "Show hidden locations" row appears only while something is hidden
|
||||
let hidden_before = !h.app.ui.hidden_locations.is_empty();
|
||||
assert_eq!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), hidden_before);
|
||||
let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
@@ -859,7 +874,11 @@ mod tests {
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true);
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.ui.hidden_locations.is_empty());
|
||||
assert!(ids(&mut h).contains(&format!("source:local:{path}")));
|
||||
assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore");
|
||||
if !hidden_before {
|
||||
// (with Home hidden for this test, the folder is back inside Home's tree instead)
|
||||
assert!(ids(&mut h).contains(&format!("source:local:{path}")));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -876,8 +895,10 @@ mod tests {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
|
||||
let (photos, day1, day2, other) =
|
||||
(s(base.join("Photos")), s(base.join("Photos/2026/20260101")), s(base.join("Photos/2026/20260114")), s(base.join("Other")));
|
||||
// joined by component: the sidebar's ids spell paths with the platform's separator
|
||||
let year = base.join("Photos").join("2026");
|
||||
let (photos, day1, day2, other) = (s(base.join("Photos")), s(year.join("20260101")), s(year.join("20260114")), s(base.join("Other")));
|
||||
h.hide_home_above(&base);
|
||||
let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t);
|
||||
let rects = |h: &mut Headless| -> std::collections::HashMap<String, f64> {
|
||||
let w = h.request("ui.widgets", json!({"filter": "lc-ui-roots-"}), t);
|
||||
|
||||
@@ -366,6 +366,7 @@ fn local_folder_tree_expands_and_browses() {
|
||||
std::fs::create_dir_all(base.join(".hidden")).unwrap();
|
||||
let mut h = detail("panel.edit");
|
||||
exec(&mut h, "view.leftPanel", json!({"show": true}));
|
||||
h.hide_home_above(&base);
|
||||
exec(&mut h, "library.browse", json!({"path": base.to_string_lossy()}));
|
||||
h.settle(SETTLE);
|
||||
let base_s = base.to_string_lossy().to_string();
|
||||
|
||||
Reference in New Issue
Block a user