lightcraft/crates/engine/src/tests_libops.rs

592 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Library management commands: date groups, keyword rename/delete/merge, batch rename, capture
//! time, label names, import review.
use serde_json::json;
use crate::Session;
/// Date headers follow the grid order and the sort's grouping.
#[test]
fn date_groups_follow_sort_and_grouping() {
let mut s = Session::with_demo();
let vis = s.visible_cloned();
let g = s.execute("library.groups", &json!({})).unwrap();
let groups = g.as_array().unwrap();
assert!(groups.len() > 3, "{g}");
let total: u64 = groups.iter().map(|x| x["count"].as_u64().unwrap()).sum();
assert_eq!(total as usize, vis.len());
// contiguous runs in grid order, labelled with weekday and date
let mut next = 0;
for x in groups {
assert_eq!(x["start"].as_u64().unwrap(), next);
next += x["count"].as_u64().unwrap();
assert!(x["label"].as_str().unwrap().contains(", "), "{x}");
}
let months = s.execute("library.groups", &json!({"by": "month"})).unwrap();
assert!(months.as_array().unwrap().len() < groups.len());
assert_eq!(months[0]["key"].as_str().unwrap().len(), 7);
s.execute("library.sort", &json!({"group": "none"})).unwrap();
assert_eq!(s.execute("library.groups", &json!({})).unwrap(), json!([]));
s.execute("library.sort", &json!({"group": "year", "key": "fileName"})).unwrap();
assert_eq!(s.execute("library.groups", &json!({})).unwrap(), json!([]), "no date headers when sorting by name");
assert_eq!(s.sort.group, lightcraft_catalog::GroupBy::Year);
assert!(s.execute("library.sort", &json!({"group": "week"})).is_err());
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-libops-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn keywords_of(s: &Session, id: u64) -> Vec<String> {
s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.clone()
}
/// Keyword rename / merge / delete: one undo step each, replayed from the op log after a restart,
/// and the keyword filter follows the renamed keyword.
#[test]
fn keyword_rename_merge_delete_undo_and_replay() {
let dir = temp_dir("kw");
let mut s = Session::new();
s.open_library(&dir, true).unwrap();
let ids: Vec<u64> = s.visible_cloned().iter().take(4).map(|p| p.0).collect();
s.execute("photo.setMeta", &json!({"ids": [ids[0], ids[1]], "keywords": ["travel|italy|rome", "sea"]})).unwrap();
s.execute("photo.setMeta", &json!({"ids": [ids[2]], "keywords": ["Italia"]})).unwrap();
s.execute("photo.setMeta", &json!({"ids": [ids[3]], "keywords": ["travel|france", "sea"]})).unwrap();
let tree = s.execute("keyword.list", &json!({})).unwrap();
let travel = tree.as_array().unwrap().iter().find(|n| n["name"] == "travel").unwrap().clone();
assert_eq!(travel["count"], 3, "{tree}");
assert_eq!(travel["children"][1]["path"], "travel|italy");
// filter by a parent keyword: its children match
s.execute("library.filter", &json!({"keyword": "travel|italy"})).unwrap();
assert_eq!(s.visible().len(), 2);
let undo_before = s.undo.len();
let r = s.execute("keyword.rename", &json!({"from": "travel|italy", "to": "Europe|Italy"})).unwrap();
assert_eq!(r["changed"], 2);
assert_eq!(s.undo.len(), undo_before + 1, "one undo step");
assert_eq!(keywords_of(&s, ids[0]), ["Europe|Italy|rome", "sea"]);
assert_eq!(s.filter.keyword.as_deref(), Some("Europe|Italy"), "the filter follows the rename");
assert_eq!(s.visible().len(), 2);
s.execute("keyword.merge", &json!({"from": ["Italia"], "into": "Europe|Italy"})).unwrap();
assert_eq!(keywords_of(&s, ids[2]), ["Europe|Italy"]);
assert_eq!(s.visible().len(), 3);
s.execute("keyword.delete", &json!({"keyword": "travel"})).unwrap();
assert_eq!(keywords_of(&s, ids[3]), ["sea"]);
// suggestions: keywords used together with `sea` first
let sug = s.execute("keyword.suggest", &json!({"ids": [ids[3]]})).unwrap();
assert_eq!(sug[0], "Europe|Italy|rome", "{sug}");
let sug = s.execute("keyword.suggest", &json!({"ids": [ids[3]], "prefix": "eur"})).unwrap();
assert_eq!(sug, json!(["Europe|Italy|rome", "Europe|Italy"]), "most used first");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(keywords_of(&s, ids[3]), ["travel|france", "sea"]);
s.execute("edit.redo", &json!({})).unwrap();
// restart without a clean close: the log replays to the same state
let expect = s.catalog.to_snapshot();
drop(s);
let mut s = Session::new();
s.open_library(&dir, false).unwrap();
assert_eq!(s.catalog.to_snapshot(), expect);
// invalid
assert!(s.execute("keyword.rename", &json!({"from": "sea", "to": ""})).is_err());
assert!(s.execute("keyword.merge", &json!({"from": [], "into": "x"})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
fn write_png(path: &std::path::Path, seed: u8) {
let (w, h) = (24usize, 16usize);
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 11) as u8, seed, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
fn path_of(s: &Session, id: u64) -> String {
match &s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().source {
lightcraft_catalog::Source::File { path } => path.clone(),
_ => panic!("not a file"),
}
}
/// Batch rename on disk: collisions with existing files and within the batch get suffixes, sidecars
/// move along, virtual copies follow, undo/redo move the files back and forth, a failed move rolls
/// the batch back, and the op log replays the new paths.
#[test]
fn batch_rename_files_collisions_undo_and_replay() {
let src = temp_dir("rename-src");
let lib = temp_dir("rename-lib");
write_png(&src.join("a.png"), 1);
write_png(&src.join("b.png"), 2);
std::fs::write(src.join("Trip-001.png"), b"not ours").unwrap();
std::fs::write(src.join("a.xmp"), b"<x:xmpmeta xmlns:x='adobe:ns:meta/'></x:xmpmeta>").unwrap();
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(), src.join("b.png").to_string_lossy()]})).unwrap();
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!(ids.len(), 2, "{r}");
s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap();
s.execute("photo.virtualCopy", &json!({})).unwrap();
let copy = s.catalog.photos().find(|p| p.copy_of.is_some()).unwrap().id.0;
// preview: the existing Trip-001.png is skipped, the copy shares its master's file
let pv = s.execute("photo.renamePreview", &json!({"ids": [ids[0], copy, ids[1]], "template": "Trip-{seq:3}"})).unwrap();
assert_eq!(pv.as_array().unwrap().len(), 2, "{pv}");
assert_eq!(pv[0]["to"], "Trip-001-1.png");
assert_eq!(pv[1]["to"], "Trip-002.png");
let r = s.execute("photo.rename", &json!({"ids": [ids[0], copy, ids[1]], "template": "Trip-{seq:3}"})).unwrap();
assert_eq!(r["renamed"], 3, "{r}");
assert!(src.join("Trip-001-1.png").is_file() && src.join("Trip-002.png").is_file());
assert!(!src.join("a.png").exists() && !src.join("b.png").exists());
assert_eq!(std::fs::read(src.join("Trip-001.png")).unwrap(), b"not ours", "never overwritten");
assert!(src.join("Trip-001-1.xmp").is_file() && !src.join("a.xmp").exists(), "sidecar moved");
assert_eq!(path_of(&s, copy), path_of(&s, ids[0]), "the virtual copy follows");
assert_eq!(s.catalog.photo(lightcraft_catalog::PhotoId(ids[1])).unwrap().file_name, "Trip-002.png");
// undo moves the files back; redo renames again
s.execute("edit.undo", &json!({})).unwrap();
assert!(src.join("a.png").is_file() && src.join("b.png").is_file() && src.join("a.xmp").is_file());
assert_eq!(path_of(&s, ids[0]), src.join("a.png").to_string_lossy());
s.execute("edit.redo", &json!({})).unwrap();
assert!(src.join("Trip-002.png").is_file() && !src.join("b.png").exists());
// collisions inside the batch
let r = s.execute("photo.rename", &json!({"ids": [ids[0], ids[1]], "template": "same"})).unwrap();
assert_eq!(r["plans"][0]["to"], "same.png");
assert_eq!(r["plans"][1]["to"], "same-1.png");
// a failing move rolls the whole batch back and changes nothing
std::fs::remove_file(src.join("same-1.png")).unwrap();
let before = s.catalog.to_snapshot();
assert!(s.execute("photo.rename", &json!({"ids": [ids[0], ids[1]], "template": "x-{seq}"})).is_err());
assert!(src.join("same.png").is_file() && !src.join("x-1.png").exists(), "rolled back");
assert_eq!(s.catalog.to_snapshot(), before);
// the op log replays the renames
let expect = s.catalog.to_snapshot();
drop(s);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!(s.catalog.to_snapshot(), expect);
assert_eq!(path_of(&s, ids[0]), src.join("same.png").to_string_lossy());
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
/// Demo photos (no file) rename in the catalog only.
#[test]
fn rename_demo_photos_in_catalog() {
let mut s = Session::with_demo();
let ids: Vec<u64> = s.visible_cloned().iter().take(3).map(|p| p.0).collect();
let r = s.execute("photo.rename", &json!({"ids": ids, "template": "{date:%Y-%m-%d}_{seq:2}", "start": 7})).unwrap();
assert_eq!(r["renamed"], 3);
let name = &s.catalog.photo(lightcraft_catalog::PhotoId(ids[0])).unwrap().file_name;
assert!(name.ends_with("_07.jpg") || name.contains("_07."), "{name}");
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.catalog.photo(lightcraft_catalog::PhotoId(ids[0])).unwrap().file_name.starts_with("LC"));
}
fn captured(s: &Session, id: u64) -> Option<String> {
s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().captured.clone()
}
/// Edit Capture Time: set (others shift along), set each, shift by an offset, time-zone shift;
/// one undo step each; replayed from the op log; the grid order follows.
#[test]
fn capture_time_set_shift_undo_and_replay() {
let dir = temp_dir("capture");
let mut s = Session::new();
s.open_library(&dir, true).unwrap();
let ids: Vec<u64> = s.visible_cloned().iter().take(3).map(|p| p.0).collect();
let before: Vec<Option<String>> = ids.iter().map(|i| captured(&s, *i)).collect();
let secs = |t: &Option<String>| lightcraft_catalog::dates::iso_seconds(t.as_deref().unwrap()).unwrap();
// set the active photo; the others keep their distance to it
s.execute("library.select", &json!({"ids": ids, "active": ids[1]})).unwrap();
let r = s.execute("photo.setCaptureTime", &json!({"time": "2020-01-02 03:04:05"})).unwrap();
assert_eq!(r["changed"], 3, "{r}");
assert_eq!(captured(&s, ids[1]).as_deref(), Some("2020-01-02T03:04:05"));
assert_eq!(secs(&captured(&s, ids[0])) - secs(&captured(&s, ids[1])), secs(&before[0]) - secs(&before[1]));
// undo restores all three in one step
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ids.iter().map(|i| captured(&s, *i)).collect::<Vec<_>>(), before);
// shift by +1 h 30 min, then a time-zone shift of −2 h
s.execute("photo.setCaptureTime", &json!({"shift": 5400})).unwrap();
s.execute("photo.setCaptureTime", &json!({"hours": -2})).unwrap();
for (i, id) in ids.iter().enumerate() {
assert_eq!(secs(&captured(&s, *id)) - secs(&before[i]), 5400 - 7200);
}
// everybody the same time
s.execute("photo.setCaptureTime", &json!({"time": "2026-10-01T08:00:00", "each": true})).unwrap();
assert!(ids.iter().all(|i| captured(&s, *i).as_deref() == Some("2026-10-01T08:00:00")));
// the grid order (capture date, newest first) follows: they're now the newest photos
let vis = s.visible_cloned();
assert!(vis[..3].iter().all(|v| ids.contains(&v.0)), "{vis:?}");
// invalid dates are refused
assert!(s.execute("photo.setCaptureTime", &json!({"time": "2026-02-30 10:00"})).is_err());
assert!(s.execute("photo.setCaptureTime", &json!({"time": "yesterday"})).is_err());
let expect = s.catalog.to_snapshot();
drop(s);
let mut s = Session::new();
s.open_library(&dir, false).unwrap();
assert_eq!(s.catalog.to_snapshot(), expect);
let _ = std::fs::remove_dir_all(&dir);
}
/// Colour label names: one undo step, journaled; a colour's own name or empty resets it.
#[test]
fn label_names_set_undo_and_replay() {
let dir = temp_dir("labels");
let mut s = Session::new();
s.open_library(&dir, true).unwrap();
let r = s.execute("label.setNames", &json!({"names": {"red": "Reject later", "green": "Approved", "blue": " "}})).unwrap();
assert_eq!(r["changed"], 2);
let names = s.execute("label.names", &json!({})).unwrap();
assert_eq!(names[0], json!({"label": "red", "name": "Reject later", "custom": "Reject later"}));
assert_eq!(names[3]["name"], "Blue");
assert_eq!(names[3]["custom"], json!(null));
s.execute("label.setNames", &json!({"names": {"red": "red"}})).unwrap();
assert_eq!(s.catalog.label_name(lightcraft_catalog::ColorLabel::Red), "Red", "the colour's own name resets");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.label_name(lightcraft_catalog::ColorLabel::Red), "Reject later");
assert!(s.execute("label.setNames", &json!({"names": {"orange": "x"}})).is_err());
let expect = s.catalog.to_snapshot();
drop(s);
let mut s = Session::new();
s.open_library(&dir, false).unwrap();
assert_eq!(s.catalog.to_snapshot(), expect);
assert_eq!(s.catalog.label_name(lightcraft_catalog::ColorLabel::Green), "Approved");
let _ = std::fs::remove_dir_all(&dir);
}
/// Import review: `library.importPreview` lists candidates with duplicates marked (nothing is
/// added); `library.import` takes the checked files with preset, keywords and a new album as one
/// undo step, and copies into the dated library folders.
#[test]
fn import_review_preview_and_options() {
let src = temp_dir("imp-src");
let lib = temp_dir("imp-lib");
write_png(&src.join("a.png"), 1);
write_png(&src.join("sub/b.png"), 2);
write_png(&src.join("sub/c.png"), 3);
std::fs::copy(src.join("a.png"), src.join("sub/a-again.png")).unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
// c.png is already in the library
s.execute("library.import", &json!({"paths": [src.join("sub").join("c.png").to_string_lossy()]})).unwrap();
let n0 = s.catalog.len();
let undo0 = s.undo.len();
let pv = s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(pv["scanned"], 4, "{pv}");
assert_eq!(pv["duplicates"], 2, "{pv}");
assert_eq!(s.catalog.len(), n0, "a preview adds nothing");
assert_eq!(s.undo.len(), undo0);
let cands = pv["candidates"].as_array().unwrap();
let dup = |name: &str| cands.iter().find(|c| c["name"] == name).unwrap()["duplicate"].clone();
assert_eq!(dup("c.png"), "path");
assert_eq!(dup("a-again.png"), "content");
assert_eq!(dup("a.png"), json!(null));
assert_eq!(cands[0]["width"], 24);
// the dialog imports the checked (non-duplicate) files with its options
let preset = s.presets.iter().find(|p| p.builtin).unwrap().id.clone();
let checked: Vec<String> = cands.iter().filter(|c| c["duplicate"].is_null()).map(|c| c["path"].as_str().unwrap().to_string()).collect();
let r = s
.execute(
"library.import",
&json!({"paths": checked, "mode": "copy", "preset": preset, "keywords": ["trip", "family|kids"], "albumName": "Imported Trip"}),
)
.unwrap();
assert_eq!(r["imported"].as_array().unwrap().len(), 2, "{r}");
let album = lightcraft_catalog::AlbumId(r["album"].as_u64().unwrap());
assert_eq!(s.catalog.album(album).unwrap().name, "Imported Trip");
assert_eq!(s.catalog.album_count(album), 2);
for id in r["imported"].as_array().unwrap() {
let p = s.catalog.photo(lightcraft_catalog::PhotoId(id.as_u64().unwrap())).unwrap();
assert_eq!(p.meta.keywords, ["trip", "family|kids"]);
assert!(p.history.last().unwrap().label.starts_with("Preset: "), "preset applied with a History entry");
let lightcraft_catalog::Source::File { path } = &p.source else { panic!() };
assert!(std::path::Path::new(path).starts_with(lib.join("Originals")), "{path}");
}
assert_eq!(s.undo.len(), undo0 + 1, "import + album = one undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.len(), n0);
assert!(s.catalog.album(album).is_none());
assert!(s.execute("library.import", &json!({"paths": ["/nope"], "preset": "no-such"})).is_err());
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
/// Label sets: built-ins, the one in use, saving (persisted with the library's prefs) and the
/// names written to / read from XMP.
#[test]
fn label_sets_and_xmp_label_names() {
use lightcraft_catalog::ColorLabel;
let dir = std::env::temp_dir().join(format!("lc-labelsets-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::new();
s.open_library(&dir, true).unwrap();
let sets = s.execute("label.sets", &json!({})).unwrap();
assert_eq!(sets["current"], "Colors");
s.execute("label.applySet", &json!({"name": "review"})).unwrap();
assert_eq!(s.catalog.label_name(ColorLabel::Green), "Approved");
assert_eq!(s.execute("label.sets", &json!({})).unwrap()["current"], "Review");
// tweak one name, save as a user set; built-in names are refused
s.execute("label.setNames", &json!({"names": {"purple": "Client"}})).unwrap();
assert_eq!(s.execute("label.sets", &json!({})).unwrap()["current"], json!(null));
assert!(s.execute("label.saveSet", &json!({"name": "Colors"})).is_err());
s.execute("label.saveSet", &json!({"name": "Studio"})).unwrap();
s.execute("label.applySet", &json!({"name": "Colors"})).unwrap();
assert_eq!(s.catalog.custom_label_name(ColorLabel::Green), None);
drop(s);
let mut s = Session::new();
s.open_library(&dir, false).unwrap();
s.execute("label.applySet", &json!({"name": "studio"})).unwrap();
assert_eq!(s.catalog.label_name(ColorLabel::Purple), "Client");
// XMP: the label's name is written, and read back through the names
let mut p = lightcraft_catalog::Photo::new(
lightcraft_catalog::PhotoId(1),
lightcraft_catalog::Source::Demo { scene: 0 },
"a.jpg",
"JPEG",
4,
4,
"2026-01-01T00:00:00",
);
p.label = Some(ColorLabel::Green);
let x = crate::sidecar::sidecar_packet(&p, &s.catalog);
assert!(x.contains("Approved"), "{x}");
let sc = crate::sidecar::parse_sidecar(&x, crate::crs::Target::Rendered).unwrap().resolve_label(&s.catalog);
assert_eq!(sc.label, Some(Some(ColorLabel::Green)));
let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), crate::crs::Target::Rendered).unwrap().resolve_label(&s.catalog);
assert_eq!(plain.label, Some(Some(ColorLabel::Blue)), "colour names still work");
s.execute("label.deleteSet", &json!({"name": "Studio"})).unwrap();
assert!(s.execute("label.deleteSet", &json!({"name": "Review"})).is_err(), "built-ins stay");
let _ = std::fs::remove_dir_all(&dir);
}
/// Keyword sets: Recent Keywords fill as keywords are added; ⌥N toggles keyword N on the
/// selection (added unless every photo has it) without reshuffling the numbers; user sets.
#[test]
fn keyword_sets_and_recent_keywords() {
let mut s = Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap();
s.execute("photo.setMeta", &json!({"addKeywords": ["beach", "sunset"]})).unwrap();
s.execute("photo.setMeta", &json!({"addKeywords": ["family"]})).unwrap();
let sets = s.execute("keyword.sets", &json!({})).unwrap();
assert_eq!(sets["current"], "Recent Keywords");
assert_eq!(sets["keywords"], json!(["family", "beach", "sunset"]), "newest first");
// ⌥3 on both photos: added to the one without it, so both have it
s.execute("library.select", &json!({"ids": ids})).unwrap();
let r = s.execute("keyword.toggleFromSet", &json!({"index": 3})).unwrap();
assert_eq!((r["keyword"].as_str(), r["added"].as_bool()), (Some("sunset"), Some(true)));
let has = |s: &Session, id: u64, k: &str| s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.iter().any(|x| x == k);
assert!(has(&s, ids[0], "sunset") && has(&s, ids[1], "sunset"));
assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["keywords"][2], "sunset", "the numbers stay put");
// again: everyone has it, so it's removed
let r = s.execute("keyword.toggleFromSet", &json!({"index": 3})).unwrap();
assert_eq!(r["added"], false);
assert!(!has(&s, ids[0], "sunset") && !has(&s, ids[1], "sunset"));
assert_eq!(s.execute("keyword.toggleFromSet", &json!({"index": 9})).unwrap()["changed"], 0, "an empty slot does nothing");
assert!(s.execute("keyword.toggleFromSet", &json!({"index": 10})).is_err());
// a saved set becomes current; deleting it falls back to Recent Keywords
let r = s.execute("keyword.saveSet", &json!({"name": "Wedding", "keywords": ["ceremony", "rings", "first dance"]})).unwrap();
assert_eq!((r["current"].as_str(), r["keywords"][1].as_str()), (Some("Wedding"), Some("rings")));
s.execute("keyword.toggleFromSet", &json!({"index": 2})).unwrap();
assert!(has(&s, ids[1], "rings"));
assert!(s.execute("keyword.saveSet", &json!({"name": "Recent Keywords"})).is_err(), "reserved");
s.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap();
s.execute("keyword.useSet", &json!({"name": "wedding"})).unwrap();
assert_eq!(s.keyword_set.as_deref(), Some("Wedding"));
s.execute("keyword.deleteSet", &json!({"name": "Wedding"})).unwrap();
assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["current"], "Recent Keywords");
}
/// Auto-Tag from Tracklog: GPS positions from a GPX file by capture time — interpolated within a
/// segment, the camera's zone from the photo or `offset`; photos with GPS kept unless `replace`;
/// one undo step, replayed from the op log.
#[test]
fn tracklog_auto_tag() {
use lightcraft_catalog::{Op, PhotoId};
const GPX: &str = r#"<?xml version="1.0"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
<trkpt lat="46.0000" lon="7.0000"><time>2026-05-01T10:00:00Z</time></trkpt>
<trkpt lat="46.0010" lon="7.0020"><time>2026-05-01T10:01:00Z</time></trkpt>
<trkpt lat="46.0020" lon="7.0040"><time>2026-05-01T10:02:00Z</time></trkpt>
</trkseg></trk></gpx>"#;
let dir = temp_dir("tracklog");
let gpx_path = dir.join("walk.gpx");
std::fs::write(&gpx_path, GPX).unwrap();
let mut s = Session::new();
s.open_library(dir.join("lib"), true).unwrap();
let ids: Vec<u64> = s.visible_cloned().iter().take(4).map(|p| p.0).collect();
let set = |s: &mut Session, id: u64, t: &str| {
s.commit("t", Op::SetCaptured { id: PhotoId(id), captured: Some(t.into()) }).unwrap();
};
set(&mut s, ids[0], "2026-05-01T12:00:30"); // camera clock on +02:00, no zone recorded
set(&mut s, ids[1], "2026-05-01T12:01:00+02:00"); // zone recorded: exactly on the 2nd point
set(&mut s, ids[2], "2026-05-01T15:00:00"); // hours after the log ends
set(&mut s, ids[3], "2026-05-01T12:02:00");
s.execute("photo.setMeta", &json!({"ids": [ids[3]], "gps": [10.0, 20.0]})).unwrap();
let gps = |s: &Session, id: u64| s.catalog.photo(PhotoId(id)).unwrap().meta.gps;
let before: Vec<_> = ids.iter().map(|i| gps(&s, *i)).collect();
let path = gpx_path.to_string_lossy().to_string();
// a dry run reports without changing anything
let r = s.execute("photo.autoTagTracklog", &json!({"path": path, "ids": ids, "offset": "+02:00", "dryRun": true})).unwrap();
assert_eq!((r["tagged"].as_u64(), r["interpolated"].as_u64()), (Some(2), Some(2)), "{r}");
assert_eq!(r["skipped"], json!({"noTime": 0, "outside": 1, "hasGps": 1}));
assert_eq!((r["points"].as_u64(), r["start"].as_str()), (Some(3), Some("2026-05-01T10:00:00Z")));
assert_eq!(ids.iter().map(|i| gps(&s, *i)).collect::<Vec<_>>(), before);
// tag: one undo step
let undo_depth = s.undo.len();
s.execute("photo.autoTagTracklog", &json!({"path": path, "ids": ids, "offset": 2})).unwrap();
assert_eq!(s.undo.len(), undo_depth + 1);
assert_eq!(gps(&s, ids[0]), Some((46.0005, 7.001)), "interpolated half-way between the first two points");
assert_eq!(gps(&s, ids[1]), Some((46.001, 7.002)));
assert_eq!(gps(&s, ids[2]), None);
assert_eq!(gps(&s, ids[3]), Some((10.0, 20.0)), "existing GPS kept without `replace`");
// without an offset, zone-less capture times count as UTC (and are reported)
let r = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[0]], "replace": true, "dryRun": true})).unwrap();
assert_eq!((r["tagged"].as_u64(), r["assumedUtc"].as_u64()), (Some(0), Some(1)), "{r}");
// replace: the photo that had GPS gets the track's position (its last point, 12:02 local)
s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[3]], "offset": "+02:00", "replace": true})).unwrap();
assert_eq!(gps(&s, ids[3]), Some((46.002, 7.004)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(gps(&s, ids[3]), Some((10.0, 20.0)));
// the op log replays the tags
let expect = s.catalog.to_snapshot();
drop(s);
let mut s = Session::new();
s.open_library(dir.join("lib"), false).unwrap();
assert_eq!(s.catalog.to_snapshot(), expect);
// errors: no file, not GPX, no timed points, bad offset
assert!(s.execute("photo.autoTagTracklog", &json!({"ids": [ids[0]]})).is_err());
assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": "<kml/>", "ids": [ids[0]]})).is_err());
assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": "<gpx><trk><trkseg/></trk></gpx>", "ids": [ids[0]]})).is_err());
assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[0]], "offset": "noon"})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
// ---- random sort: Given the library, When the user picks Random, Then the grid is a stable shuffle
#[test]
fn random_sort_is_stable_until_reshuffled() {
use lightcraft_catalog::SortKey;
let mut s = Session::with_demo();
let dated = s.visible_cloned();
s.execute("library.sort", &json!({"key": "random", "seed": 11})).unwrap();
assert_eq!((s.sort.key, s.sort.seed), (SortKey::Random, 11));
let a = s.visible_cloned();
assert_ne!(a, dated);
let mut sorted = a.clone();
sorted.sort();
let mut all = dated.clone();
all.sort();
assert_eq!(sorted, all, "a permutation of the same photos");
// an unrelated catalog change must not move anything
let first = a[0];
s.execute("photo.rate", &json!({"rating": 4, "ids": [first.0]})).unwrap();
assert_eq!(s.visible_cloned(), a, "same seed, same grid after an edit");
assert_eq!(s.execute("library.groups", &json!({})).unwrap(), json!([]), "no date headers for a shuffle");
// Reshuffle picks a new seed (and works even with a frozen clock)
s.execute("library.shuffle", &json!({})).unwrap();
let seed1 = s.sort.seed;
assert_eq!(s.sort.key, SortKey::Random);
assert_ne!(seed1, 11);
assert_ne!(s.visible_cloned(), a);
s.execute("library.shuffle", &json!({"seed": null})).unwrap();
assert_ne!(s.sort.seed, seed1, "every reshuffle is new, and a null seed means none given");
assert!(s.sort.seed < 1 << 53, "seeds survive a round trip through a JSON double");
// an explicit seed is reproducible
s.execute("library.shuffle", &json!({"seed": 11})).unwrap();
assert_eq!(s.visible_cloned(), a);
}
#[test]
fn random_sort_rejects_bad_params() {
let mut s = Session::with_demo();
let before = s.sort;
assert!(s.execute("library.sort", &json!({"seed": "x"})).is_err());
assert!(s.execute("library.sort", &json!({"seed": -1})).is_err());
assert!(s.execute("library.shuffle", &json!({"seed": 1.5})).is_err());
assert!(s.execute("library.sort", &json!({"seed": 1u64 << 53})).is_err(), "beyond what a JSON double holds exactly");
assert!(s.execute("library.sort", &json!({"seed": (1u64 << 53) - 1})).is_ok());
assert_eq!(s.sort.key, before.key, "a rejected command leaves the sort alone");
}
#[test]
fn random_sort_applies_to_albums_too() {
let mut s = Session::with_demo();
s.execute("library.sort", &json!({"key": "random", "seed": 5})).unwrap();
let all = s.visible_cloned();
// manual order shows newest-first (descending), i.e. the reverse of this insertion order, so it
// differs from the shuffle unless Random really overrides it
let ids: Vec<u64> = all.iter().take(12).map(|i| i.0).collect();
let album = s.execute("album.create", &json!({"name": "Shuffled"})).unwrap()["id"].as_u64().unwrap();
s.execute("album.addPhotos", &json!({"id": album, "ids": ids})).unwrap();
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
let in_album = s.visible_cloned();
assert_eq!(in_album.len(), 12);
let expect: Vec<_> = all.iter().filter(|i| in_album.contains(i)).copied().collect();
assert_eq!(in_album, expect, "the album shows its photos in the shuffle's order, not manual order");
}
/// Choosing Random from another sort is a fresh shuffle (not the fixed seed-0 order); picking it
/// again, or changing only the direction, keeps the shuffle on screen; an explicit seed wins.
#[test]
fn choosing_random_starts_a_fresh_shuffle() {
let mut s = Session::with_demo();
assert_eq!(s.sort.seed, 0);
s.execute("library.sort", &json!({"key": "random"})).unwrap();
let first = s.sort.seed;
assert_ne!(first, 0, "the first Random is not the fixed seed-0 order");
s.execute("library.sort", &json!({"key": "random"})).unwrap();
s.execute("library.sort", &json!({"ascending": true})).unwrap();
assert_eq!(s.sort.seed, first, "already random: the shuffle stays");
s.execute("library.sort", &json!({"key": "fileName"})).unwrap();
s.execute("library.sort", &json!({"key": "random"})).unwrap();
assert_ne!(s.sort.seed, first, "coming back to Random reshuffles");
s.execute("library.sort", &json!({"key": "rating"})).unwrap();
s.execute("library.sort", &json!({"key": "random", "seed": 42})).unwrap();
assert_eq!(s.sort.seed, 42, "an explicit seed is honoured");
assert!(s.sort.seed < 1 << 53);
}
fn capture_time_raw_metadata_session(raw: Option<&str>) -> Session {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
let mut s = Session::new();
let id = PhotoId(1);
let mut p = Photo::new(id, Source::Demo { scene: 0 }, "one.jpg", "JPEG", 4, 3, "2026-04-01T00:00:00");
p.captured = raw.map(str::to_string);
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.selection = crate::Selection::single(id);
s
}
#[test]
fn capture_time_invalid_calendar_anchor_keeps_other_targets_and_redo() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
for raw in ["2026-02-30T12:00:00", "2026x03x01T12:00:00"] {
let mut s = capture_time_raw_metadata_session(Some(raw));
let peer = PhotoId(2);
let mut p = Photo::new(peer, Source::Demo { scene: 0 }, "peer.jpg", "JPEG", 4, 3, "2026-10-09T00:00:00");
p.captured = Some("2026-03-01T12:00:00.25+02:00".into());
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.commit("Earlier rating", Op::SetRating { id: peer, rating: 4 }).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.selection.ids = vec![PhotoId(1), peer];
s.selection.active = Some(PhotoId(1));
let before = s.catalog.clone();
let selection = s.selection.clone();
let undo: Vec<_> = s.undo.iter().map(|e| (e.label.clone(), e.op.clone())).collect();
let redo: Vec<_> = s.redo.iter().map(|e| (e.label.clone(), e.op.clone())).collect();
let log = s.pending_log.clone();
assert!(s.execute("photo.setCaptureTime", &json!({"time": "2026-03-03T12:00:00"})).is_err(), "{raw}");
assert_eq!(s.catalog, before);
assert_eq!(s.selection, selection);
assert_eq!(s.undo.iter().map(|e| (e.label.clone(), e.op.clone())).collect::<Vec<_>>(), undo);
assert_eq!(s.redo.iter().map(|e| (e.label.clone(), e.op.clone())).collect::<Vec<_>>(), redo);
assert_eq!(s.pending_log, log);
}
}