624 lines
28 KiB
Rust
624 lines
28 KiB
Rust
//! Synchronize Folder: bring a library folder up to date with what is on disk (see
|
||
//! [`crate::sync`]).
|
||
//!
|
||
//! Scenarios, in the words of someone whose folder changed outside LightCraft:
|
||
//!
|
||
//! * Given a folder of the library, when files were added to it (or to a folder inside it), the
|
||
//! scan lists them as new; files the library already has, by path or by content, are not new.
|
||
//! * When a photo's file was deleted or moved away, the scan lists the photo as missing.
|
||
//! * When another app saved a photo's XMP sidecar after the photo came into the library, and the
|
||
//! sidecar says something the library doesn't, the scan lists a metadata update; a sidecar older
|
||
//! than that, or one that agrees with the library, is not an update.
|
||
//! * Scanning changes nothing.
|
||
//! * Synchronizing imports the new files; it removes missing photos (to Recently Deleted) and
|
||
//! reads metadata updates only when asked. Whatever it does is one undo step.
|
||
//! * Only a folder of the library can be synchronized, never the whole startup disk.
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::time::{Duration, SystemTime};
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::Session;
|
||
|
||
/// A scratch folder that goes away with the test, however it ends.
|
||
struct Scratch(PathBuf);
|
||
|
||
impl Scratch {
|
||
fn new(tag: &str) -> Scratch {
|
||
let dir = std::env::temp_dir().join(format!("lc-sync-{tag}-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
Scratch(dir)
|
||
}
|
||
fn path(&self, rel: &str) -> String {
|
||
self.0.join(rel).to_string_lossy().to_string()
|
||
}
|
||
}
|
||
|
||
impl Drop for Scratch {
|
||
fn drop(&mut self) {
|
||
let _ = std::fs::remove_dir_all(&self.0);
|
||
}
|
||
}
|
||
|
||
/// A small procedural PNG (distinct per seed).
|
||
fn write_png(path: &str, seed: u8) {
|
||
let (w, h) = (24usize, 16usize);
|
||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) 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::new(path).parent().unwrap()).unwrap();
|
||
std::fs::write(path, bytes).unwrap();
|
||
}
|
||
|
||
fn sidecar(rating: u8) -> String {
|
||
format!(
|
||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="{rating}"/>
|
||
</rdf:RDF></x:xmpmeta>"#
|
||
)
|
||
}
|
||
|
||
/// Write `a.xmp` beside `a.png`, dated `age` before now.
|
||
fn write_sidecar(png: &str, rating: u8, age: Duration) {
|
||
let xmp = Path::new(png).with_extension("xmp");
|
||
std::fs::write(&xmp, sidecar(rating)).unwrap();
|
||
let f = std::fs::File::options().write(true).open(&xmp).unwrap();
|
||
f.set_modified(SystemTime::now() - age).unwrap();
|
||
}
|
||
|
||
/// A library holding `trip/a.png` and `trip/day1/b.png`, the session clock at a fixed past time.
|
||
fn library(dir: &Scratch) -> Session {
|
||
write_png(&dir.path("trip/a.png"), 1);
|
||
write_png(&dir.path("trip/day1/b.png"), 2);
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap();
|
||
assert_eq!(s.catalog.len(), 2);
|
||
s
|
||
}
|
||
|
||
fn paths(v: &Value, key: &str) -> Vec<String> {
|
||
let mut out: Vec<String> = v[key].as_array().unwrap().iter().map(|c| c["path"].as_str().unwrap().to_string()).collect();
|
||
out.sort();
|
||
out
|
||
}
|
||
|
||
/// What the library holds: each photo's file, whether it is in the library, and its rating.
|
||
fn holdings(s: &Session) -> Vec<(String, bool, u8)> {
|
||
let mut v: Vec<(String, bool, u8)> = s.catalog.photos().map(|p| (p.file_name.clone(), p.in_library(), p.rating)).collect();
|
||
v.sort();
|
||
v
|
||
}
|
||
|
||
fn scan(s: &mut Session, path: &str) -> Value {
|
||
s.execute("folder.scanChanges", &json!({"path": path})).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn an_unchanged_folder_has_no_changes() {
|
||
let dir = Scratch::new("none");
|
||
let mut s = library(&dir);
|
||
let r = scan(&mut s, &dir.path("trip"));
|
||
assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn files_added_to_the_folder_or_a_folder_inside_it_are_new() {
|
||
let dir = Scratch::new("new");
|
||
let mut s = library(&dir);
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
write_png(&dir.path("trip/day2/d.png"), 4);
|
||
// the same bytes as a photo the library has: not new
|
||
std::fs::copy(dir.path("trip/a.png"), dir.path("trip/a-copy.png")).unwrap();
|
||
let before = s.catalog.to_snapshot();
|
||
let r = scan(&mut s, &dir.path("trip"));
|
||
assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png"), dir.path("trip/day2/d.png")], "{r}");
|
||
assert_eq!(r["duplicates"], 1, "{r}");
|
||
assert_eq!(s.catalog.to_snapshot(), before, "scanning changes nothing");
|
||
}
|
||
|
||
#[test]
|
||
fn a_photo_whose_file_is_gone_is_missing() {
|
||
let dir = Scratch::new("missing");
|
||
let mut s = library(&dir);
|
||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||
let r = scan(&mut s, &dir.path("trip"));
|
||
assert_eq!(paths(&r, "missing"), vec![dir.path("trip/day1/b.png")], "{r}");
|
||
assert!(r["missing"][0]["id"].is_u64());
|
||
}
|
||
|
||
#[test]
|
||
fn a_sidecar_saved_by_another_app_is_a_metadata_update() {
|
||
let dir = Scratch::new("meta");
|
||
let mut s = library(&dir);
|
||
// saved now: after the photo came in (the session clock says 2026-09-30)
|
||
write_sidecar(&dir.path("trip/a.png"), 4, Duration::ZERO);
|
||
let r = scan(&mut s, &dir.path("trip"));
|
||
assert_eq!(paths(&r, "metadata"), vec![dir.path("trip/a.png")], "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn an_old_sidecar_or_one_that_agrees_is_no_update() {
|
||
let dir = Scratch::new("meta-old");
|
||
let mut s = library(&dir);
|
||
// older than the import: whatever it says, the library has had its say since
|
||
write_sidecar(&dir.path("trip/a.png"), 4, Duration::from_secs(400 * 24 * 3600));
|
||
// new, but says what the library already knows
|
||
write_sidecar(&dir.path("trip/day1/b.png"), 0, Duration::ZERO);
|
||
let r = scan(&mut s, &dir.path("trip"));
|
||
assert!(paths(&r, "metadata").is_empty(), "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn synchronizing_imports_new_files_and_leaves_the_rest_unless_asked() {
|
||
let dir = Scratch::new("sync");
|
||
let mut s = library(&dir);
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
|
||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(1), Some(0), Some(0)), "{r}");
|
||
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 3, "the missing photo stays");
|
||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap();
|
||
assert_eq!(a.rating, 0, "metadata is read only when asked");
|
||
}
|
||
|
||
#[test]
|
||
fn synchronizing_everything_is_one_undo_step() {
|
||
let dir = Scratch::new("sync-all");
|
||
let mut s = library(&dir);
|
||
let before = holdings(&s);
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
|
||
let r =
|
||
s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "importNew": true, "removeMissing": true, "readMetadata": true})).unwrap();
|
||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(1), Some(1), Some(1)), "{r}");
|
||
let library: Vec<String> = s.catalog.photos().filter(|p| p.in_library()).map(|p| p.file_name.clone()).collect();
|
||
assert_eq!(library.len(), 2, "b went to Recently Deleted: {library:?}");
|
||
assert_eq!(s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().rating, 5, "the sidecar was read");
|
||
let undo = s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(undo["undone"], "Synchronize Folder");
|
||
assert_eq!(holdings(&s), before, "one undo step puts everything back");
|
||
}
|
||
|
||
#[test]
|
||
fn a_folder_with_no_changes_synchronizes_without_an_undo_step() {
|
||
let dir = Scratch::new("sync-none");
|
||
let mut s = library(&dir);
|
||
let steps = s.undo.len();
|
||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true, "readMetadata": true})).unwrap();
|
||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(0), Some(0), Some(0)), "{r}");
|
||
assert_eq!(s.undo.len(), steps);
|
||
}
|
||
|
||
#[test]
|
||
fn only_a_folder_of_the_library_is_synchronized() {
|
||
let dir = Scratch::new("refused");
|
||
let mut s = library(&dir);
|
||
std::fs::create_dir_all(dir.0.join("elsewhere")).unwrap();
|
||
for (cmd, p, why) in [
|
||
("folder.scanChanges", json!({}), "missing path"),
|
||
("folder.scanChanges", json!({"path": " "}), "blank path"),
|
||
("folder.scanChanges", json!({"path": dir.path("elsewhere")}), "no photo was imported from it"),
|
||
("folder.scanChanges", json!({"path": "/"}), "the startup disk"),
|
||
("folder.synchronize", json!({"path": dir.path("elsewhere")}), "no photo was imported from it"),
|
||
("folder.synchronize", json!({"path": "/"}), "the startup disk"),
|
||
] {
|
||
assert!(s.execute(cmd, &p).is_err(), "{cmd}: {why}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn synchronizing_acts_on_the_changes_just_scanned() {
|
||
let dir = Scratch::new("cached");
|
||
let mut s = library(&dir);
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
let r = scan(&mut s, &dir.path("trip"));
|
||
assert_eq!(paths(&r, "new").len(), 1);
|
||
// arrived after the scan the person was shown: not part of what they agreed to
|
||
write_png(&dir.path("trip/d.png"), 4);
|
||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||
assert_eq!(r["imported"], 1, "{r}");
|
||
// the scan is used once; the next synchronize looks again
|
||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||
assert_eq!(r["imported"], 1, "d.png now: {r}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_scan_is_stale_once_the_library_changed() {
|
||
let dir = Scratch::new("stale");
|
||
let mut s = library(&dir);
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
scan(&mut s, &dir.path("trip"));
|
||
// c.png comes in some other way between the scan and the synchronize
|
||
s.execute("library.import", &json!({"paths": [dir.path("trip/c.png")]})).unwrap();
|
||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||
assert_eq!(r["imported"], 0, "the library changed: it looked again: {r}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_cancelled_scan_stops_checking_files() {
|
||
let dir = Scratch::new("cancel");
|
||
let mut s = library(&dir);
|
||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
|
||
let progress = crate::sync::SyncProgress::default();
|
||
progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||
let c = crate::sync::scan_with(input, &progress);
|
||
assert!(c.missing.is_empty() && c.metadata.is_empty(), "{c:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn many_photos_are_checked_in_parallel_with_the_same_answer() {
|
||
let dir = Scratch::new("many");
|
||
for i in 0..40u8 {
|
||
write_png(&dir.path(&format!("big/{i:02}.png")), i);
|
||
}
|
||
let mut s = Session::new().with_fs();
|
||
s.execute("library.import", &json!({"paths": [dir.path("big")]})).unwrap();
|
||
for i in (0..40u8).step_by(3) {
|
||
std::fs::remove_file(dir.path(&format!("big/{i:02}.png"))).unwrap();
|
||
}
|
||
for i in (1..40u8).step_by(5) {
|
||
write_sidecar(&dir.path(&format!("big/{i:02}.png")), 2, Duration::ZERO);
|
||
}
|
||
let r = scan(&mut s, &dir.path("big"));
|
||
let expect_missing: Vec<String> = (0..40u8).step_by(3).map(|i| dir.path(&format!("big/{i:02}.png"))).collect();
|
||
let expect_meta: Vec<String> = (1..40u8).step_by(5).filter(|i| i % 3 != 0).map(|i| dir.path(&format!("big/{i:02}.png"))).collect();
|
||
assert_eq!(paths(&r, "missing"), expect_missing);
|
||
assert_eq!(paths(&r, "metadata"), expect_meta);
|
||
}
|
||
|
||
#[test]
|
||
fn a_folder_that_is_not_there_is_not_scanned_as_if_everything_went_missing() {
|
||
let dir = Scratch::new("offline");
|
||
let mut s = library(&dir);
|
||
// an unplugged disk, or a folder moved away whole
|
||
std::fs::rename(dir.0.join("trip"), dir.0.join("trip-elsewhere")).unwrap();
|
||
for cmd in ["folder.scanChanges", "folder.synchronize"] {
|
||
let e = s.execute(cmd, &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap_err().to_string();
|
||
assert!(e.contains("not there"), "{cmd}: {e}");
|
||
}
|
||
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was removed");
|
||
}
|
||
|
||
#[test]
|
||
fn a_whole_disk_or_a_folder_holding_disks_is_synchronized_only_on_request() {
|
||
let dir = Scratch::new("disks");
|
||
let mut s = library(&dir);
|
||
// a library photo on another disk makes the scratch folder's root look like it holds disks
|
||
let id = s.catalog.alloc_photo_id();
|
||
let p = lightcraft_catalog::Photo::new(
|
||
id,
|
||
lightcraft_catalog::Source::File { path: "/Volumes/nas/x.jpg".into() },
|
||
"x.jpg",
|
||
"JPEG",
|
||
6,
|
||
4,
|
||
"2026-01-01",
|
||
);
|
||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||
if std::path::Path::new("/Volumes").is_dir() {
|
||
let e = s.execute("folder.scanChanges", &json!({"path": "/Volumes"})).unwrap_err().to_string();
|
||
assert!(e.contains("disk: true"), "{e}");
|
||
}
|
||
// a folder inside one disk needs nothing more
|
||
assert!(s.execute("folder.scanChanges", &json!({"path": dir.path("trip")})).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn a_file_only_browsed_in_local_is_new_and_joins_the_library() {
|
||
let dir = Scratch::new("local");
|
||
let mut s = library(&dir);
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
// looking at the folder in Local catalogues c.png as a browse record, not a library photo
|
||
s.execute("library.browse", &json!({"path": dir.path("trip")})).unwrap();
|
||
assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.local));
|
||
let r = scan(&mut s, &dir.path("trip"));
|
||
assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png")], "{r}");
|
||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||
assert_eq!(r["imported"], 1, "{r}");
|
||
assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.in_library()));
|
||
}
|
||
|
||
#[test]
|
||
fn a_file_renamed_or_moved_inside_the_folder_is_relinked_not_lost() {
|
||
let dir = Scratch::new("moved");
|
||
let mut s = library(&dir);
|
||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap();
|
||
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/day1/a-renamed.png")).unwrap();
|
||
let r = scan(&mut s, &dir.path("trip"));
|
||
assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}");
|
||
assert_eq!(r["moved"][0]["id"], a.0, "{r}");
|
||
assert_eq!(r["moved"][0]["to"], dir.path("trip/day1/a-renamed.png"), "{r}");
|
||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap();
|
||
assert_eq!((r["relinked"].as_u64(), r["removed"].as_u64(), r["imported"].as_u64()), (Some(1), Some(0), Some(0)), "{r}");
|
||
let p = s.catalog.photo(a).unwrap();
|
||
assert!(p.in_library() && p.rating == 4, "the same photo, edits and all");
|
||
assert_eq!(p.source, lightcraft_catalog::Source::File { path: dir.path("trip/day1/a-renamed.png") });
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.catalog.photo(a).unwrap().source, lightcraft_catalog::Source::File { path: dir.path("trip/a.png") });
|
||
}
|
||
|
||
#[test]
|
||
fn synchronizing_is_one_undo_step_even_with_a_full_undo_history() {
|
||
let dir = Scratch::new("full-undo");
|
||
let mut s = library(&dir);
|
||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||
// a long session: the history is at its limit and drops its oldest steps
|
||
for i in 0..1005u32 {
|
||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": i % 5})).unwrap();
|
||
}
|
||
let before = holdings(&s);
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap();
|
||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64()), (Some(1), Some(1)), "{r}");
|
||
let undo = s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(undo["undone"], "Synchronize Folder");
|
||
assert_eq!(holdings(&s), before, "one step undoes the import and the removal");
|
||
}
|
||
|
||
#[test]
|
||
fn a_scan_stays_current_while_only_other_folders_change() {
|
||
let dir = Scratch::new("current");
|
||
let mut s = library(&dir);
|
||
write_png(&dir.path("other/x.png"), 9);
|
||
s.execute("library.import", &json!({"paths": [dir.path("other")]})).unwrap();
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
scan(&mut s, &dir.path("trip"));
|
||
// something elsewhere in the library changes while the dialog is open (a background import…)
|
||
let x = s.catalog.photos().find(|p| p.file_name == "x.png").unwrap().id;
|
||
s.execute("photo.rate", &json!({"ids": [x.0], "rating": 3})).unwrap();
|
||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap();
|
||
assert_eq!(r["imported"], 1, "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_change_to_the_folders_photos_makes_the_scan_stale() {
|
||
let dir = Scratch::new("stale-folder");
|
||
let mut s = library(&dir);
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
scan(&mut s, &dir.path("trip"));
|
||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 2})).unwrap();
|
||
// the dialog asks for exactly what it showed: refused, never redone on the spot
|
||
let e = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap_err().to_string();
|
||
assert!(e.contains("scan again"), "{e}");
|
||
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was done");
|
||
// an agent that just asks to synchronize gets a fresh scan
|
||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||
assert_eq!(r["imported"], 1, "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn the_progress_counts_the_photos_checked_too() {
|
||
use std::sync::atomic::Ordering::Relaxed;
|
||
let dir = Scratch::new("progress");
|
||
let mut s = library(&dir);
|
||
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
|
||
let progress = crate::sync::SyncProgress::default();
|
||
crate::sync::scan_with(input, &progress);
|
||
assert_eq!((progress.checked.load(Relaxed), progress.to_check.load(Relaxed)), (2, 2));
|
||
let (done, total) = progress.counts();
|
||
assert!(done == total && total >= 2, "{done}/{total}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_sidecar_the_library_wrote_itself_is_no_update() {
|
||
let dir = Scratch::new("autowrite");
|
||
let mut s = library(&dir);
|
||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 3})).unwrap();
|
||
s.execute("photo.label", &json!({"ids": [a.0], "label": "green"})).unwrap();
|
||
assert!(Path::new(&dir.path("trip/a.xmp")).exists(), "auto-write wrote the sidecar");
|
||
let r = scan(&mut s, &dir.path("trip"));
|
||
assert!(paths(&r, "metadata").is_empty(), "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_renamed_photo_with_no_content_hash_is_relinked_when_its_file_is_unmistakable() {
|
||
// as photos taken over from a Lightroom catalog are: no hash on record
|
||
let dir = Scratch::new("nohash");
|
||
let mut s = library(&dir);
|
||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||
let mut p = (**s.catalog.photo(a).unwrap()).clone();
|
||
p.content_hash = None;
|
||
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id: a }).unwrap();
|
||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/a-renamed.png")).unwrap();
|
||
let r = scan(&mut s, &dir.path("trip"));
|
||
assert_eq!(r["moved"][0]["to"], dir.path("trip/a-renamed.png"), "{r}");
|
||
assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn renamed_duplicates_are_each_relinked() {
|
||
let dir = Scratch::new("dups");
|
||
let mut s = library(&dir);
|
||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||
s.execute("photo.duplicate", &json!({"ids": [a.0]})).unwrap();
|
||
assert!(Path::new(&dir.path("trip/a-copy.png")).exists());
|
||
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/one.png")).unwrap();
|
||
std::fs::rename(dir.path("trip/a-copy.png"), dir.path("trip/two.png")).unwrap();
|
||
let r = scan(&mut s, &dir.path("trip"));
|
||
assert_eq!(r["moved"].as_array().map(Vec::len), Some(2), "{r}");
|
||
assert!(paths(&r, "missing").is_empty(), "{r}");
|
||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||
assert_eq!(r["relinked"], 2, "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn face_regions_found_meanwhile_leave_the_scan_current() {
|
||
let dir = Scratch::new("faces");
|
||
let mut s = library(&dir);
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
scan(&mut s, &dir.path("trip"));
|
||
// background face search adds a region to a photo of the folder
|
||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||
let mut meta = s.catalog.photo(a).unwrap().meta.clone();
|
||
meta.regions.push(lightcraft_meta::Region { rect: Default::default(), kind: lightcraft_meta::RegionKind::Face, name: None, description: None });
|
||
s.catalog.apply(lightcraft_catalog::Op::SetMeta { id: a, meta: Box::new(meta) }).unwrap();
|
||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap();
|
||
assert_eq!(r["imported"], 1, "{r}");
|
||
}
|
||
|
||
/// Synchronizing in halves, as the app does it: readied on the UI thread without touching the
|
||
/// disk, the work (every file read) on a worker, each step committed back on the UI thread.
|
||
fn sync_in_halves(s: &mut Session, path: &str, choice: crate::sync::SyncChoice) -> crate::sync::SyncReport {
|
||
let changes = crate::sync::scan(s, path, false).unwrap();
|
||
let (work, mut commit) = crate::sync::SyncJob::start(s, changes, choice).unwrap();
|
||
let progress = crate::sync::SyncProgress::default();
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
std::thread::spawn(move || work.run(&progress, |step| tx.send(step).is_ok())).join().unwrap();
|
||
for step in rx {
|
||
commit.apply(s, step);
|
||
}
|
||
commit.finish(s)
|
||
}
|
||
|
||
#[test]
|
||
fn the_work_of_synchronizing_runs_apart_from_its_commits() {
|
||
let dir = Scratch::new("halves");
|
||
let mut s = library(&dir);
|
||
let before = holdings(&s);
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
|
||
let choice = crate::sync::SyncChoice { import_new: true, relink_moved: true, remove_missing: true, read_metadata: true };
|
||
let r = sync_in_halves(&mut s, &dir.path("trip"), choice);
|
||
assert_eq!((r.imported, r.removed, r.read), (1, 1, 1), "{r:?}");
|
||
assert_eq!(s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().rating, 5);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(holdings(&s), before, "still one undo step");
|
||
}
|
||
|
||
#[test]
|
||
fn readying_a_synchronize_reads_no_file() {
|
||
let dir = Scratch::new("ready");
|
||
let mut s = library(&dir);
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
let changes = crate::sync::scan(&mut s, &dir.path("trip"), false).unwrap();
|
||
// the file goes away between readying (UI thread) and the work (worker): the work finds out
|
||
let (work, mut commit) = crate::sync::SyncJob::start(&mut s, changes, crate::sync::SyncChoice::default()).unwrap();
|
||
std::fs::remove_file(dir.path("trip/c.png")).unwrap();
|
||
let mut steps = Vec::new();
|
||
work.run(&crate::sync::SyncProgress::default(), |step| {
|
||
steps.push(step);
|
||
true
|
||
});
|
||
for step in steps {
|
||
commit.apply(&mut s, step);
|
||
}
|
||
let r = commit.finish(&mut s);
|
||
assert_eq!(r.imported, 0, "{r:?}");
|
||
assert_eq!(r.failed.len(), 1, "{r:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_cancelled_synchronize_does_nothing_more() {
|
||
let dir = Scratch::new("cancel-run");
|
||
let mut s = library(&dir);
|
||
let steps_before = s.undo.len();
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
let changes = crate::sync::scan(&mut s, &dir.path("trip"), false).unwrap();
|
||
let (work, commit) = crate::sync::SyncJob::start(&mut s, changes, crate::sync::SyncChoice::default()).unwrap();
|
||
let progress = crate::sync::SyncProgress::default();
|
||
progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||
let mut sent = 0;
|
||
work.run(&progress, |_| {
|
||
sent += 1;
|
||
true
|
||
});
|
||
assert_eq!(sent, 0);
|
||
let r = commit.finish(&mut s);
|
||
assert_eq!((r.imported, s.undo.len()), (0, steps_before));
|
||
}
|
||
|
||
/// Ready a synchronize of the trip folder and do its work, keeping the steps for the test to
|
||
/// commit when it likes.
|
||
fn readied(s: &mut Session, dir: &Scratch, choice: crate::sync::SyncChoice) -> (Vec<crate::sync::SyncStep>, crate::sync::SyncCommit) {
|
||
let changes = crate::sync::scan(s, &dir.path("trip"), false).unwrap();
|
||
let (work, commit) = crate::sync::SyncJob::start(s, changes, choice).unwrap();
|
||
let mut steps = Vec::new();
|
||
work.run(&crate::sync::SyncProgress::default(), |step| {
|
||
steps.push(step);
|
||
true
|
||
});
|
||
(steps, commit)
|
||
}
|
||
|
||
#[test]
|
||
fn steps_for_a_library_that_was_closed_meanwhile_are_not_applied() {
|
||
let dir = Scratch::new("switched");
|
||
let mut s = library(&dir);
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||
let choice = crate::sync::SyncChoice { remove_missing: true, ..Default::default() };
|
||
let (steps, mut commit) = readied(&mut s, &dir, choice);
|
||
// File › Open Library while the work runs: another catalog, whose photo ids mean other photos
|
||
s.library_identity = std::sync::Arc::new(());
|
||
let before = holdings(&s);
|
||
let undo = s.undo.len();
|
||
for step in steps {
|
||
commit.apply(&mut s, step);
|
||
}
|
||
let r = commit.finish(&mut s);
|
||
assert_eq!(holdings(&s), before, "nothing landed in the other library");
|
||
assert_eq!(s.undo.len(), undo);
|
||
assert_eq!((r.imported, r.removed), (0, 0), "{r:?}");
|
||
assert!(!r.failed.is_empty(), "and the report says why: {r:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn an_undo_pressed_while_synchronizing_is_not_folded_into_it() {
|
||
let dir = Scratch::new("undo-mid");
|
||
let mut s = library(&dir);
|
||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap();
|
||
write_png(&dir.path("trip/c.png"), 3);
|
||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||
let choice = crate::sync::SyncChoice { remove_missing: true, ..Default::default() };
|
||
let (mut steps, mut commit) = readied(&mut s, &dir, choice);
|
||
assert!(steps.len() >= 2, "an import step and a remove step");
|
||
let first = steps.remove(0);
|
||
commit.apply(&mut s, first);
|
||
// the person presses Undo between two steps of the run
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
for step in steps {
|
||
commit.apply(&mut s, step);
|
||
}
|
||
commit.finish(&mut s);
|
||
// undoing the run's last step never takes the rating with it
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.catalog.photo(a).unwrap().rating, 4, "the earlier, unrelated step stays");
|
||
}
|
||
|
||
#[test]
|
||
fn sidecar_namings_for_many_photos_agree_with_one_at_a_time() {
|
||
let dir = Scratch::new("namings");
|
||
let mut s = library(&dir);
|
||
// a raw beside its JPEG: the raw owns IMG_1.xmp, the JPEG's is IMG_1.JPG.xmp
|
||
for name in ["IMG_1.CR3", "IMG_1.JPG", "IMG_2.JPG"] {
|
||
let id = s.catalog.alloc_photo_id();
|
||
let p = lightcraft_catalog::Photo::new(
|
||
id,
|
||
lightcraft_catalog::Source::File { path: dir.path(&format!("trip/{name}")) },
|
||
name,
|
||
"X",
|
||
6,
|
||
4,
|
||
"2026-01-01",
|
||
);
|
||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||
}
|
||
let ids: Vec<lightcraft_catalog::PhotoId> = s.catalog.photos().map(|p| p.id).collect();
|
||
let one_by_one: Vec<_> = ids.iter().map(|id| s.sidecar_naming(*id)).collect();
|
||
assert_eq!(s.sidecar_namings(&ids), one_by_one);
|
||
assert!(one_by_one.contains(&crate::sidecar::SidecarNaming::Full), "the case that matters is there");
|
||
}
|