Files
lightcraft/crates/engine/src/tests_import.rs
T
Brandon ThomasandClaude Opus 5.5 759d3c7f8a M1.3: import (recursive folders, content-hash duplicates, add vs copy)
- engine::import: expand files/folders recursively (sorted; hidden entries
  and the library folder skipped), skip known paths, probe + hash in
  parallel (std::thread::scope), skip duplicates by content hash (against
  the library and within the batch), add in place or copy into
  <library>/Originals/YYYY/YYYY-MM-DD/ with unique names; one undoable op.
- library.import {paths, mode: add|copy, album?} returns
  {imported, duplicates[{path, existing, reason}], failed, scanned}.
- probe_bytes records the content hash (SipHash-1-3 128) on every photo.
- system clock for import/edit times on native (Session::with_system_clock).
- app: command-line folders are expanded by the engine (re-importing the
  same folder is a no-op); example gen_photos writes procedural test JPEGs
  (never committed).
- Measured (200 procedural 2000 px JPEGs, M-series): import 2.25 s; reopen:
  library load 1.7 ms, control ready 0.12 s, first thumbnail 0.17 s,
  visible grid drawn 0.48 s (thumbnails from the disk cache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:04:07 -04:00

104 lines
4.3 KiB
Rust

//! Import: recursive folders, duplicate detection by content, add vs copy into the library.
use std::path::Path;
use serde_json::{Value, json};
use crate::Session;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-import-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A small procedural PNG (distinct per seed).
fn write_png(path: &Path, seed: u8) {
let (w, h) = (48usize, 32usize);
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
fn ids(v: &Value, key: &str) -> usize {
v[key].as_array().map(Vec::len).unwrap_or(0)
}
#[test]
fn recursive_import_with_duplicates() {
let src = temp_dir("src");
write_png(&src.join("a.png"), 1);
write_png(&src.join("trip/b.png"), 2);
write_png(&src.join("trip/day2/c.PNG"), 3);
std::fs::copy(src.join("a.png"), src.join("trip/a-copy.png")).unwrap(); // same bytes
write_png(&src.join(".hidden/x.png"), 9);
std::fs::write(src.join("notes.txt"), "not a photo").unwrap();
std::fs::write(src.join("trip/broken.jpg"), "garbage").unwrap();
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(r["scanned"], 5, "{r}"); // a, b, c, a-copy, broken (hidden + txt skipped)
assert_eq!(ids(&r, "imported"), 3, "{r}");
assert_eq!(ids(&r, "duplicates"), 1, "{r}");
assert_eq!(r["duplicates"][0]["reason"], "content");
assert_eq!(ids(&r, "failed"), 1, "{r}");
assert_eq!(s.catalog.len(), 3);
assert!(s.catalog.photos().all(|p| p.content_hash.as_ref().is_some_and(|h| h.len() == 32) && p.width == 48));
// importing again: everything is a duplicate (by path), one undo step for the first import
let r2 = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(ids(&r2, "imported"), 0);
assert_eq!(r2["duplicates"].as_array().unwrap().iter().filter(|d| d["reason"] == "path").count(), 3);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.len(), 0);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn copy_into_library_and_persist() {
let src = temp_dir("copysrc");
let lib = temp_dir("copylib");
write_png(&src.join("one.png"), 4);
write_png(&src.join("sub/one.png"), 5); // same name, different content
let mut s = Session::new().with_fs();
assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).is_err(), "copy needs a library");
s.open_library(&lib, false).unwrap();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).unwrap();
assert_eq!(ids(&r, "imported"), 2, "{r}");
let paths: Vec<String> = s
.catalog
.photos()
.map(|p| match &p.source {
lightcraft_catalog::Source::File { path } => path.clone(),
_ => panic!(),
})
.collect();
for p in &paths {
assert!(Path::new(p).starts_with(lib.join("Originals")), "{p}");
assert!(Path::new(p).exists());
}
assert_ne!(paths[0], paths[1], "unique names");
// originals deleted from the source: the library copies remain usable after a restart
std::fs::remove_dir_all(&src).unwrap();
drop(s);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!(s.catalog.len(), 2);
let id = s.catalog.photos().next().unwrap().id;
assert!(s.render_now(id, 32, 32).is_ok());
// the library folder itself is never re-imported
let r = s.execute("library.import", &json!({"paths": [lib.to_string_lossy()]})).unwrap();
assert_eq!(r["scanned"], 0, "{r}");
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn civil_dates() {
assert_eq!(crate::import::civil(0), "1970-01-01T00:00:00");
assert_eq!(crate::import::civil(951_782_400), "2000-02-29T00:00:00");
assert_eq!(crate::import::civil(1_790_000_000), "2026-09-21T14:13:20");
}