//! Organizing commands: smart albums, stacks, virtual copies — through `Session::execute`, with //! undo and a persistent library (journal replay must reproduce the live state). use lightcraft_catalog::Flag; use serde_json::json; use crate::{LibrarySource, Session}; fn temp_dir(tag: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("lc-organize-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); d } fn open(dir: &std::path::Path) -> Session { let mut s = Session::new(); s.open_library(dir, true).unwrap(); s } #[test] fn smart_album_from_view_updates_live_and_persists() { let dir = temp_dir("smart"); let mut s = open(&dir); s.execute("library.filter", &json!({"rating": 4})).unwrap(); let want = s.visible_cloned(); assert!(!want.is_empty()); let r = s.execute("album.createSmart", &json!({"name": "Four plus"})).unwrap(); let id = r["id"].as_u64().unwrap(); assert_eq!(r["count"].as_u64().unwrap() as usize, want.len()); s.execute("library.clearFilter", &json!({})).unwrap(); s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap(); let mut got = s.visible_cloned(); got.sort(); let mut w = want.clone(); w.sort(); assert_eq!(got, w); // live: rating another photo 5 adds it; rating a member 1 removes it let other = s.catalog.photos().find(|p| p.rating < 4 && !p.deleted).unwrap().id; s.execute("photo.rate", &json!({"ids": [other.0], "rating": 5})).unwrap(); assert!(s.visible_cloned().contains(&other)); s.execute("photo.rate", &json!({"ids": [want[0].0], "rating": 1})).unwrap(); assert!(!s.visible_cloned().contains(&want[0])); // manual membership changes are refused assert!(s.execute("album.addPhotos", &json!({"id": id, "ids": [want[0].0]})).is_err()); // edit rules (merge), rename; undo the rename s.execute("album.setRules", &json!({"id": id, "rules": {"flag": "pick"}})).unwrap(); let rules = s.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().smart.clone().unwrap(); assert_eq!((rules.rating, rules.flag), (4, Some(Flag::Pick))); s.execute("album.rename", &json!({"id": id, "name": "Best picks"})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().name, "Four plus"); // rules via params: camera + lens + date range let r2 = s .execute("album.createSmart", &json!({"name": "Range", "rules": {"dateFrom": "2000-01-01", "dateTo": "2100", "lens": "", "edited": true}})) .unwrap(); let edited = s.catalog.photos().filter(|p| !p.deleted && p.is_edited()).count(); assert_eq!(r2["count"].as_u64().unwrap() as usize, edited); let listed = s.execute("albums.list", &json!({})).unwrap(); assert!(listed.to_string().contains("\"rulesText\""), "{listed}"); let expect = s.catalog.to_snapshot(); drop(s); // crash: replay the log let mut s2 = open(&dir); assert_eq!(s2.catalog.to_snapshot(), expect); // deleting the smart album s2.execute("album.delete", &json!({"id": id})).unwrap(); assert!(s2.catalog.album(lightcraft_catalog::AlbumId(id)).is_none()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn smart_album_from_smart_album_view_keeps_its_rules() { let mut s = Session::with_demo(); let id = s.execute("album.createSmart", &json!({"name": "Picks", "rules": {"flag": "pick"}})).unwrap()["id"].as_u64().unwrap(); s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap(); s.execute("library.filter", &json!({"rating": 3})).unwrap(); let r = s.view_rules(); assert_eq!((r.flag, r.rating, r.album), (Some(Flag::Pick), 3, None)); assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id))); } /// Saving the view of a smart album keeps the filter bar's settings even when the album holds a /// rule that no longer checks (it tests an album that has since been deleted). #[test] fn smart_album_view_keeps_the_filter_bar_over_a_stale_rule() { let mut s = Session::with_demo(); let gone = s.execute("album.create", &json!({"name": "Gone", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); let rules = json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": gone}]}}); let id = s.execute("album.createSmart", &json!({"name": "Not in Gone", "rules": rules})).unwrap()["id"].as_u64().unwrap(); s.execute("album.delete", &json!({"id": gone})).unwrap(); s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap(); s.execute("library.filter", &json!({"rating": 3})).unwrap(); let r = s.view_rules(); assert_eq!(r.rating, 3, "the filter bar's rating survives"); assert!(r.rule_set.is_some(), "and so do the album's own rules"); } #[test] fn virtual_copies_are_independent_and_persist() { let dir = temp_dir("vc"); let mut s = open(&dir); let src = s.visible_cloned()[1]; let album = s.execute("album.create", &json!({"name": "Keep", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); s.execute("album.addPhotos", &json!({"id": album, "ids": [src.0]})).unwrap(); s.execute("library.select", &json!({"ids": [src.0]})).unwrap(); s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap(); let undo_before = s.undo.len(); let r = s.execute("photo.virtualCopy", &json!({})).unwrap(); assert_eq!(s.undo.len(), undo_before + 1); let c1 = lightcraft_catalog::PhotoId(r["ids"][0].as_u64().unwrap()); let r2 = s.execute("photo.virtualCopy", &json!({"ids": [src.0]})).unwrap(); let c2 = lightcraft_catalog::PhotoId(r2["ids"][0].as_u64().unwrap()); let (p, a, b) = (s.catalog.photo(src).unwrap().clone(), s.catalog.photo(c1).unwrap().clone(), s.catalog.photo(c2).unwrap().clone()); assert_eq!((a.copy_of, a.copy_name.as_deref()), (Some(src), Some("Copy 1"))); assert_eq!(b.copy_name.as_deref(), Some("Copy 2")); assert_eq!(a.source, p.source, "shares the original file"); assert_eq!(a.develop.light.exposure, 0.5, "starts from the current settings"); // a copy of a copy names itself after the master let r3 = s.execute("photo.virtualCopy", &json!({"ids": [c1.0]})).unwrap(); let c3 = s.catalog.photo(lightcraft_catalog::PhotoId(r3["ids"][0].as_u64().unwrap())).unwrap().clone(); assert_eq!((c3.copy_of, c3.copy_name.as_deref()), (Some(src), Some("Copy 3"))); // independent settings s.execute("library.select", &json!({"ids": [c1.0]})).unwrap(); s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap(); assert_eq!(s.catalog.photo(src).unwrap().develop.light.exposure, 0.5); assert_eq!(s.catalog.photo(c1).unwrap().develop.light.exposure, -1.0); // same album, stacked (expanded) with the original, visible in the grid assert!(s.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.starts_with(&[src, c2, c1, c3.id])); assert_eq!(s.catalog.stack_of(c1).unwrap().photos, vec![src, c2, c1, c3.id]); assert!(s.visible_cloned().contains(&c2)); let found = s.execute("catalog.query", &json!({"filter": {"text": "copy:yes"}})).unwrap(); assert_eq!(found["total"], 3); // undo removes a copy completely s.execute("edit.undo", &json!({})).unwrap(); // exposure s.execute("edit.undo", &json!({})).unwrap(); // third copy assert!(s.catalog.photo(c3.id).is_none()); let expect = s.catalog.to_snapshot(); drop(s); let s2 = open(&dir); assert_eq!(s2.catalog.to_snapshot(), expect); let _ = std::fs::remove_dir_all(&dir); } /// Issue #1493: soft-delete must drop the copy from the stack so `1/2` does not linger, and /// deleting `2/3` renumbers the rest to `1/2` `2/2`. Undo restores the stack. #[test] fn deleting_a_virtual_copy_drops_it_from_the_stack() { let mut s = Session::with_demo(); let src = s.visible_cloned()[0]; s.execute("library.select", &json!({"ids": [src.0]})).unwrap(); let c1 = lightcraft_catalog::PhotoId(s.execute("photo.virtualCopy", &json!({})).unwrap()["ids"][0].as_u64().unwrap()); let c2 = lightcraft_catalog::PhotoId(s.execute("photo.virtualCopy", &json!({"ids": [src.0]})).unwrap()["ids"][0].as_u64().unwrap()); assert_eq!(s.catalog.stack_of(src).unwrap().photos, vec![src, c2, c1]); s.execute("photo.delete", &json!({"ids": [c2.0]})).unwrap(); let left = s.catalog.stack_of(src).expect("two members remain stacked"); assert_eq!(left.photos, vec![src, c1]); assert_eq!((left.position(src), left.position(c1), left.photos.len()), (Some(0), Some(1), 2), "1/2 and 2/2"); assert!(s.catalog.photo(c2).unwrap().deleted); s.execute("photo.delete", &json!({"ids": [c1.0]})).unwrap(); assert!(s.catalog.stack_of(src).is_none(), "the original is unstacked once it is the only member"); assert!(s.catalog.photo(c1).unwrap().deleted); s.execute("edit.undo", &json!({})).unwrap(); assert!(!s.catalog.photo(c1).unwrap().deleted); assert_eq!(s.catalog.stack_of(src).unwrap().photos, vec![src, c1]); } #[test] fn stacks_group_collapse_and_replay() { let dir = temp_dir("stacks"); let mut s = open(&dir); let vis = s.visible_cloned(); let n = vis.len(); let (a, b, c) = (vis[2], vis[3], vis[4]); s.execute("library.select", &json!({"ids": [a.0, b.0, c.0], "active": b.0})).unwrap(); let r = s.execute("stack.group", &json!({})).unwrap(); assert_eq!(r["count"], 3); // collapsed by default: the top (the active photo) stands for the stack let v = s.visible_cloned(); assert_eq!(v.len(), n - 2); assert!(v.contains(&b) && !v.contains(&a) && !v.contains(&c)); assert_eq!(s.selection.active, Some(b)); let info = s.execute("photo.inspect", &json!({"id": a.0})).unwrap(); assert_eq!(info["stack"]["photos"], json!([b.0, a.0, c.0])); // expand: members follow the top s.execute("stack.toggle", &json!({})).unwrap(); let v = s.visible_cloned(); let i = v.iter().position(|x| *x == b).unwrap(); assert_eq!(&v[i..i + 3], &[b, a, c]); // set top, collapse with a member active → active moves to the visible top s.execute("library.select", &json!({"ids": [c.0]})).unwrap(); s.execute("stack.setTop", &json!({})).unwrap(); s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); s.execute("stack.toggle", &json!({})).unwrap(); assert_eq!(s.selection.active, Some(c)); // remove from stack; undo s.execute("stack.remove", &json!({"ids": [a.0]})).unwrap(); assert_eq!(s.catalog.stack_of(c).unwrap().photos, vec![c, b]); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.stack_of(c).unwrap().photos.len(), 3); // auto-stack the rest of the view: a huge gap groups everything unstacked let pre = s.execute("stack.auto", &json!({"gap": 1e9, "preview": true})).unwrap(); assert_eq!(pre["stacks"], 1); assert_eq!(s.catalog.stacks().count(), 1, "preview changes nothing"); s.execute("stack.auto", &json!({"gap": 1e9})).unwrap(); assert_eq!(s.catalog.stacks().count(), 2); assert_eq!(s.visible_cloned().len(), 2); s.execute("stack.expandAll", &json!({})).unwrap(); assert_eq!(s.visible_cloned().len(), n); let expect = s.catalog.to_snapshot(); drop(s); let mut s2 = open(&dir); assert_eq!(s2.catalog.to_snapshot(), expect); s2.execute("library.select", &json!({"ids": [b.0]})).unwrap(); s2.execute("stack.ungroup", &json!({})).unwrap(); assert_eq!(s2.catalog.stacks().count(), 1); let _ = std::fs::remove_dir_all(&dir); } #[test] fn album_source_reports_in_library_state_and_persists_the_view() { // An album source made `library.state` panic (serde can't put an integer in an internally // tagged enum) and `view.json` empty, so the view was lost on the next launch. let dir = temp_dir("album-view"); let mut s = open(&dir); let id = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap(); s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap(); s.execute("library.sort", &json!({"key": "fileName"})).unwrap(); let st = s.execute("library.state", &json!({})).unwrap(); assert_eq!(st["source"], json!({"kind": "album", "id": id})); // the reported source is what `library.source` takes s.execute("library.source", &json!({"kind": "all"})).unwrap(); s.execute("library.source", &st["source"]).unwrap(); assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id))); assert_eq!(s.execute("library.state", &json!({})).unwrap()["source"], json!({"kind": "album", "id": id})); s.close_library().unwrap(); drop(s); let s2 = open(&dir); assert_eq!(s2.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id))); assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored too"); let _ = std::fs::remove_dir_all(&dir); } /// A library opens unfiltered: the source and sort come back, a filter from the last session does not /// (it would hide photos behind a small badge). #[test] fn filters_are_not_restored_when_a_library_reopens() { let dir = temp_dir("filter-not-restored"); let mut s = open(&dir); let all = s.visible_cloned().len(); s.execute("library.sort", &json!({"key": "fileName"})).unwrap(); s.execute("library.filter", &json!({"rating": 5, "date": "2026-01-16", "person": "Jane Doe"})).unwrap(); assert!(s.visible_cloned().len() < all); s.close_library().unwrap(); drop(s); let mut s2 = open(&dir); assert_eq!(s2.filter, lightcraft_catalog::Filter::default()); assert_eq!(s2.visible_cloned().len(), all); assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn filter_chips_clear_one_filter_and_total_ignores_the_filter() { let dir = temp_dir("chips"); let mut s = open(&dir); let all = s.visible_cloned().len(); assert!(all > 1); assert_eq!(s.source_total(), Some(all)); s.execute("library.filter", &json!({"rating": 4, "date": "2026-01-16", "text": "x"})).unwrap(); // the total is the source's, whatever the filter hides assert_eq!(s.source_total(), Some(all)); let chips = crate::filter_chips(&s.filter, &s.catalog); let labels: Vec<&str> = chips.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, ["Search: “x”", "Rating ≥ 4★", "Date: January 16, 2026"]); // clearing the date chip leaves the other two filters alone s.execute("library.filter", &chips[2].clear).unwrap(); assert_eq!(s.filter.date, None); assert_eq!((s.filter.rating, s.filter.text.as_str()), (4, "x")); for c in crate::filter_chips(&s.filter, &s.catalog) { s.execute("library.filter", &c.clear).unwrap(); } assert_eq!(s.filter, Default::default()); } /// A shuffle is part of the saved view: the same seed (and so the same grid) is back on the next launch. #[test] fn random_sort_persists_with_the_view() { let dir = temp_dir("random-view"); let mut s = open(&dir); s.execute("library.sort", &json!({"key": "random", "seed": 123_456_789})).unwrap(); s.close_library().unwrap(); drop(s); let s2 = open(&dir); assert_eq!(s2.sort.key, lightcraft_catalog::SortKey::Random); assert_eq!(s2.sort.seed, 123_456_789); let _ = std::fs::remove_dir_all(&dir); } /// Issue #501: deleting a populated folder is one reversible, durable catalog edit. #[test] fn deleting_an_album_folder_preserves_photos_and_restores_its_subtree() { use lightcraft_catalog::AlbumId; let dir = temp_dir("delete-folder"); let mut s = open(&dir); let make = |s: &mut Session, p| s.execute("album.create", &p).unwrap()["id"].as_u64().unwrap(); let outer = make(&mut s, json!({"name": "Trips", "folder": true})); let nested = make(&mut s, json!({"name": "Europe", "folder": true, "parent": outer})); let album = make(&mut s, json!({"name": "Best", "parent": nested, "addSelected": true})); let sibling = make(&mut s, json!({"name": "Kept", "addSelected": true})); let smart = s.execute("album.createSmart", &json!({"name": "Rated", "parent": outer, "rules": {"rating": 4}})).unwrap()["id"].as_u64().unwrap(); s.execute("album.reorder", &json!({"id": album, "parent": nested})).unwrap(); s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); let before = s.catalog.to_snapshot(); let photos = serde_json::to_value(s.catalog.photos().collect::>()).unwrap(); let undo_before = s.undo.len(); s.execute("album.delete", &json!({"id": outer})).unwrap(); for id in [outer, nested, album, smart] { assert!(s.catalog.album(AlbumId(id)).is_none()); } assert!(s.catalog.album(AlbumId(sibling)).is_some()); assert_eq!(serde_json::to_value(s.catalog.photos().collect::>()).unwrap(), photos); assert_eq!(s.source, LibrarySource::All, "a deleted descendant cannot remain the source"); assert_eq!(s.undo.len(), undo_before + 1); let deleted = s.catalog.to_snapshot(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.to_snapshot(), before, "all album fields and memberships restored"); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.catalog.to_snapshot(), deleted); let undo_after = s.undo.len(); assert!(s.execute("album.delete", &json!({"id": outer})).is_err()); assert_eq!(s.catalog.to_snapshot(), deleted, "missing id leaves the catalog unchanged"); assert_eq!(s.undo.len(), undo_after); drop(s); let reopened = open(&dir); assert_eq!(reopened.catalog.to_snapshot(), deleted, "journal replay preserves the deletion"); drop(reopened); let _ = std::fs::remove_dir_all(dir); } /// A huge `start` is a validation error, not an arithmetic overflow in the numbering. #[test] fn rename_huge_start_is_a_bad_param() { let mut s = Session::with_demo(); let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); let names_before: Vec = s.catalog.photos().map(|p| p.file_name.clone()).collect(); for cmd in ["photo.renamePreview", "photo.rename"] { let e = s.execute(cmd, &json!({"template": "{seq}", "start": u64::MAX, "ids": ids})).unwrap_err(); assert!(e.to_string().contains("start out of range"), "{cmd}: {e}"); } let names_after: Vec = s.catalog.photos().map(|p| p.file_name.clone()).collect(); assert_eq!(names_before, names_after, "a rejected rename changes nothing"); // the last start whose whole sequence fits is accepted, one more is not let fits = usize::MAX as u64 - 2; let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits, "ids": ids})); assert!(r.is_ok(), "{r:?}"); let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits + 1, "ids": ids})); assert!(r.is_err(), "{r:?}"); } #[test] fn stack_toggle_by_ids_leaves_the_selection_alone() { let mut s = Session::with_demo(); let vis = s.visible_cloned(); let (loose, top, member) = (vis[0], vis[1], vis[2]); s.execute("library.select", &json!({"ids": [top.0, member.0], "active": top.0})).unwrap(); s.execute("stack.group", &json!({})).unwrap(); // a loose photo is selected: the stack is named by `ids` (a badge click), not selected s.execute("library.select", &json!({"ids": [loose.0]})).unwrap(); let r = s.execute("stack.toggle", &json!({"ids": [member.0]})).unwrap(); assert_eq!(r["changed"], 1, "{r}"); assert!(!s.catalog.stack_of(top).unwrap().collapsed); assert_eq!((s.selection.ids.clone(), s.selection.active), (vec![loose], Some(loose)), "the selection is untouched"); s.execute("stack.toggle", &json!({"ids": [top.0]})).unwrap(); assert!(s.catalog.stack_of(top).unwrap().collapsed); // naming only loose photos is refused without a commit, as is an unknown id let commits = s.commits(); assert!(s.execute("stack.toggle", &json!({"ids": [loose.0]})).is_err()); assert!(s.execute("stack.toggle", &json!({"ids": [999_999]})).is_err()); assert_eq!(s.commits(), commits); // without `ids` the selection still decides (a loose photo selected: not available) assert!(matches!(s.execute("stack.toggle", &json!({})), Err(crate::EngineError::Disabled(..)))); }