Files
lightcraft/crates/engine/src/tests_library.rs
T
Brandon ThomasandClaude Opus 5.5 bcecab7d84 M1.5: thumbnail/preview cache (disk + memory LRU, prioritized job pool)
- new crate lightcraft-preview (L3): 128-bit content hashing (SipHash-1-3),
  cost-bounded Lru, DiskCache (JPEG q90 files keyed by content hash +
  settings hash + size + RENDER_CACHE_VERSION; temp+rename writes; LRU prune
  by mtime to 80% of budget; corrupt files dropped), PreviewCache (memory
  LRU over disk), JobPool (per-slot dedupe, priorities, reprioritize/drop,
  inline mode for wasm).
- engine: Session::thumb_job buckets thumbnails to 128/256/384/512 and serves
  them from the cache without decoding the source; decoded thumbnail
  sources now in a 384 MB LRU (was unbounded); library attaches
  <library>/thumbs; library.info cache stats; library.clearPreviews.
- ui-egui: Renderer runs on JobPool; thumbnails scrolled out of the
  prefetch area are dropped from the queue; on-screen thumbnails get
  re-prioritized; ui.inspect reports in-flight renders + thumb textures.
- Demo library (24 photos): grid fully drawn 4.4 s cold -> 0.21 s warm
  (24/24 disk hits, no source decoded).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:51:47 -04:00

129 lines
5.3 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);
}
#[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);
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"]);
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);
}