Files
lightcraft/crates/engine/src/tests_library.rs
T
Nathaniel LedfordandClaude Sonnet 5.5 5d13d59520 Folders: review fixes (smart albums, disk guards, deep chains, long titles)
- engine: a smart album made from a folder view keeps the folder; only a
  folder that shows exactly its own photos can be the source (not the
  startup disk, not a folder above disks, not one with no photos); a
  folder source and a folder filter never coexist; a folder source with no
  folder shows nothing; a saved view whose folder is gone opens on All
  Photos; removing a folder above a disk needs `disk: true` even with one
  disk mounted; library.folders documents volume/selectable
- ui: deep chains stop indenting after five levels, the grid title gives
  way to the photo count (full name on hover), disclosure triangles have
  spoken names, the remove dialog says what is included and has a disk
  title; test temp folders are cleaned up on failure

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 09:06:18 -04:00

443 lines
21 KiB
Rust

//! Persistent library: commands survive a restart (with and without a clean close).
use serde_json::json;
use crate::Session;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-engine-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn open(dir: &std::path::Path, seed: bool) -> Session {
let mut s = Session::new();
s.open_library(dir, seed).unwrap();
s
}
#[test]
fn edits_survive_restart_without_close() {
let dir = temp_dir("crash");
let mut s = open(&dir, true);
assert!(s.library.as_ref().unwrap().report.created);
assert!(s.catalog.len() > 5, "seeded demo");
let id = s.selection.active.unwrap();
s.execute("photo.rate", &json!({"rating": 5})).unwrap();
s.execute("photo.flag", &json!({"flag": "reject"})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.25})).unwrap();
// a slider drag: one undo step, logged once at the end
s.execute("develop.beginInteraction", &json!({"label": "Contrast"})).unwrap();
for v in [5, 10, 20, 33] {
s.execute("develop.set", &json!({"control": "light.contrast", "value": v})).unwrap();
}
s.execute("develop.endInteraction", &json!({})).unwrap();
s.execute("album.create", &json!({"name": "Keepers", "addSelected": true})).unwrap();
s.execute("preset.create", &json!({"name": "Bright"})).unwrap();
s.execute("edit.undo", &json!({})).ok();
s.execute("edit.redo", &json!({})).ok();
let expect = s.catalog.to_snapshot();
let info = s.execute("library.info", &json!({})).unwrap();
assert!(info["logRecords"].as_u64().unwrap() >= 5, "{info}");
drop(s); // no close: like a crash
let s2 = open(&dir, true);
let r = &s2.library.as_ref().unwrap().report;
assert!(!r.created && r.replayed >= 5, "{r:?}");
assert_eq!(s2.catalog.to_snapshot(), expect);
let p = s2.catalog.photo(id).unwrap();
assert_eq!((p.rating, p.develop.light.exposure, p.develop.light.contrast), (5, 1.25, 33.0));
assert!(s2.catalog.albums().any(|a| a.name == "Keepers" && a.photos == vec![id]));
assert!(s2.presets.iter().any(|p| p.name == "Bright" && !p.builtin));
let _ = std::fs::remove_dir_all(&dir);
}
/// Compaction at the threshold runs on a worker thread (issue #37): commands go on (and stay
/// durable) meanwhile, the frame loop's `persist_if_dirty` finishes it, and a crash at any time
/// keeps every command.
#[test]
fn threshold_compaction_runs_in_the_background() {
let dir = temp_dir("bg-compact");
let mut s = open(&dir, true);
s.library.as_mut().unwrap().journal_mut().policy = lightcraft_catalog::SnapshotPolicy { max_records: 8, max_bytes: u64::MAX };
let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect();
let mut background = 0;
for k in 0..60usize {
s.selection = crate::Selection::single(ids[k % ids.len()]);
s.execute("photo.rate", &json!({"rating": k % 6})).unwrap();
if s.execute("library.info", &json!({})).unwrap()["persistence"]["snapshotRunning"].as_bool().unwrap() {
background += 1;
}
if k == 30 {
// a crash while a compaction may be in flight
let expect = s.catalog.to_snapshot();
let copy = temp_dir("bg-compact-crash");
std::fs::create_dir_all(&copy).unwrap();
for f in ["catalog.snap", "catalog.log"] {
if dir.join(f).exists() {
std::fs::copy(dir.join(f), copy.join(f)).unwrap();
}
}
let s2 = open(&copy, true);
assert_eq!(s2.catalog.to_snapshot(), expect);
let _ = std::fs::remove_dir_all(&copy);
}
}
assert!(background > 0, "compactions ran in the background");
for _ in 0..10_000 {
s.persist_if_dirty();
if !s.library.as_ref().unwrap().journal().snapshot_running() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
let info = s.execute("library.info", &json!({})).unwrap();
let p = &info["persistence"];
assert!(p["snapshots"].as_u64() >= Some(2) && p["lastSnapshot"]["background"] == true, "{p}");
// the log holds exactly the records after the snapshot
assert_eq!(info["logRecords"].as_u64().unwrap(), info["seq"].as_u64().unwrap() - info["snapshotSeq"].as_u64().unwrap(), "{info}");
let expect = s.catalog.to_snapshot();
drop(s); // no close: like a crash
let s2 = open(&dir, true);
assert_eq!(s2.catalog.to_snapshot(), expect);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn close_writes_snapshot_and_view_state() {
let dir = temp_dir("close");
let mut s = open(&dir, true);
let ids = s.visible_cloned();
s.execute("library.select", &json!({"ids": [ids[3].0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
let expect = s.catalog.to_snapshot();
s.close_library().unwrap();
assert_eq!(std::fs::metadata(dir.join("catalog.log")).unwrap().len(), 0);
drop(s); // one session per library (issue #99)
let s2 = open(&dir, true);
let r = &s2.library.as_ref().unwrap().report;
assert_eq!(r.replayed, 0);
assert_eq!(s2.catalog.to_snapshot(), expect);
assert_eq!(s2.selection.active, Some(ids[3]));
let _ = std::fs::remove_dir_all(&dir);
}
/// Thumbnails rendered in one session are served from the disk cache in the next, without
/// loading the source; an edit changes the key.
#[test]
fn thumbnails_come_from_the_disk_cache_after_restart() {
let dir = temp_dir("thumbs");
let mut s = open(&dir, true);
let ids: Vec<_> = s.visible_cloned().into_iter().take(3).collect();
for id in &ids {
let job = s.thumb_job(*id, 230).unwrap();
assert_eq!(job.request.max_w, 256, "bucketed");
let r = job.run();
assert!(r.loaded.is_some(), "first render decodes the source");
s.accept(&r);
let img = r.rendered.unwrap().image;
assert_eq!(img.width.max(img.height), 256);
}
drop(s);
let mut s = open(&dir, true);
for id in &ids {
let r = s.thumb_job(*id, 240).unwrap().run();
assert!(r.loaded.is_none(), "served from cache");
let img = r.rendered.unwrap().image;
assert_eq!(img.width.max(img.height), 256);
}
let info = s.execute("library.info", &json!({})).unwrap();
assert_eq!(info["cache"]["disk"]["hits"], 3, "{info}");
// an edit is a new key: rendered (from the source), then cached
s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap();
let r = s.thumb_job(ids[0], 230).unwrap().run();
assert!(r.loaded.is_some());
// exact-size renders (loupe, before/after, exports) bypass the thumbnail cache
assert!(s.render_job(ids[0], 230, 230, true, true).unwrap().cache.is_none());
assert!(s.render_job(ids[0], 230, 230, false, true).unwrap().cache.is_none());
s.execute("library.clearPreviews", &json!({})).unwrap();
assert!(s.thumb_job(ids[1], 230).unwrap().run().loaded.is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn new_library_without_seed_is_empty_and_compacts() {
let dir = temp_dir("empty");
let mut s = open(&dir, false);
assert!(s.catalog.is_empty());
s.execute("album.create", &json!({"name": "A"})).unwrap();
s.execute("library.compact", &json!({})).unwrap();
let info = s.execute("library.info", &json!({})).unwrap();
assert_eq!(info["logRecords"], 0);
assert_eq!(info["snapshotSeq"], info["seq"]);
// persistence timings (issue #37): the append and the compaction were measured
let p = &info["persistence"];
assert!(p["appends"].as_u64() >= Some(1), "{p}");
assert!(p["snapshots"].as_u64() >= Some(1), "{p}");
assert!(p["lastSnapshot"]["bytes"].as_u64() > Some(0), "{p}");
assert!(p["lastSnapshot"]["totalMs"].as_f64() >= p["lastSnapshot"]["serializeMs"].as_f64(), "{p}");
drop(s); // one session per library (issue #99)
let s2 = open(&dir, true);
assert_eq!(s2.catalog.len(), 0, "an existing library is never seeded");
assert_eq!(s2.catalog.albums().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn profile_favorites_and_recent_survive_reopen() {
let dir = temp_dir("profiles");
let mut s = open(&dir, true);
s.execute("profile.favorite", &json!({"id": "lc.bw.sepia"})).unwrap();
s.execute("profile.favorite", &json!({"id": "lc.vivid"})).unwrap();
s.execute("profile.favorite", &json!({"id": "lc.vivid"})).unwrap(); // toggled off again
assert!(s.execute("profile.favorite", &json!({"id": "nope"})).is_err());
for id in ["lc.neutral", "lc.vivid", "lc.film.cool-fade", "lc.mono", "lc.cine.teal-amber", "lc.muted.bleached", "lc.vivid"] {
s.execute("develop.profile", &json!({"id": id})).unwrap();
}
let want_recent = ["lc.vivid", "lc.muted.bleached", "lc.cine.teal-amber", "lc.mono", "lc.film.cool-fade"];
assert_eq!(s.profile_recent, want_recent, "newest first, deduplicated, at most 5");
drop(s); // no clean close: every command persisted already
let mut s = open(&dir, false);
assert_eq!(s.profile_favorites, ["lc.bw.sepia"]);
assert_eq!(s.profile_recent, want_recent);
let menu = s.execute("profiles.menu", &json!({})).unwrap();
assert_eq!(menu["favorites"][0]["name"], "Sepia Tone");
assert_eq!(menu["recent"].as_array().unwrap().len(), 5);
assert_eq!(menu["groups"][0]["name"], "Basic");
let list = s.execute("profiles.list", &json!({})).unwrap();
assert!(list.as_array().unwrap().iter().any(|p| p["id"] == "lc.bw.sepia" && p["favorite"] == true));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_shown_library_folder_survives_reopen() {
let dir = temp_dir("libfolder");
let mut s = open(&dir, true);
let id = s.catalog.alloc_photo_id();
let p = lightcraft_catalog::Photo::new(
id,
lightcraft_catalog::Source::File { path: "/pics/trip/a.jpg".into() },
"a.jpg",
"JPEG",
60,
40,
"2026-01-01T10:00:00",
);
s.commit("Add", lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
s.execute("library.source", &json!({"kind": "libraryFolder", "path": "/pics/trip"})).unwrap();
s.save_view();
drop(s);
let s = open(&dir, false);
assert_eq!((s.source, s.library_folder.as_deref()), (crate::LibrarySource::LibraryFolder, Some("/pics/trip")));
drop(s);
// a folder that holds none of the library's photos any more opens on everything too
std::fs::write(dir.join("view.json"), br#"{"source": {"kind": "libraryFolder"}, "library_folder": "/no/such/folder"}"#).unwrap();
let s = open(&dir, false);
assert_eq!((s.source, s.library_folder.clone()), (crate::LibrarySource::All, None));
drop(s);
// a view file that names the source but no folder opens on everything, never an empty grid
std::fs::write(dir.join("view.json"), br#"{"source": {"kind": "libraryFolder"}}"#).unwrap();
let s = open(&dir, false);
assert_eq!((s.source, s.library_folder), (crate::LibrarySource::All, None));
let _ = std::fs::remove_dir_all(&dir);
}
/// Smart albums with a rule set: all / any / none, nested groups, validation, live updates.
#[test]
fn smart_album_rule_sets() {
let mut s = crate::Session::with_demo();
s.clock = Box::new(|| "2026-10-02T12:00:00".to_string());
let fields = s.execute("album.ruleFields", &serde_json::json!({})).unwrap();
assert!(fields.as_array().unwrap().iter().any(|f| f["field"] == "keywords" && f["ops"].as_array().unwrap().len() > 3));
let bad = s.execute(
"album.createSmart",
&serde_json::json!({"name": "Bad", "rules": {"ruleSet": {"rules": [{"field": "rating", "op": "contains", "value": 1}]}}}),
);
assert!(bad.is_err(), "operators are checked");
let r = s
.execute(
"album.createSmart",
&serde_json::json!({"name": "Good ones", "rules": {"ruleSet": {"match": "all", "rules": [
{"field": "rating", "op": "gte", "value": 4},
{"group": {"match": "none", "rules": [{"field": "flag", "op": "is", "value": "reject"}]}}
]}}}),
)
.unwrap();
let id = lightcraft_catalog::AlbumId(r["id"].as_u64().unwrap());
let want = s.catalog.photos().filter(|p| !p.deleted && p.rating >= 4 && p.flag != lightcraft_catalog::Flag::Reject).count();
assert!(want > 0);
assert_eq!(s.catalog.album_count(id), want);
// live: a new 5-star photo joins
let other = s.catalog.photos().find(|p| p.rating < 4 && !p.deleted).unwrap().id;
s.execute("photo.rate", &serde_json::json!({"ids": [other.0], "rating": 5})).unwrap();
assert_eq!(s.catalog.album_count(id), want + 1);
// the same rules work as a library filter (agents: library.filter {ruleSet})
s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "is", "value": 5}]}})).unwrap();
assert!(s.visible_cloned().iter().all(|id| s.catalog.photo(*id).unwrap().rating == 5));
let list = s.execute("albums.list", &serde_json::json!({})).unwrap();
let a = list.as_array().unwrap().iter().find(|a| a["name"] == "Good ones").unwrap().clone();
assert!(a.to_string().contains("rating is ≥ 4"), "{a}");
}
/// The filter bar's label multi-select: any of the chosen labels.
#[test]
fn filter_any_of_several_labels() {
use lightcraft_catalog::ColorLabel;
let mut s = crate::Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
for (id, l) in ids.iter().zip(["red", "yellow", "green"]) {
s.execute("photo.label", &serde_json::json!({"ids": [id], "label": l})).unwrap();
}
s.execute("library.filter", &serde_json::json!({"labels": ["red", "yellow"]})).unwrap();
let vis = s.visible_cloned();
let want = s.catalog.photos().filter(|p| p.in_library() && matches!(p.label, Some(ColorLabel::Red | ColorLabel::Yellow))).count();
assert_eq!(want, 2);
assert_eq!(vis.len(), want);
assert!(vis.iter().all(|id| matches!(s.catalog.photo(*id).unwrap().label, Some(ColorLabel::Red | ColorLabel::Yellow))));
assert!(s.filter.describe().contains("label red or yellow"));
s.execute("library.filter", &serde_json::json!({"labels": []})).unwrap();
assert!(s.visible_cloned().len() > want);
}
/// Quick Collection / target album: B toggles the selection in it (created on first use);
/// another album can be the target; clear empties it; all undoable.
#[test]
fn quick_collection_and_target_album() {
let mut s = crate::Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
assert!(s.catalog.quick_collection().is_none());
s.execute("library.select", &serde_json::json!({"ids": [ids[0], ids[1]]})).unwrap();
let r = s.execute("album.toggleTarget", &serde_json::json!({})).unwrap();
assert_eq!((r["added"].as_bool(), r["count"].as_u64(), r["name"].as_str()), (Some(true), Some(2), Some("Quick Collection")));
let quick = s.catalog.quick_collection().expect("created");
// again: both are in it, so they come out
assert_eq!(s.execute("album.toggleTarget", &serde_json::json!({})).unwrap()["count"], 0);
s.execute("edit.undo", &serde_json::json!({})).unwrap();
assert_eq!(s.catalog.album_count(quick), 2);
// a regular album as the target
let alb = s.execute("album.create", &serde_json::json!({"name": "Picks"})).unwrap()["id"].as_u64().unwrap();
s.execute("album.setTarget", &serde_json::json!({"id": alb})).unwrap();
s.execute("album.toggleTarget", &serde_json::json!({"ids": [ids[2]]})).unwrap();
assert_eq!(s.catalog.album_count(lightcraft_catalog::AlbumId(alb)), 1);
assert_eq!(s.catalog.album_count(quick), 2, "the Quick Collection is untouched");
let smart = s.execute("album.createSmart", &serde_json::json!({"name": "S", "rules": {"rating": 3}})).unwrap()["id"].as_u64().unwrap();
assert!(s.execute("album.setTarget", &serde_json::json!({"id": smart})).is_err());
s.execute("album.setTarget", &serde_json::json!({"id": null})).unwrap();
s.execute("album.clearQuick", &serde_json::json!({})).unwrap();
assert_eq!(s.catalog.album_count(quick), 0);
}
/// Scaling check (ignored: `cargo test --release -p lightcraft-engine -- --ignored scale --nocapture`):
/// the per-frame / per-click library queries on a 100k-photo catalog.
#[test]
#[ignore]
fn scale_100k_library_queries() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use std::time::Instant;
let mut s = crate::Session::new();
let n = 100_000u64;
let mut ops = Vec::with_capacity(n as usize);
for i in 0..n {
let mut p = Photo::new(
PhotoId(i + 1),
Source::Demo { scene: (i % 20) as u32 },
&format!("IMG_{i:06}.jpg"),
"JPEG",
6000,
4000,
"2026-01-01T00:00:00",
);
p.captured = Some(format!("20{:02}-{:02}-{:02}T{:02}:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28, i % 24));
p.rating = (i % 6) as u8;
p.meta.keywords = vec![format!("kw{}", i % 500), "travel|italy".into()];
p.meta.camera = format!("Model {}", i % 30);
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
let t = Instant::now();
s.commit("Add", Op::Batch { ops }).unwrap();
eprintln!("add 100k: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
let time = |what: &str, f: &mut dyn FnMut()| {
let t = Instant::now();
f();
let ms = t.elapsed().as_secs_f64() * 1e3;
eprintln!("{what}: {ms:.1} ms");
ms
};
let mut worst: Vec<(String, f64)> = Vec::new();
let mut m = |w: &str, f: &mut dyn FnMut()| worst.push((w.to_string(), time(w, f)));
m("visible (all)", &mut || {
let _ = s.visible_cloned();
});
m("visible (cached)", &mut || {
let _ = s.visible_cloned();
});
m("filter rating ≥ 4", &mut || {
s.execute("library.filter", &serde_json::json!({"rating": 4})).unwrap();
let _ = s.visible_cloned();
});
m("filter ruleSet keyword + date", &mut || {
s.execute("library.filter", &serde_json::json!({"rating": 0, "ruleSet": {"rules": [{"field": "keywords", "op": "contains", "value": "kw42"}, {"field": "captureDate", "op": "is", "value": "2015"}]}})).unwrap();
let _ = s.visible_cloned();
});
s.execute("library.filter", &serde_json::json!({"ruleSet": null})).unwrap();
m("date groups", &mut || {
let _ = s.execute("library.groups", &serde_json::json!({})).unwrap();
});
m("keyword tree", &mut || {
let _ = s.execute("keyword.list", &serde_json::json!({})).unwrap();
});
m("keyword suggestions", &mut || {
let _ = s.catalog.keyword_suggestions(&["kw1".to_string()], "kw", 12);
});
let alb = s.execute("album.createSmart", &serde_json::json!({"name": "S", "rules": {"rating": 5}})).unwrap()["id"].as_u64().unwrap();
m("smart album count", &mut || {
let _ = s.catalog.album_count(lightcraft_catalog::AlbumId(alb));
});
m("albums.list", &mut || {
let _ = s.execute("albums.list", &serde_json::json!({})).unwrap();
});
m("select all", &mut || {
s.execute("library.selectAll", &serde_json::json!({})).unwrap();
});
m("rate 100k selected", &mut || {
s.execute("photo.rate", &serde_json::json!({"rating": 3})).unwrap();
});
m("undo", &mut || {
s.execute("edit.undo", &serde_json::json!({})).unwrap();
});
worst.sort_by(|a, b| b.1.total_cmp(&a.1));
eprintln!("slowest: {:?}", &worst[..3]);
}
/// Issue #99: a library is open in one session at a time. A second opener is refused (nothing
/// read or written), the owning session can reopen it, and it is free again once closed.
#[test]
fn a_library_open_elsewhere_is_refused() {
let dir = temp_dir("locked");
let mut s = open(&dir, true);
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
let log = std::fs::read(dir.join("catalog.log")).unwrap();
let mut other = Session::new();
let e = other.open_library(&dir, true).unwrap_err();
assert!(matches!(e, crate::EngineError::LibraryInUse(_)), "{e:?}");
assert!(e.to_string().contains("already open in"), "{e}");
assert!(other.library.is_none());
assert_eq!(std::fs::read(dir.join("catalog.log")).unwrap(), log, "the refused opener wrote nothing");
// the session that has it open may reopen it (Settings → Open Library on the same folder)
s.close_library().unwrap();
s.open_library(&dir, true).unwrap();
assert!(other.open_library(&dir, true).is_err(), "still locked after reopening");
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
let expect = s.catalog.to_snapshot();
drop(s);
other.open_library(&dir, true).unwrap();
assert_eq!(other.catalog.to_snapshot(), expect);
let _ = std::fs::remove_dir_all(&dir);
}