//! 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(); // undated files take the file's date: the tests' clock time (2026-01-14T05:58:48) let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328); std::fs::File::options().write(true).open(path).unwrap().set_modified(t).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 = 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"); } #[test] fn browsing_a_folder_lists_its_photos_without_adding_them() { use crate::LibrarySource; let dir = std::env::temp_dir().join(format!("lc-browse-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("sub")).unwrap(); let png = |p: &std::path::Path, seed: u8| { let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]); let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }) .unwrap(); std::fs::write(p, b).unwrap(); }; png(&dir.join("a.png"), 1); png(&dir.join("b.png"), 2); png(&dir.join("sub/c.png"), 3); std::fs::write(dir.join("notes.txt"), "x").unwrap(); let mut s = Session::new().with_fs(); let r = s.execute("library.browse", &serde_json::json!({"path": dir.to_string_lossy()})).unwrap(); assert_eq!(r["photos"], 2, "{r}"); assert_eq!(s.source, LibrarySource::Folder); assert_eq!(s.visible_cloned().len(), 2); // not in the library s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap(); assert!(s.visible_cloned().is_empty(), "browsed photos stay out of All Photos"); assert_eq!(s.execute("catalog.stats", &serde_json::json!({})).unwrap()["photos"], 0, "nor in the counts"); assert!(s.catalog.date_groups().is_empty()); // subfolders; browsing again reuses the photos let r = s.execute("library.browse", &serde_json::json!({"path": dir.to_string_lossy(), "subfolders": true})).unwrap(); assert_eq!((r["photos"].as_u64(), r["new"].as_u64()), (Some(3), Some(1))); // add one to the library let first = s.visible_cloned()[0]; s.execute("photo.addToLibrary", &serde_json::json!({"ids": [first.0]})).unwrap(); // importing the folder for real brings in the rest (no duplicates of the browsed ones) let r = s.execute("library.import", &serde_json::json!({"paths": [dir.to_string_lossy()]})).unwrap(); assert_eq!(r["imported"].as_array().map(Vec::len), Some(2), "{r}"); s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap(); assert_eq!(s.visible_cloned().len(), 3); assert_eq!(s.catalog.photos().count(), 3); assert!(s.execute("library.browse", &serde_json::json!({"path": dir.join("a.png").to_string_lossy()})).is_err()); let _ = std::fs::remove_dir_all(&dir); } /// Photos a.png, b.png, c.png imported from `old`, then: a renamed into `moved`; b renamed, with /// an impostor (same name and size, other bytes) under its old name; c gone, with two impostors. fn renamed_and_ambiguous_scene(tag: &str) -> (std::path::PathBuf, Session) { let dir = temp_dir(tag); std::fs::create_dir_all(dir.join("old")).unwrap(); std::fs::create_dir_all(dir.join("moved/real")).unwrap(); for (n, seed) in [("a.png", 1), ("b.png", 2), ("c.png", 3)] { write_png(&dir.join("old").join(n), seed); } let mut s = Session::new().with_fs(); s.execute("library.import", &json!({"paths": [dir.join("old").to_string_lossy()]})).unwrap(); let impostor = |src: &Path, dst: &Path| { let mut b = std::fs::read(src).unwrap(); let k = b.len() - 20; b[k] ^= 0xff; std::fs::write(dst, b).unwrap(); }; std::fs::rename(dir.join("old/a.png"), dir.join("moved/Trip-001.png")).unwrap(); impostor(&dir.join("old/b.png"), &dir.join("moved/b.png")); std::fs::rename(dir.join("old/b.png"), dir.join("moved/real/Trip-002.png")).unwrap(); impostor(&dir.join("old/c.png"), &dir.join("moved/c.png")); impostor(&dir.join("old/c.png"), &dir.join("moved/real/c.png")); std::fs::remove_file(dir.join("old/c.png")).unwrap(); (dir, s) } /// Issue #105: Find Missing finds renamed files by content, prefers the content match over a /// same-name same-size impostor, and skips (reports) photos it can't tell apart. #[test] fn find_missing_matches_renamed_files_by_content() { let (dir, mut s) = renamed_and_ambiguous_scene("missing-content"); let r = s.execute("library.findMissing", &json!({"folder": dir.join("moved").to_string_lossy()})).unwrap(); let found = r["found"].as_array().unwrap(); assert_eq!(found.len(), 2, "{r}"); 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").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); } /// Issue #104: the app plans Find Missing on a worker thread (no session) and relinks the plan /// afterwards; that gives exactly what the synchronous command gives — renamed files by content, /// impostors and look-alikes skipped (`ambiguous`). #[test] fn find_missing_planned_on_a_worker_matches_the_command() { use crate::cmd::missing::{find_candidates, plan_find_missing}; let (dir, mut s) = renamed_and_ambiguous_scene("missing-worker"); let folder = dir.join("moved").to_string_lossy().to_string(); let candidates = find_candidates(&s.catalog); let f = folder.clone(); let plan = std::thread::spawn(move || plan_find_missing(&candidates, &f)).join().unwrap().unwrap(); assert_eq!((plan.found.len(), plan.missing, plan.ambiguous.len()), (2, 1, 1), "{plan:?}"); let applied = s.execute("library.findMissing", &plan.to_json()).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(3), "undo restores all three"); let direct = s.execute("library.findMissing", &json!({"folder": folder})).unwrap(); assert_eq!(applied, direct); assert_eq!(direct["found"].as_array().map(Vec::len), Some(2), "{direct}"); assert_eq!(direct["ambiguous"].as_array().map(Vec::len), Some(1), "{direct}"); let _ = std::fs::remove_dir_all(&dir); } /// Issue #104: a plan made on a worker is re-checked when it is applied: a photo relinked /// meanwhile is left alone, and a file that is now another photo's original isn't used. #[test] fn find_missing_plan_is_rechecked_when_applied() { use crate::cmd::missing::{find_candidates, plan_find_missing}; let (dir, mut s) = renamed_and_ambiguous_scene("missing-recheck"); let plan = plan_find_missing(&find_candidates(&s.catalog), &dir.join("moved").to_string_lossy()).unwrap(); let to = |name: &str| plan.found.iter().find(|f| f.from.ends_with(name)).unwrap().clone(); let (a, b) = (to("a.png"), to("b.png")); // meanwhile: a is relinked by hand (elsewhere), and the ambiguous c is pointed at b's file std::fs::copy(&a.to, dir.join("a-elsewhere.png")).unwrap(); s.execute("photo.relink", &json!({"id": a.id.0, "path": dir.join("a-elsewhere.png").to_string_lossy()})).unwrap(); let c = plan.ambiguous[0].id; s.execute("photo.relink", &json!({"id": c.0, "path": b.to})).unwrap(); let r = s.execute("library.findMissing", &plan.to_json()).unwrap(); assert_eq!(r["found"].as_array().map(Vec::len), Some(0), "{r}"); assert_eq!(r["ambiguous"].as_array().map(Vec::len), Some(0), "c isn't missing any more: {r}"); assert_eq!(r["missing"], 1, "b stays missing: its file is c's now: {r}"); let path_of = |id: lightcraft_catalog::PhotoId| match &s.catalog.photo(id).unwrap().source { lightcraft_catalog::Source::File { path } => path.clone(), _ => String::new(), }; assert_eq!(path_of(a.id), dir.join("a-elsewhere.png").to_string_lossy()); assert_eq!(path_of(b.id), b.from); let _ = std::fs::remove_dir_all(&dir); } #[test] fn missing_files_are_found_and_relinked() { let dir = std::env::temp_dir().join(format!("lc-missing-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("old")).unwrap(); std::fs::create_dir_all(dir.join("moved/deeper")).unwrap(); let png = |p: &std::path::Path, seed: u8| { let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]); let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }) .unwrap(); std::fs::write(p, b).unwrap(); }; png(&dir.join("old/a.png"), 1); png(&dir.join("old/b.png"), 2); let mut s = Session::new().with_fs(); s.execute("library.import", &serde_json::json!({"paths": [dir.join("old").to_string_lossy()]})).unwrap(); assert_eq!(s.execute("library.missing", &serde_json::json!({})).unwrap().as_array().map(Vec::len), Some(0)); // the files move away std::fs::rename(dir.join("old/a.png"), dir.join("moved/a.png")).unwrap(); std::fs::rename(dir.join("old/b.png"), dir.join("moved/deeper/b.png")).unwrap(); let m = s.execute("library.missing", &serde_json::json!({})).unwrap(); assert_eq!(m.as_array().map(Vec::len), Some(2)); s.execute("library.source", &serde_json::json!({"kind": "missing"})).unwrap(); assert_eq!(s.visible_cloned().len(), 2, "the Missing Photos source lists them"); s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap(); // one by hand let id = m[0]["id"].as_u64().unwrap(); let name = std::path::Path::new(m[0]["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().to_string(); let new = if name == "a.png" { dir.join("moved/a.png") } else { dir.join("moved/deeper/b.png") }; s.execute("photo.relink", &serde_json::json!({"id": id, "path": new.to_string_lossy()})).unwrap(); assert_eq!(s.execute("library.missing", &serde_json::json!({})).unwrap().as_array().map(Vec::len), Some(1)); assert!(s.execute("photo.relink", &serde_json::json!({"id": id, "path": dir.join("nope.png").to_string_lossy()})).is_err()); // the rest by searching a folder let r = s.execute("library.findMissing", &serde_json::json!({"folder": dir.join("moved").to_string_lossy()})).unwrap(); assert_eq!((r["found"].as_array().map(Vec::len), r["missing"].as_u64()), (Some(1), Some(0)), "{r}"); // undo points the photo back at the old path but doesn't move any file s.execute("edit.undo", &serde_json::json!({})).unwrap(); assert_eq!(s.execute("library.missing", &serde_json::json!({})).unwrap().as_array().map(Vec::len), Some(1)); assert!(dir.join("moved/a.png").exists() && dir.join("moved/deeper/b.png").exists()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn recently_added_covers_recent_imports_newest_first() { let dir = std::env::temp_dir().join(format!("lc-recent-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let png = |name: &str, seed: u8| { let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]); let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }) .unwrap(); std::fs::write(dir.join(name), b).unwrap(); dir.join(name).to_string_lossy().to_string() }; let mut s = Session::new().with_fs(); // three imports: 60 days ago, 10 days ago, today for (when, name, seed) in [("2026-08-01T09:00:00", "old.png", 1), ("2026-09-20T09:00:00", "mid.png", 2), ("2026-09-30T09:00:00", "new.png", 3)] { let path = png(name, seed); let w = when.to_string(); s.clock = Box::new(move || w.clone()); s.execute("library.import", &serde_json::json!({"paths": [path]})).unwrap(); } s.execute("library.source", &serde_json::json!({"kind": "recentlyAdded"})).unwrap(); let names: Vec = s.visible_cloned().iter().map(|id| s.catalog.photo(*id).unwrap().file_name.clone()).collect(); assert_eq!(names, ["new.png", "mid.png"], "the last 30 days, newest import first"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn recently_added_keeps_an_import_batch_in_file_order() { use lightcraft_catalog::{Op, Photo, PhotoId, Source}; let mut s = Session::new(); // one import batch: one `imported` string, ids running opposite to the names let mut by_name = Vec::new(); for (n, name) in ["IMG_0001.png", "IMG_0002.png", "IMG_0003.png", "IMG_0004.png"].iter().enumerate() { let id = PhotoId(5000 - n as u64); let p = Photo::new(id, Source::File { path: name.to_string() }, name, "PNG", 16, 12, "2026-09-30T09:00:00"); s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); by_name.push(name.to_string()); } s.clock = Box::new(|| "2026-10-01T09:00:00".to_string()); for ascending in [true, false] { s.execute("library.sort", &serde_json::json!({"key": "captureDate", "ascending": ascending})).unwrap(); s.execute("library.source", &serde_json::json!({"kind": "recentlyAdded"})).unwrap(); let names: Vec = s.visible_cloned().iter().map(|id| s.catalog.photo(*id).unwrap().file_name.clone()).collect(); assert_eq!(names, by_name, "ascending: {ascending}"); } } /// Copy imports: a destination folder, flat / by-month folders, renamed copies (numbered in /// import order), and a metadata preset on every photo. #[test] fn copy_with_destination_organize_rename_and_metadata_preset() { let src = temp_dir("orgsrc"); let dest = temp_dir("orgdest"); write_png(&src.join("a.png"), 1); write_png(&src.join("b.png"), 2); let mut s = Session::new().with_fs(); s.execute("metadata.savePreset", &json!({"name": "Studio", "fields": {"copyright": "© Studio", "creator": "Sam"}})).unwrap(); assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "metadataPreset": "Nope"})).is_err()); assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy", "organize": "weekly"})).is_err()); let r = s .execute( "library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat", "rename": "Shoot-{seq:3}", "renameStart": 7, "metadataPreset": "Studio"}), ) .unwrap(); assert_eq!(ids(&r, "imported"), 2, "{r}"); let mut names: Vec = s.catalog.photos().map(|p| p.file_name.clone()).collect(); names.sort(); assert_eq!(names, ["Shoot-007.png", "Shoot-008.png"], "catalogued under the new names"); assert!(dest.join("Shoot-007.png").exists() && dest.join("Shoot-008.png").exists(), "flat: straight into the destination"); assert!(s.catalog.photos().all(|p| p.meta.copyright == "© Studio" && p.meta.creator == "Sam")); // by month, no library needed when a destination is given let dest2 = temp_dir("orgdest2"); write_png(&src.join("c.png"), 3); let r = s .execute( "library.import", &json!({"paths": [src.join("c.png").to_string_lossy()], "mode": "copy", "destination": dest2.to_string_lossy(), "organize": "month"}), ) .unwrap(); assert_eq!(ids(&r, "imported"), 1, "{r}"); let p = s.catalog.photos().find(|p| p.file_name == "c.png").unwrap(); let lightcraft_catalog::Source::File { path } = &p.source else { panic!() }; let rel = Path::new(path).strip_prefix(&dest2).unwrap(); assert_eq!(rel.components().count(), 3, "YYYY/YYYY-MM/c.png: {rel:?}"); assert_eq!(rel.parent().unwrap().file_name().unwrap().len(), 7); for d in [&src, &dest, &dest2] { let _ = std::fs::remove_dir_all(d); } } /// Copy with a custom folder template: `{date:%Y}/{date:%Y%m%d}` files a photo under /// `2026/20260114/` (an undated file by the import time); templates that would leave the /// destination are refused. #[test] fn copy_with_a_folder_template() { let src = temp_dir("tplsrc"); let dest = temp_dir("tpldest"); write_png(&src.join("a.png"), 1); let mut s = Session::new().with_fs(); s.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); for bad in ["../{date}", "/tmp/{date}", "{date:%Y}/../x", "C:/x"] { let r = s.execute( "library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": bad}), ); assert!(r.is_err(), "{bad} should be refused"); } assert_eq!(s.catalog.len(), 0); let r = s .execute( "library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"}), ) .unwrap(); assert_eq!(ids(&r, "imported"), 1, "{r}"); assert!(dest.join("2026").join("20260114").join("a.png").is_file()); for d in [&src, &dest] { let _ = std::fs::remove_dir_all(d); } } /// Auto import: files in the watched folder are added once their size held between two scans, /// into the named album; non-photos are tried once; the selection stays put. #[test] fn auto_import_watched_folder() { let dir = temp_dir("watch"); let mut s = Session::new().with_fs(); assert!(s.execute("library.autoImport", &json!({"folder": dir.join("nope").to_string_lossy()})).is_err()); s.execute("library.autoImport", &json!({"folder": dir.to_string_lossy(), "album": "Tethered"})).unwrap(); assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([])); write_png(&dir.join("one.png"), 1); std::fs::write( dir.join("one.xmp"), r#""#, ) .unwrap(); std::fs::write(dir.join("notes.txt"), "not a photo").unwrap(); // first sight: wait (it may still be copying) assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([])); let r = s.execute("library.autoImportScan", &json!({})).unwrap(); assert_eq!(r["imported"].as_array().unwrap().len(), 1, "{r}"); assert!(r["warnings"].as_array().unwrap().iter().any(|w| w["code"] == "cameraProfile" && w["profile"] == "Color"), "{r}"); let album = s.catalog.albums().find(|a| a.name == "Tethered").expect("album").id; assert_eq!(s.catalog.album_count(album), 1); assert!(s.selection.ids.is_empty(), "arrivals don't take the selection"); // nothing new: nothing happens, and the text file isn't retried for _ in 0..2 { assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([])); } write_png(&dir.join("two.png"), 2); s.execute("library.autoImportScan", &json!({})).unwrap(); s.execute("library.autoImportScan", &json!({})).unwrap(); assert_eq!(s.catalog.album_count(album), 2); s.execute("library.autoImport", &json!({"folder": null})).unwrap(); assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["folder"], json!(null)); let _ = std::fs::remove_dir_all(&dir); } /// Smart previews: with the original offline the photo still renders (and edits apply) from /// its proxy; without the proxy it can't be opened. #[test] fn smart_previews_stand_in_for_offline_originals() { let src = temp_dir("smartsrc"); let lib = temp_dir("smartlib"); write_png(&src.join("a.png"), 7); let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); let id = s.active().unwrap(); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!(r["built"], 1, "{r}"); assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap(), json!({"smartPreview": true, "originalOnline": true})); let online = s.render_now(id, 48, 32).unwrap().image; // the drive goes away std::fs::rename(&src, src.with_extension("offline")).unwrap(); s.media.forget(id); assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["originalOnline"], false); let offline = s.render_now(id, 48, 32).expect("renders from the smart preview").image; let diff: f64 = online.data.iter().zip(&offline.data).map(|(a, b)| (a[0] as f64 - b[0] as f64).abs()).sum::() / online.data.len() as f64; assert!(diff < 6.0, "the proxy looks like the original: {diff}"); s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap(); s.media.forget(id); assert_ne!(s.render_now(id, 48, 32).unwrap().image.data, offline.data, "edits apply offline"); // without the proxy it can't be opened s.execute("library.smartPreviews", &json!({"discard": true})).unwrap(); s.media.forget(id); assert!(s.render_now(id, 48, 32).is_err()); let _ = std::fs::remove_dir_all(src.with_extension("offline")); let _ = std::fs::remove_dir_all(&lib); } /// Issue #106: a smart preview cut short (crash, full drive) counted as built forever and failed /// exactly when the original went offline. Writes are atomic now, and a damaged proxy is not /// reported as present and is rebuilt. #[test] fn damaged_smart_previews_are_rebuilt_and_failed_writes_leave_none() { let src = temp_dir("smartdmg-src"); let lib = temp_dir("smartdmg-lib"); write_png(&src.join("a.png"), 5); let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); let id = s.active().unwrap(); let dir = s.media.smart_dir.clone().unwrap(); let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); { let _fault = lightcraft_catalog::safe_file::fail_writes_after(64); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["failed"].as_array().map(Vec::len)), (Some(0), Some(1)), "{r}"); } assert!(!file.exists(), "no partial proxy"); assert_eq!(std::fs::read_dir(file.parent().unwrap()).unwrap().count(), 0, "no temp file left in shard"); assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], false); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0)), "{r}"); assert!(crate::smart::is_valid(&file)); // cut short, as by a crash before the fix let full = std::fs::read(&file).unwrap(); std::fs::write(&file, &full[..full.len() / 2]).unwrap(); assert!(!crate::smart::is_valid(&file)); assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], false, "not counted as there"); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(1)), "{r}"); assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole"); // the app's background build (issue #104) checks and repairs the same way, on a worker thread std::fs::write(&file, &full[..full.len() / 2]).unwrap(); let r = s.execute("library.smartPreviews", &json!({"background": true})).unwrap(); assert_eq!(r["what"], "smart previews", "{r}"); let t0 = std::time::Instant::now(); let r = loop { let r = s.execute("library.previewProgress", &json!({})).unwrap(); if r["running"] == false || t0.elapsed() > std::time::Duration::from_secs(60) { break r; } std::thread::sleep(std::time::Duration::from_millis(5)); }; assert_eq!((r["done"].as_u64(), r["failed"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0), Some(1)), "{r}"); assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole in the background"); // its row in the activity stack (issue #345) goes with it let t0 = std::time::Instant::now(); while !s.activity.list().is_empty() && t0.elapsed() < std::time::Duration::from_secs(10) { std::thread::sleep(std::time::Duration::from_millis(5)); } assert!(s.activity.list().is_empty(), "{:?}", s.activity.list()); let _ = std::fs::remove_dir_all(&src); let _ = std::fs::remove_dir_all(&lib); } /// LUT profiles: a .cube imports as a creative profile (into the library), renders differently /// (Amount 0 = no change), is listed in the profile browser and comes back with the library. #[test] fn cube_luts_become_profiles() { let src = temp_dir("cubesrc"); let lib = temp_dir("cubelib"); // a warm look: red up, blue down let n = 9; let mut cube = String::from("TITLE \"Warm Test\"\nLUT_3D_SIZE 9\n"); for b in 0..n { for g in 0..n { for r in 0..n { let f = |v: usize| v as f32 / (n - 1) as f32; cube.push_str(&format!("{} {} {}\n", (f(r) * 1.15).min(1.0), f(g), f(b) * 0.8)); } } } std::fs::create_dir_all(src.join("Film Looks")).unwrap(); std::fs::write(src.join("Film Looks/warm.cube"), &cube).unwrap(); std::fs::write(src.join("Film Looks/broken.cube"), "LUT_3D_SIZE 4\n0 0 0\n").unwrap(); let mut s = Session::new().with_fs(); s.open_library(&lib, true).unwrap(); let r = s.execute("profile.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); assert_eq!((r["imported"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (1, 1), "{r}"); let id = r["imported"][0]["id"].as_str().unwrap().to_string(); assert_eq!((r["imported"][0]["name"].as_str(), r["imported"][0]["group"].as_str()), (Some("Warm Test"), Some("Film Looks"))); let menu = s.profile_menu(); assert!(menu["groups"].as_array().unwrap().iter().any(|g| g["name"] == "Film Looks"), "{menu}"); let photo = s.catalog.photos().next().unwrap().id; s.execute("library.select", &json!({"ids": [photo.0]})).unwrap(); let base = s.render_now(photo, 48, 32).unwrap().image; s.execute("develop.profile", &json!({"id": id, "amount": 100})).unwrap(); let warm = s.render_now(photo, 48, 32).unwrap().image; let mean = |img: &lightcraft_raster::Rgba8, k: usize| img.data.iter().map(|p| p[k] as f64).sum::() / img.data.len() as f64; assert!( mean(&warm, 0) > mean(&base, 0) && mean(&warm, 2) < mean(&base, 2), "warmer: R {} → {}, B {} → {}", mean(&base, 0), mean(&warm, 0), mean(&base, 2), mean(&warm, 2) ); s.execute("develop.profile", &json!({"id": id, "amount": 0})).unwrap(); assert_eq!(s.render_now(photo, 48, 32).unwrap().image.data, base.data, "Amount 0 = the photo as it was"); assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks"))); s.execute("profile.favorite", &json!({"id": id})).unwrap(); assert!(s.profile_favorites.contains(&id)); // `profiles.list` lists it next to the built-in profiles (#618) let listed = |s: &mut Session| { let list = s.execute("profiles.list", &json!({})).unwrap(); let list = list.as_array().unwrap(); assert!(list.iter().any(|p| p["id"] == "lc.color"), "built-in profiles stay listed"); list.iter().find(|p| p["id"] == id.as_str()).cloned() }; assert_eq!(listed(&mut s), Some(json!({"id": id, "name": "Warm Test", "group": "Film Looks", "favorite": true, "imported": true}))); // the library keeps it drop(s); lightcraft_pipeline::lut::unregister(&id); let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); assert!(s.lut_profiles.iter().any(|p| p.id == id)); assert!(s.profile_favorites.contains(&id), "imported LUT favorite survives reopen"); assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks"))); assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open"); assert!(listed(&mut s).is_some_and(|p| p["favorite"] == true), "listed after reopen"); s.execute("profile.deleteImported", &json!({"id": id})).unwrap(); assert!(lightcraft_pipeline::lut::get(&id).is_none()); assert_eq!(listed(&mut s), None, "a deleted profile is no longer listed"); let _ = std::fs::remove_dir_all(&src); let _ = std::fs::remove_dir_all(&lib); } /// Folder rename / move on disk: files and sidecars go along, the photos are relinked and /// still render; clashes and moving into itself are refused. #[test] fn folders_rename_and_move_with_their_photos() { let root = temp_dir("folders"); let a = root.join("Trip"); write_png(&a.join("one.png"), 3); std::fs::write(a.join("one.xmp"), "").unwrap(); std::fs::create_dir_all(root.join("Taken")).unwrap(); let mut s = Session::new().with_fs(); s.execute("library.import", &json!({"paths": [a.to_string_lossy()]})).unwrap(); let id = s.active().unwrap(); assert!(s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Taken"})).is_err(), "name taken"); assert!(s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "a/b"})).is_err()); let r = s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Italy 2026"})).unwrap(); assert_eq!(r["relinked"], 1); let b = root.join("Italy 2026"); assert!(b.join("one.png").exists() && b.join("one.xmp").exists() && !a.exists()); let path = |s: &Session| match &s.catalog.photo(id).unwrap().source { lightcraft_catalog::Source::File { path } => path.clone(), _ => panic!(), }; assert_eq!(path(&s), b.join("one.png").to_string_lossy()); s.media.forget(id); assert!(s.render_now(id, 16, 16).is_ok()); 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").join("Italy 2026").join("one.png").to_string_lossy()); let _ = std::fs::remove_dir_all(&root); } /// Rename / Move Folder are undo steps (issue #97): undo renames the folder back on disk (files and /// sidecars along) and restores the photos' paths, redo repeats it; a destination taken in the /// meantime is never overwritten — the step is refused, reported and stays on the stack. #[test] fn folder_rename_and_move_undo_and_redo_on_disk() { let root = temp_dir("folders-undo"); let a = root.join("Trip"); write_png(&a.join("one.png"), 3); std::fs::write(a.join("one.xmp"), "").unwrap(); let mut s = Session::new().with_fs(); s.execute("library.import", &json!({"paths": [a.to_string_lossy()]})).unwrap(); let id = s.active().unwrap(); let path = |s: &Session| match &s.catalog.photo(id).unwrap().source { lightcraft_catalog::Source::File { path } => path.clone(), _ => panic!(), }; let undo0 = s.undo.len(); let b = root.join("Italy"); s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Italy"})).unwrap(); assert_eq!(s.undo.len(), undo0 + 1, "one undo step"); assert!(s.undo.last().unwrap().label.contains("Rename Folder")); assert!(b.join("one.png").exists() && !a.exists()); s.execute("edit.undo", &json!({})).unwrap(); assert!(a.join("one.png").exists() && a.join("one.xmp").exists() && !b.exists(), "renamed back on disk"); assert_eq!(path(&s), a.join("one.png").to_string_lossy()); s.media.forget(id); assert!(s.render_now(id, 16, 16).is_ok()); s.execute("edit.redo", &json!({})).unwrap(); assert!(b.join("one.png").exists() && b.join("one.xmp").exists() && !a.exists()); assert_eq!(path(&s), b.join("one.png").to_string_lossy()); // the old name is taken now: undo is refused, nothing is overwritten, the step stays std::fs::create_dir_all(&a).unwrap(); std::fs::write(a.join("keep.txt"), "mine").unwrap(); let undo_n = s.undo.len(); let e = s.execute("edit.undo", &json!({})).unwrap_err().to_string(); assert!(e.contains("already exists"), "{e}"); assert_eq!(s.undo.len(), undo_n, "the step stays on the stack"); assert_eq!(std::fs::read_to_string(a.join("keep.txt")).unwrap(), "mine"); assert!(b.join("one.png").exists()); assert_eq!(path(&s), b.join("one.png").to_string_lossy()); std::fs::remove_dir_all(&a).unwrap(); // move into another folder, undo, redo let into = root.join("Archive"); s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": into.to_string_lossy()})).unwrap(); let moved = into.join("Italy"); assert_eq!(path(&s), moved.join("one.png").to_string_lossy()); s.execute("edit.undo", &json!({})).unwrap(); assert!(b.join("one.png").exists() && !moved.exists()); assert_eq!(path(&s), b.join("one.png").to_string_lossy()); // the redo target is taken (even by an empty folder): refused, nothing replaced std::fs::create_dir_all(&moved).unwrap(); assert!(s.execute("edit.redo", &json!({})).is_err()); assert!(b.join("one.png").exists()); std::fs::remove_dir(&moved).unwrap(); s.execute("edit.redo", &json!({})).unwrap(); assert!(moved.join("one.png").exists() && moved.join("one.xmp").exists() && !b.exists()); assert_eq!(path(&s), moved.join("one.png").to_string_lossy()); let _ = std::fs::remove_dir_all(&root); } /// Assisted culling: a burst (same scene, seconds apart) of a sharp and two soft shots, plus an /// unrelated photo: the burst is grouped, the sharp one is its best, blurry shots can be rejected. #[test] fn assisted_culling_groups_bursts_and_scores_focus() { let dir = temp_dir("cull"); let scene = lightcraft_scenes::demo_library()[0].render(480, 320); let soften = |img: &lightcraft_raster::Rgb32f, r: usize| { let (w, h) = (img.width, img.height); let mut out = img.clone(); for y in 0..h { for x in 0..w { let (mut acc, mut n) = ([0f32; 3], 0.0); for yy in y.saturating_sub(r)..(y + r + 1).min(h) { for xx in x.saturating_sub(r)..(x + r + 1).min(w) { let c = img.data[yy * w + xx]; acc = [acc[0] + c[0], acc[1] + c[1], acc[2] + c[2]]; n += 1.0; } } out.data[y * w + x] = acc.map(|v| v / n); } } out }; let save = |img: &lightcraft_raster::Rgb32f, name: &str| { let data: Vec<[u8; 4]> = img .data .iter() .map(|c| { let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8; [e(c[0]), e(c[1]), e(c[2]), 255] }) .collect(); let png = lightcraft_codecs::encode_png( &lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }), &Default::default(), ) .unwrap(); std::fs::write(dir.join(name), png).unwrap(); }; save(&soften(&scene, 3), "a_soft.png"); save(&scene, "b_sharp.png"); save(&soften(&scene, 6), "c_softer.png"); save(&lightcraft_scenes::demo_library()[7].render(480, 320), "d_other.png"); let mut s = Session::new().with_fs(); let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap(); let ids: Vec = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; for (i, n) in ["a_soft.png", "b_sharp.png", "c_softer.png"].iter().enumerate() { let id = by_name(&s, n); s.commit("t", lightcraft_catalog::Op::SetCaptured { id, captured: Some(format!("2026-05-01T10:00:0{i}")) }).unwrap(); } let other = by_name(&s, "d_other.png"); s.commit("t", lightcraft_catalog::Op::SetCaptured { id: other, captured: Some("2026-05-01T10:00:04".into()) }).unwrap(); let r = s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": 0.0, "pickBest": true})).unwrap(); assert_eq!(r["groups"], 1, "{r}"); let a = |s: &Session, n: &str| s.catalog.photo(by_name(s, n)).unwrap().analysis.unwrap(); let (soft, sharp, softer, odd) = (a(&s, "a_soft.png"), a(&s, "b_sharp.png"), a(&s, "c_softer.png"), a(&s, "d_other.png")); eprintln!("sharpness: sharp {} soft {} softer {} other {}", sharp.sharpness, soft.sharpness, softer.sharpness, odd.sharpness); assert!(sharp.sharpness > soft.sharpness && soft.sharpness > softer.sharpness); assert!(sharp.best && !soft.best && sharp.group.is_some() && sharp.group == softer.group); assert!(odd.group.is_none(), "an unrelated photo isn't in the burst"); assert_eq!(s.catalog.photo(by_name(&s, "b_sharp.png")).unwrap().flag, lightcraft_catalog::Flag::Pick); // reject the blurry ones (by score), as a rule too let cut = (soft.sharpness + sharp.sharpness) / 2.0; s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": cut})).unwrap(); assert_eq!(s.catalog.photo(by_name(&s, "c_softer.png")).unwrap().flag, lightcraft_catalog::Flag::Reject); s.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "bestOfGroup", "op": "is", "value": true}]}})).unwrap(); let vis = s.visible_cloned(); assert!(vis.contains(&by_name(&s, "b_sharp.png")) && !vis.contains(&by_name(&s, "a_soft.png")) && vis.contains(&other)); let _ = std::fs::remove_dir_all(&dir); } /// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step). #[test] fn dust_spots_are_found_and_healed() { let dir = temp_dir("dust"); let (w, h) = (900usize, 600usize); let spots = [(180.0f32, 140.0f32), (600.0, 300.0)]; let data: Vec<[u8; 4]> = (0..w * h) .map(|i| { let (x, y) = ((i % w) as f32, (i / w) as f32); let mut v = 0.5 + 0.3 * (y / h as f32); for (cx, cy) in spots { v -= 0.07 * (-((x - cx).powi(2) + (y - cy).powi(2)) / (2.0 * 25.0)).exp(); } let b = (v * 255.0) as u8; [b, b, (b as f32 * 1.15).min(255.0) as u8, 255] }) .collect(); let png = lightcraft_codecs::encode_png( &lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: w, height: h, data }), &Default::default(), ) .unwrap(); std::fs::write(dir.join("sky.png"), png).unwrap(); let mut s = Session::new().with_fs(); s.execute("library.import", &json!({"paths": [dir.join("sky.png").to_string_lossy()]})).unwrap(); let id = s.active().unwrap(); let before = s.render_now(id, 900, 600).unwrap().image; let r = s.execute("spot.findDust", &json!({})).unwrap(); assert_eq!(r["added"], 2, "{r}"); let after = s.render_now(id, 900, 600).unwrap().image; let at = |img: &lightcraft_raster::Rgba8, x: usize, y: usize| img.data[y * 900 + x][0] as i32; for (cx, cy) in spots { assert!(at(&after, cx as usize, cy as usize) > at(&before, cx as usize, cy as usize) + 6, "the spot at {cx},{cy} is lifted"); } s.execute("edit.undo", &json!({})).unwrap(); assert!(s.develop_of(id).unwrap().spots.is_empty(), "one undo step"); let _ = std::fs::remove_dir_all(&dir); } /// Smart previews can live outside the library: the folder is saved per library, used for /// building, status and offline rendering, changing it needs an explicit choice for the /// previews already built, and an unavailable folder is an error (never a silent fallback). #[test] fn smart_previews_folder_is_chosen_per_library() { let src = temp_dir("smartloc-src"); let lib = temp_dir("smartloc-lib"); let away = temp_dir("smartloc-away"); write_png(&src.join("a.png"), 7); let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); let id = s.active().unwrap(); let loc = s.execute("library.smartPreviewsLocation", &json!({})).unwrap(); assert_eq!(loc["custom"], false); assert_eq!(loc["path"], lib.join("Smart Previews").to_string_lossy().as_ref()); s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!(crate::smart::stats(&lib.join("Smart Previews")).0, 1); // A flat proxy from older builds remains discoverable and migrates to the new sharded layout. let dir = lib.join("Smart Previews"); let photo = s.catalog.photo(id).unwrap(); let shard = crate::smart::sharded_file_path(&dir, photo); let flat = dir.join(crate::smart::file_name(photo)); std::fs::rename(&shard, &flat).unwrap(); assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], true); // relative paths and missing drives are refused, and nothing is created on this drive assert!(s.execute("library.smartPreviewsLocation", &json!({"path": "rel/dir"})).is_err()); let gone = away.join("unplugged/previews"); let err = s.execute("library.smartPreviewsLocation", &json!({"path": gone.to_string_lossy()})).unwrap_err().to_string(); assert!(err.contains("not available"), "{err}"); assert!(!away.join("unplugged").exists()); // the previews already built need an explicit choice let to = away.join("proxies"); let err = s.execute("library.smartPreviewsLocation", &json!({"path": to.to_string_lossy()})).unwrap_err().to_string(); assert!(err.contains("existing"), "{err}"); assert_eq!(s.execute("library.smartPreviewsLocation", &json!({})).unwrap()["custom"], false); let r = s.execute("library.smartPreviewsLocation", &json!({"path": to.to_string_lossy(), "existing": "move"})).unwrap(); assert_eq!((r["custom"].clone(), r["handled"].clone(), r["count"].clone()), (json!(true), json!(1), json!(1)), "{r}"); assert_eq!(crate::smart::stats(&lib.join("Smart Previews")).0, 0); assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], true); // the setting is saved with the library drop(s); // one session per library (issue #99) let mut again = Session::new().with_fs(); again.open_library(&lib, false).unwrap(); assert_eq!(again.execute("library.smartPreviewsLocation", &json!({})).unwrap()["path"], to.to_string_lossy().as_ref()); // offline original: the render comes from the proxy in the chosen folder std::fs::rename(&src, src.with_extension("offline")).unwrap(); again.media.forget(id); again.render_now(id, 48, 32).expect("renders from the smart preview in the chosen folder"); // the chosen drive goes away: building says so and creates nothing on this drive let drive = away.join("drive"); std::fs::create_dir_all(&drive).unwrap(); let on_drive = drive.join("proxies"); again.execute("library.smartPreviewsLocation", &json!({"path": on_drive.to_string_lossy(), "existing": "leave"})).unwrap(); std::fs::remove_dir_all(&drive).unwrap(); let loc = again.execute("library.smartPreviewsLocation", &json!({})).unwrap(); assert_eq!(loc["available"], false, "{loc}"); let err = again.execute("library.smartPreviews", &json!({"ids": [id.0]})).unwrap_err().to_string(); assert!(err.contains("not available"), "{err}"); assert!(!drive.exists()); // back to the library's own folder, discarding the proxies left in the first chosen folder let r = again.execute("library.smartPreviewsLocation", &json!({"reset": true})).unwrap(); assert_eq!(r["custom"], false, "{r}"); assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref()); } /// Missing Photos covers library photos only: a small library next to many Local browse /// records (and a deleted photo) checks — and lists, and counts — the library files alone. #[test] fn missing_photos_skip_local_browse_records() { use lightcraft_catalog::{Op, Photo, PhotoId, Source}; let gone = std::env::temp_dir().join(format!("lc-missing-scope-{}", std::process::id())); let mut s = Session::new(); let mut ops = Vec::new(); for i in 0..205u64 { let path = gone.join(format!("IMG_{i:04}.jpg")).to_string_lossy().to_string(); let mut p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); p.local = i >= 5; // 5 library photos, 200 seen while browsing p.deleted = i == 4; ops.push(Op::AddPhoto { photo: Box::new(p) }); } s.commit("Add", Op::Batch { ops }).unwrap(); let mut checked = 0; let lost = crate::cmd::missing::missing_with(&s.catalog, |_| { checked += 1; false }); assert_eq!((checked, lost.len()), (4, 4), "only the non-deleted library files are checked"); assert_eq!(crate::cmd::missing::candidates(&s.catalog).len(), 4, "the sidebar count checks the same files"); assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(4)); s.execute("library.source", &json!({"kind": "missing"})).unwrap(); assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts"); } /// Import a file that is in Recently Deleted (issues #298, #706): by default the photo comes /// back with its edits; `onDeleted: skip` leaves it there but the report says it is in the trash; /// `onDeleted: fresh` replaces the trashed record with a new one. fn trashed_photo(tag: &str) -> (Session, std::path::PathBuf, u64) { let src = temp_dir(tag); write_png(&src.join("a.png"), 1); let mut s = Session::new().with_fs(); let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); let id = r["imported"][0].as_u64().unwrap(); s.execute("photo.rate", &json!({"ids": [id], "rating": 4})).unwrap(); s.execute("photo.delete", &json!({"ids": [id]})).unwrap(); (s, src, id) } /// Issue #706: import a photo, delete it, import the same file again. Before, the import skipped /// it as "already in the library" (a duplicate by path) while the grid showed it deleted, and the /// user was stuck. Now the photo is restored: it is back in All Photos, with its edits. #[test] fn importing_a_deleted_photo_again_brings_it_back() { let (mut s, src, id) = trashed_photo("trash-reimport"); assert_eq!(s.visible_cloned().len(), 0, "deleted: not in All Photos"); let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); assert_eq!(r["restored"], json!([id]), "{r}"); assert_eq!(ids(&r, "duplicates"), 0, "not reported as a duplicate: {r}"); let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap(); assert!(!p.deleted && p.rating == 4, "back with its edits"); assert_eq!(s.catalog.len(), 1, "no second record"); assert_eq!(s.visible_cloned(), vec![lightcraft_catalog::PhotoId(id)], "shown again in All Photos"); // undo puts it back in Recently Deleted s.execute("edit.undo", &json!({})).unwrap(); assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted); let _ = std::fs::remove_dir_all(&src); } /// The same sequence with the library on disk and a restart between the delete and the import: /// the trashed record is read back from the catalog and restored, not refused. #[test] fn importing_a_deleted_photo_again_after_reopening_the_library_brings_it_back() { let src = temp_dir("trash-reimport-reopen"); write_png(&src.join("a.png"), 1); let lib = src.join("library"); let id = { let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()], "albumName": "Trip"})).unwrap(); let id = r["imported"][0].as_u64().unwrap(); s.execute("photo.rate", &json!({"ids": [id], "rating": 2})).unwrap(); s.execute("photo.delete", &json!({"ids": [id]})).unwrap(); s.close_library().unwrap(); id }; let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "deleted state persisted"); let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); assert_eq!(r["restored"], json!([id]), "{r}"); assert_eq!(ids(&r, "duplicates"), 0, "{r}"); let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap(); assert!(!p.deleted && p.rating == 2, "restored with its edits"); let album = s.catalog.albums().find(|a| a.name == "Trip").unwrap(); assert_eq!(album.photos, vec![lightcraft_catalog::PhotoId(id)], "still in its album"); assert_eq!(s.visible_cloned().len(), 1); let _ = std::fs::remove_dir_all(&src); } /// A deleted photo whose file was moved meanwhile: importing the file from its new place restores /// the photo and points it at the file (matched by content), instead of bringing back a record /// whose file is missing. #[test] fn importing_a_deleted_photo_from_its_new_place_restores_and_relinks_it() { let (mut s, src, id) = trashed_photo("trash-reimport-moved"); std::fs::create_dir_all(src.join("moved")).unwrap(); std::fs::rename(src.join("a.png"), src.join("moved/b.png")).unwrap(); let r = s.execute("library.import", &json!({"paths": [src.join("moved/b.png").to_string_lossy()]})).unwrap(); assert_eq!(r["restored"], json!([id]), "{r}"); let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap(); assert!(!p.deleted && p.rating == 4); assert_eq!(p.source, lightcraft_catalog::Source::File { path: src.join("moved/b.png").to_string_lossy().to_string() }); assert_eq!(p.file_name, "b.png"); assert_eq!(s.catalog.len(), 1); let _ = std::fs::remove_dir_all(&src); } /// Only an import brings a deleted photo back. Looking at its folder in Local, and Auto Import /// seeing its file again in the watched folder (a copy: the source is not a path the library /// knows), leave it in Recently Deleted. #[test] fn browsing_and_auto_import_never_restore_a_deleted_photo() { let (mut s, src, id) = trashed_photo("trash-no-silent-restore"); let r = s.execute("library.browse", &json!({"path": src.to_string_lossy()})).unwrap(); assert_eq!(r["new"], 0, "{r}"); assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "browsing only looks"); assert_eq!(s.catalog.len(), 1); // auto import in copy mode: the trashed photo's copy is in the library's folder, so the watched // folder's file is new by path and a duplicate by content let lib = src.join("library"); let watched = src.join("watched"); write_png(&watched.join("w.png"), 7); let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); let r = s.execute("library.import", &json!({"paths": [watched.join("w.png").to_string_lossy()], "mode": "copy"})).unwrap(); let copied = r["imported"][0].as_u64().unwrap(); s.execute("photo.delete", &json!({"ids": [copied]})).unwrap(); s.execute("library.autoImport", &json!({"folder": watched.to_string_lossy(), "copy": true})).unwrap(); for _ in 0..3 { let r = s.execute("library.autoImportScan", &json!({})).unwrap(); assert_eq!(r["imported"], json!([]), "{r}"); } assert!(s.catalog.photo(lightcraft_catalog::PhotoId(copied)).unwrap().deleted, "still deleted"); assert_eq!(s.catalog.len(), 1, "no second copy"); let _ = std::fs::remove_dir_all(&src); } /// `onDeleted: skip` keeps the old behaviour for callers that want it: nothing imported, the /// duplicate reported with `existingDeleted`. #[test] fn duplicate_in_recently_deleted_is_reported_as_such() { let (mut s, src, id) = trashed_photo("trash-report"); let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "skip"})).unwrap(); assert_eq!(ids(&r, "imported"), 0, "{r}"); assert_eq!(r["duplicates"][0]["existing"], id, "{r}"); assert_eq!(r["duplicates"][0]["existingDeleted"], true, "{r}"); assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "skip: left in the trash"); let _ = std::fs::remove_dir_all(&src); } #[test] fn import_can_restore_a_duplicate_from_recently_deleted() { let (mut s, src, id) = trashed_photo("trash-restore"); let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "restore"})).unwrap(); assert_eq!(r["restored"], json!([id]), "{r}"); assert_eq!(ids(&r, "duplicates"), 0, "{r}"); let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap(); assert!(!p.deleted && p.rating == 4, "restored with its edits"); let _ = std::fs::remove_dir_all(&src); } #[test] fn import_can_replace_a_duplicate_from_recently_deleted() { let (mut s, src, id) = trashed_photo("trash-fresh"); let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap(); assert_eq!(ids(&r, "imported"), 1, "{r}"); let new = r["imported"][0].as_u64().unwrap(); assert_ne!(new, id); assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none(), "the trashed record is gone"); let p = s.catalog.photo(lightcraft_catalog::PhotoId(new)).unwrap(); assert!(!p.deleted && p.rating == 0, "a new photo without the old edits"); assert_eq!(s.catalog.len(), 1); let _ = std::fs::remove_dir_all(&src); } #[test] fn unknown_on_deleted_is_an_error() { let mut s = Session::new().with_fs(); let e = s.execute("library.import", &json!({"paths": [], "onDeleted": "bogus"})).unwrap_err().to_string(); assert!(e.contains("onDeleted"), "{e}"); } #[test] fn empty_recently_deleted_removes_only_trashed_photos() { let (mut s, src, id) = trashed_photo("trash-empty"); write_png(&src.join("b.png"), 2); let r = s.execute("library.import", &json!({"paths": [src.join("b.png").to_string_lossy()]})).unwrap(); let keep = r["imported"][0].as_u64().unwrap(); let r = s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap(); assert_eq!(r["deleted"], 1, "{r}"); assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none()); assert!(s.catalog.photo(lightcraft_catalog::PhotoId(keep)).is_some()); // nothing to empty is not an error assert_eq!(s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap()["deleted"], 0); s.execute("edit.undo", &json!({})).unwrap(); let _ = std::fs::remove_dir_all(&src); } /// Two trashed photos that share an album and a two-photo stack: emptying (or permanently /// deleting) them together must not fail or leave dangling references. fn two_trashed_in_album_and_stack(tag: &str) -> (Session, std::path::PathBuf, [u64; 2], u64) { let src = temp_dir(tag); write_png(&src.join("a.png"), 1); write_png(&src.join("b.png"), 2); let mut s = Session::new().with_fs(); let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); let ids = [r["imported"][0].as_u64().unwrap(), r["imported"][1].as_u64().unwrap()]; let al = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap(); s.execute("album.addPhotos", &json!({"id": al, "ids": ids})).unwrap(); s.execute("library.select", &json!({"ids": ids})).unwrap(); s.execute("stack.group", &json!({"ids": ids})).unwrap(); s.execute("photo.delete", &json!({"ids": ids})).unwrap(); (s, src, ids, al) } #[test] fn emptying_trashed_photos_of_one_album_and_stack_leaves_nothing_dangling() { let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-empty-refs"); let r = s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap(); assert_eq!(r["deleted"], 2, "{r}"); assert_eq!(s.catalog.len(), 0); assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty(), "album still lists a removed photo"); assert!(ids.iter().all(|i| s.catalog.stack_of(lightcraft_catalog::PhotoId(*i)).is_none())); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.len(), 2, "one undo step brings both back"); assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.len(), 2); assert!(s.catalog.stack_of(lightcraft_catalog::PhotoId(ids[0])).is_some()); assert!(s.catalog.photos().all(|p| p.deleted), "back in Recently Deleted"); let _ = std::fs::remove_dir_all(&src); } #[test] fn deleting_several_photos_permanently_leaves_nothing_dangling() { let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-perm-refs"); s.execute("library.select", &json!({"ids": ids})).unwrap(); let files = [src.join("a.png"), src.join("b.png")].map(|path| { let bytes = std::fs::read(&path).unwrap(); (path, bytes) }); let snapshot = s.catalog.to_snapshot(); s.drain_log(); s.execute("photo.deletePermanently", &json!({"ids": ids})).unwrap(); assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty()); assert!(ids.iter().all(|i| s.catalog.photo(lightcraft_catalog::PhotoId(*i)).is_none())); let removed = s.catalog.to_snapshot(); let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect(); let mut reopened = lightcraft_catalog::Catalog::from_snapshot(&snapshot).unwrap(); reopened.replay(&log).unwrap(); assert_eq!(reopened.to_snapshot(), removed); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.to_snapshot(), snapshot, "one undo restores photos, album and stack"); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.catalog.to_snapshot(), removed); for (path, bytes) in files { assert_eq!(std::fs::read(path).unwrap(), bytes, "permanent deletion only removes catalog records"); } let _ = std::fs::remove_dir_all(&src); } #[test] fn fresh_import_of_two_trashed_photos_in_one_album_and_stack_works() { let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-fresh-refs"); let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap(); assert_eq!(ids_len(&r), 2, "{r}"); assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty()); assert!(ids.iter().all(|i| s.catalog.photo(lightcraft_catalog::PhotoId(*i)).is_none())); let _ = std::fs::remove_dir_all(&src); } fn ids_len(r: &Value) -> usize { ids(r, "imported") } #[test] fn restore_import_applies_the_album_to_restored_photos() { let (mut s, src, ids, _) = trashed_photo_ids("trash-restore-album"); let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "restore", "albumName": "Back"})).unwrap(); assert_eq!(r["restored"], json!(ids), "{r}"); let album = r["album"].as_u64().expect("album created for the restored photos"); assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.len(), 1); let _ = std::fs::remove_dir_all(&src); } fn trashed_photo_ids(tag: &str) -> (Session, std::path::PathBuf, [u64; 1], u64) { let (s, src, id) = trashed_photo(tag); (s, src, [id], 0) } /// Issue #374: launch arguments never turn into a walk of a whole drive or home folder. An empty /// argument is ignored; `.`, `..` and (on Windows) `/` are resolved against the working folder a /// launcher picked, which is often a drive root; a root is replaced by its memory card's DCIM /// folder or refused, and so is the home folder. #[test] fn launch_paths_never_become_a_drive_root_or_the_home_folder() { use crate::import::{LaunchPath, launch_path}; let root = if cfg!(windows) { Path::new("C:\\") } else { Path::new("/") }; let home = if cfg!(windows) { Path::new("C:\\Users\\me") } else { Path::new("/home/me") }; let none = |_: &Path| None; let refused = |r: Option| matches!(r, Some(LaunchPath::Refused { .. })); for empty in ["", " "] { assert_eq!(launch_path(empty, Some(root), Some(home), none), None, "{empty:?}"); } // the working folder is the drive root: `.` (and `..`) name the whole drive for arg in [".", "..", "./", ".\\..", "/"] { let arg = if cfg!(windows) { arg.to_string() } else { arg.replace('\\', "/") }; assert!(refused(launch_path(&arg, Some(root), Some(home), none)), "{arg:?}"); } assert!(refused(launch_path("..", Some(&home.join("Pictures")), Some(home), none)), "up to the home folder"); assert!(refused(launch_path(&home.to_string_lossy(), None, Some(home), none))); assert!(refused(launch_path(".", None, Some(home), none)), "relative, no working folder"); // a memory card's root: its camera folder let dcim = |r: &Path| Some(r.join("DCIM")); assert_eq!(launch_path(".", Some(root), Some(home), dcim), Some(LaunchPath::Import(root.join("DCIM").to_string_lossy().to_string()))); // ordinary folders and files resolve to absolute paths let pics = home.join("Pictures"); assert_eq!(launch_path("Pictures", Some(home), Some(home), none), Some(LaunchPath::Import(pics.to_string_lossy().to_string()))); assert_eq!( launch_path(&pics.join("a.jpg").to_string_lossy(), None, Some(home), none), Some(LaunchPath::Import(pics.join("a.jpg").to_string_lossy().to_string())) ); } /// Issue #374: a folder import is bounded — a tree deeper or larger than the limits is not walked /// to the end (it stops and says so), and what it found is still returned. #[test] fn expand_stops_at_the_walk_limits() { let d = temp_dir("bounded"); let mut deep = d.clone(); for i in 0..8 { deep = deep.join(format!("d{i}")); std::fs::create_dir_all(&deep).unwrap(); std::fs::write(deep.join(format!("p{i}.jpg")), b"x").unwrap(); } let top = [d.to_string_lossy().to_string()]; let (all, cut) = crate::import::expand_within(&top, None, crate::walk::Limits::default()); assert_eq!((all.len(), cut), (8, false)); let (some, cut) = crate::import::expand_within(&top, None, crate::walk::Limits { max_depth: 3, max_entries: 1000 }); assert_eq!((some.len(), cut), (2, true), "{some:?}"); let (few, cut) = crate::import::expand_within(&top, None, crate::walk::Limits { max_depth: 32, max_entries: 5 }); assert!(cut && few.len() < 8, "{few:?}"); let _ = std::fs::remove_dir_all(&d); } /// Raw containers LightCraft cannot decode but shows by their embedded JPEG are picked up from a folder /// (the extensions are in `import::EXTENSIONS`; the files differ so none is a duplicate) and import as preview only. #[test] fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() { use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; let src = temp_dir("preview-only-exts"); let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); // CRW / MRW / X3F: their magic, then the JPEG. IIQ / KDC / MOS / ERF: a TIFF whose IFD0 only points at the JPEG. let tiff = { let off = 8 + 2 + 24 + 4; let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] { f.extend_from_slice(&tag.to_le_bytes()); f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); f.extend_from_slice(&v.to_le_bytes()); } f.extend_from_slice(&[0, 0, 0, 0]); f.extend_from_slice(&jpeg); f }; let with_magic = |magic: &[u8]| [magic, &[0x11u8; 64][..], &jpeg].concat(); let files: [(&str, Vec); 7] = [ ("a.CRW", with_magic(b"II\x1a\0\0\0HEAPCCDR")), ("b.mrw", with_magic(b"\0MRM\0\x01\0\0")), ("c.x3f", with_magic(b"FOVb\x02\0\x02\0")), ("d.iiq", [&tiff[..], b"d"].concat()), ("e.kdc", [&tiff[..], b"e"].concat()), ("f.mos", [&tiff[..], b"f"].concat()), ("g.erf", [&tiff[..], b"g"].concat()), ]; for (name, bytes) in &files { std::fs::write(src.join(name), bytes).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"], 7, "{r}"); assert_eq!(ids(&r, "imported"), 7, "{r}"); assert_eq!(ids(&r, "failed"), 0, "{r}"); assert!(s.catalog.photos().all(|p| p.preview_only.is_some() && p.kind == lightcraft_catalog::MediaKind::Raw && (p.width, p.height) == (40, 30))); let _ = std::fs::remove_dir_all(&src); } /// Rewrite the JSON header line of the smart preview at `file`. fn edit_smart_header(file: &Path, edit: impl FnOnce(&mut Value)) { let bytes = std::fs::read(file).unwrap(); let start = b"LCSP1\n".len(); let nl = start + bytes[start..].iter().position(|b| *b == b'\n').unwrap(); let mut head: Value = serde_json::from_slice(&bytes[start..nl]).unwrap(); edit(&mut head); let mut out = bytes[..start].to_vec(); out.extend_from_slice(head.to_string().as_bytes()); out.extend_from_slice(&bytes[nl..]); std::fs::write(file, out).unwrap(); } fn smart_header(file: &Path) -> Value { let bytes = std::fs::read(file).unwrap(); let start = b"LCSP1\n".len(); let nl = start + bytes[start..].iter().position(|b| *b == b'\n').unwrap(); serde_json::from_slice(&bytes[start..nl]).unwrap() } /// A camera tone curve with a recognisable shape (`k` changes it). fn test_tone(k: f32) -> lightcraft_pipeline::tone::CameraTone { lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| { let x = 0.004 * 1.17f32.powi(i as i32); [x, 0.95 * (1.0 - (-k * x).exp())] })) .unwrap() } /// `plain`, but reporting `tone` as the camera look the (current) fit gives the file; counts its calls. fn look_loader( plain: crate::media::FileLoader, tone: lightcraft_pipeline::tone::CameraTone, calls: std::sync::Arc, ) -> crate::media::FileLoader { std::sync::Arc::new(move |path, max_edge| { calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let (img, mut info) = plain(path, max_edge)?; info.camera_tone = Some(tone); Ok((img, info)) }) } /// The follow-up to the camera tone curve change (#499): a smart preview keeps the look the fit /// gave it, so one written by an older fit is rebuilt when its original is online, kept while the /// original is offline, and not rebuilt again once current. Only raws that take the per-file /// camera look go stale (a PNG's proxy never does), and the opening scan runs once per fit version. #[test] fn smart_previews_from_an_older_look_fit_are_refreshed() { use std::sync::atomic::{AtomicUsize, Ordering}; let src = temp_dir("smartlook-src"); let lib = temp_dir("smartlook-lib"); write_png(&src.join("a.png"), 3); write_png(&src.join("b.png"), 9); let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id; let (id, png) = (find(&s, "a.png"), find(&s, "b.png")); // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) let mut raw = (**s.catalog.photo(id).unwrap()).clone(); raw.format = "ARW".into(); raw.kind = lightcraft_catalog::MediaKind::Raw; s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); assert!(s.catalog.photo(id).unwrap().relative_wb() && !s.catalog.photo(png).unwrap().relative_wb()); s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap(); let dir = s.media.smart_dir.clone().unwrap(); let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); let png_file = crate::smart::sharded_file_path(&dir, s.catalog.photo(png).unwrap()); let (old, new) = (test_tone(1.5), test_tone(3.5)); let calls = std::sync::Arc::new(AtomicUsize::new(0)); let plain = s.media.file_loader.clone().unwrap(); s.media.file_loader = Some(look_loader(plain.clone(), old, calls.clone())); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}"); assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version"); assert!(!crate::smart::is_stale(&file)); // current header: kept as it is, the original is not even read let before = std::fs::read(&file).unwrap(); calls.store(0, Ordering::SeqCst); s.media.file_loader = Some(look_loader(plain.clone(), new, calls.clone())); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}"); assert!(std::fs::read(&file).unwrap() == before, "a current proxy is not rebuilt"); assert_eq!(calls.load(Ordering::SeqCst), 0); // old headers (no field), originals offline: kept unchanged, and still marked stale for f in [&file, &png_file] { edit_smart_header(f, |h| { h.as_object_mut().unwrap().remove("look_version"); }); } assert!(crate::smart::is_stale(&file), "no field = version 0"); let old_bytes = std::fs::read(&file).unwrap(); let png_bytes = std::fs::read(&png_file).unwrap(); std::fs::rename(&src, src.with_extension("offline")).unwrap(); s.media.forget(id); s.media.forget(png); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(2), Some(0), Some(1)), "{r}"); assert!(r["failed"].as_array().unwrap().is_empty(), "{r}"); assert!(std::fs::read(&file).unwrap() == old_bytes, "offline: the stored preview and curve are untouched"); assert!(crate::smart::is_stale(&file), "and it stays marked for the next time"); assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(old), "the old look is still there"); // ... and the opening refresh keeps it too, then marks the folder checked assert!(!crate::smart::look_checked(&dir)); crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); assert!(std::fs::read(&file).unwrap() == old_bytes); assert!(crate::smart::look_checked(&dir)); // the original comes back: the scan has run at this version, so the next opening leaves it; // Build Smart Previews rebuilds it with the current look std::fs::rename(src.with_extension("offline"), &src).unwrap(); calls.store(0, Ordering::SeqCst); assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none(), "checked once per version"); assert!(std::fs::read(&file).unwrap() == old_bytes); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(1), 1), "{r}"); assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION)); assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, Some(new), "rebuilt with the current look"); assert!(!crate::smart::is_stale(&file)); assert!(std::fs::read(&png_file).unwrap() == png_bytes, "a PNG's proxy is never rebuilt for the look"); // a folder checked at an older version (or not at all) is scanned once at the next opening edit_smart_header(&file, |h| h["look_version"] = json!(0)); std::fs::write(dir.join("look-version"), b"0\n").unwrap(); assert!(!crate::smart::look_checked(&dir)); s.media.forget(id); calls.store(0, Ordering::SeqCst); crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); assert_eq!(calls.load(Ordering::SeqCst), 1, "only the raw's original is read"); assert!(!crate::smart::is_stale(&file)); assert!(std::fs::read(&png_file).unwrap() == png_bytes); assert!(crate::smart::look_checked(&dir)); // once only let rebuilt = std::fs::read(&file).unwrap(); assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none()); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(0), 1), "{r}"); assert!(std::fs::read(&file).unwrap() == rebuilt); // Build Smart Previews refreshes a stale one the same way edit_smart_header(&file, |h| h["look_version"] = json!(0)); s.media.file_loader = Some(plain); let r = s.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!(r["refreshed"], 1, "{r}"); assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept"); let _ = std::fs::remove_dir_all(&src); let _ = std::fs::remove_dir_all(&lib); } /// The look marker: missing, damaged or older than this build's fit version counts as unchecked. #[test] fn smart_preview_look_marker_is_read_safely() { let dir = temp_dir("smartlook-marker"); assert!(!crate::smart::look_checked(&dir), "no marker"); let cur = crate::camera_preview::LOOK_VERSION; let enc = crate::smart::ENCODE_VERSION; for (text, checked) in [ (String::new(), false), ("banana".into(), false), ("-1".into(), false), (format!("{}", cur.saturating_sub(1)), false), ("9".repeat(200), false), // Written before the encode version was recorded: only says the look was checked, so the // folder is scanned once more and the old-encoding proxies are rebuilt. (format!("{cur}\n"), false), (format!("{cur} {}\n", enc.saturating_sub(1)), false), (format!("{cur} banana\n"), false), (format!("{cur} {enc}\n"), true), (format!(" {} {}\n", cur + 1, enc + 1), true), ] { std::fs::write(dir.join("look-version"), &text).unwrap(); assert_eq!(crate::smart::look_checked(&dir), checked, "{text:?}"); } std::fs::remove_file(dir.join("look-version")).unwrap(); crate::smart::mark_look_checked(&dir).unwrap(); assert!(crate::smart::look_checked(&dir)); assert!(crate::smart::mark_look_checked(&dir.join("missing")).is_err()); let _ = std::fs::remove_dir_all(&dir); } /// A hostile or damaged `look_version` never panics: anything that is not a non-negative integer /// counts as 0 (rebuilt when the original is online); a version from a newer build is left alone. #[test] fn malformed_smart_preview_look_versions_are_handled() { let dir = temp_dir("smartlook-bad"); let img = lightcraft_scenes::demo_library()[0].render(32, 20); let file = dir.join("a.lcsp"); std::fs::write(&file, crate::smart::encode(&img, None).unwrap()).unwrap(); let cur = u64::from(crate::camera_preview::LOOK_VERSION); for bad in [json!("1"), json!(-1), json!(1.5), json!(null), json!([]), json!({}), json!(true), json!(1e30), json!(-0.0)] { let shown = bad.to_string(); edit_smart_header(&file, |h| h["look_version"] = bad); assert!(crate::smart::is_valid(&file), "{shown}"); assert!(crate::smart::is_stale(&file), "{shown} counts as version 0"); assert!(crate::smart::decode(&std::fs::read(&file).unwrap()).is_ok(), "{shown}"); } for fine in [json!(cur), json!(cur + 1), json!(u64::MAX)] { let shown = fine.to_string(); edit_smart_header(&file, |h| h["look_version"] = fine); assert!(!crate::smart::is_stale(&file), "{shown}"); } // not a header at all, or missing assert!(!crate::smart::is_stale(&dir.join("missing.lcsp"))); std::fs::write(&file, b"LCSP1\n{not json\n\xFF\xD8").unwrap(); assert!(!crate::smart::is_stale(&file)); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_file_gone_since_the_import_review_is_not_imported() { let src = temp_dir("gone-since-review"); write_png(&src.join("a.png"), 1); write_png(&src.join("b.png"), 2); let mut s = Session::new().with_fs(); s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap(); std::fs::remove_file(src.join("b.png")).unwrap(); let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 1), "{r}"); assert_eq!(s.catalog.len(), 1, "no photo for a file that isn't there"); let _ = std::fs::remove_dir_all(&src); } /// A HEIC laid out like an iPhone's (a grid of HEVC pictures, a Display P3 profile, a quarter turn /// in the container, a thumbnail) imports as a normal photo that develops and exports upright like /// a JPEG. In a build without the codecs' `heif` feature it is reported as failed, saying why. #[test] fn heic_imports_as_a_normal_photo_or_says_why_not() { use lightcraft_heif::testdata::{Prop, Spec, build}; let src = temp_dir("heic"); let photo = |x: u32, y: u32| [((x * 7 + y * 13) % 200 + 30) as u16, 110, 150]; let p3 = lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3); let bytes = build(&Spec { props: vec![Prop::Icc(p3), Prop::Irot(1)], thumbnail: Some(&photo), ..Spec::new(96, 64, &photo) }); std::fs::write(src.join("IMG_0001.HEIC"), &bytes).unwrap(); let mut s = Session::new().with_fs(); let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); if !lightcraft_codecs::Format::Heif.can_decode() { assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (0, 1), "{r}"); assert!(r["failed"].to_string().contains("isn't included in this build"), "{r}"); let _ = std::fs::remove_dir_all(&src); return; } assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}"); let p = s.catalog.photos().next().unwrap(); let id = p.id; // Upright (the container's turn applied) and not preview-only. assert_eq!((p.kind, p.preview_only.clone(), p.width, p.height), (lightcraft_catalog::MediaKind::Image, None, 64, 96)); s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap(); let plain = crate::export::export_photo(&mut s, id, &crate::export::ExportOptions::from_json(&json!({"format": "png"})), 1).unwrap(); assert_eq!((plain.width, plain.height), (64, 96)); s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap(); let brighter = crate::export::export_photo(&mut s, id, &crate::export::ExportOptions::from_json(&json!({"format": "png"})), 1).unwrap(); let mean = |png: &[u8]| { let d = lightcraft_codecs::decode(png, Default::default()).unwrap(); d.image.data.iter().map(|p| p[1]).sum::() / d.image.data.len() as f32 }; assert!(mean(&brighter.bytes) > mean(&plain.bytes) * 1.3, "exposure +1 brightens the HEIC"); let _ = std::fs::remove_dir_all(&src); } /// Issue #705: Nikon (and most cameras) write `.JPG` and `.NEF`, and a user may rename to `.Jpg`. /// The folder scan, `library.browse` and the sidecar lookup match extensions in any letter /// case. (The file dialog's filters, the one place that was case-sensitive on Linux before /// #342, are checked in `apps/lightcraft/src/dialog_filter.rs`.) #[test] fn upper_and_mixed_case_extensions_are_found_by_import_browse_and_sidecar_lookup() { use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; let src = temp_dir("upper-exts"); let jpeg = |seed: u8| { let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, seed, 200]).collect(); encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap() }; std::fs::write(src.join("a.JPG"), jpeg(1)).unwrap(); std::fs::write(src.join("b.Jpg"), jpeg(2)).unwrap(); std::fs::write(src.join("c.JPEG"), jpeg(3)).unwrap(); write_png(&src.join("d.PNG"), 4); // a raw container LightCraft shows by its embedded JPEG (a TIFF whose IFD0 points at one) let nef = { let j = jpeg(5); let off = 8 + 2 + 24 + 4; let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); for (tag, v) in [(513u16, off as u32), (514, j.len() as u32)] { f.extend_from_slice(&tag.to_le_bytes()); f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); f.extend_from_slice(&v.to_le_bytes()); } f.extend_from_slice(&[0, 0, 0, 0]); f.extend_from_slice(&j); f }; std::fs::write(src.join("e.NEF"), nef).unwrap(); std::fs::write(src.join("notes.TXT"), "not a photo").unwrap(); // sidecars: stem-named with an upper-case extension, and full-named beside a mixed-case photo let sidecar = |rating: u8| { format!( r#" "# ) }; std::fs::write(src.join("a.XMP"), sidecar(4)).unwrap(); std::fs::write(src.join("b.Jpg.xmp"), sidecar(2)).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}"); // the five photos; notes.TXT is not one assert_eq!(ids(&r, "imported"), 5, "{r}"); assert_eq!(ids(&r, "failed"), 0, "{r}"); assert_eq!(r["sidecars"], 2, "{r}"); let names: Vec<(String, String, u8)> = s.catalog.photos().map(|p| (p.file_name.clone(), p.format.clone(), p.rating)).collect(); let by_name = |n: &str| names.iter().find(|(f, _, _)| f == n).cloned().unwrap_or_else(|| panic!("{n} missing from {names:?}")); assert_eq!(by_name("a.JPG").2, 4, "a.XMP read"); assert_eq!(by_name("b.Jpg").2, 2, "b.Jpg.xmp read"); assert_eq!(by_name("c.JPEG").1, "JPEG"); assert_eq!(by_name("d.PNG").1, "PNG"); let nef = s.catalog.photos().find(|p| p.file_name == "e.NEF").unwrap(); assert!(nef.kind == lightcraft_catalog::MediaKind::Raw && nef.preview_only.is_some(), "{nef:?}"); // Browse Folder (the Local section) lists the same five, without adding them to the library let mut b = Session::new().with_fs(); let r = b.execute("library.browse", &json!({"path": src.to_string_lossy()})).unwrap(); assert_eq!(r["photos"], 5, "{r}"); assert_eq!(r["new"], 5, "{r}"); assert_eq!(r["failed"], 0, "{r}"); let visible = b.visible_cloned(); let shown: Vec = visible.iter().filter_map(|id| b.catalog.photo(*id).map(|p| p.file_name.clone())).collect(); for n in ["a.JPG", "b.Jpg", "c.JPEG", "d.PNG", "e.NEF"] { assert!(shown.iter().any(|f| f == n), "{n} not shown: {shown:?}"); } let _ = std::fs::remove_dir_all(&src); }