mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 18:18:12 -06:00
Found while testing crates/catalog/src/query.rs:146-177 (token_matches) Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
895 lines
44 KiB
Rust
895 lines
44 KiB
Rust
use std::sync::Arc;
|
|
|
|
use lightcraft_develop::DevelopSettings;
|
|
|
|
use super::*;
|
|
|
|
fn photo(c: &mut Catalog, name: &str, date: &str) -> PhotoId {
|
|
let id = c.alloc_photo_id();
|
|
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
|
|
p.captured = Some(date.to_string());
|
|
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
|
id
|
|
}
|
|
|
|
#[test]
|
|
fn apply_and_inverse_roundtrip() {
|
|
let mut c = Catalog::new();
|
|
let a = photo(&mut c, "a.jpg", "2026-04-01T10:00:00");
|
|
let before = c.clone();
|
|
let ops = vec![
|
|
Op::SetRating { id: a, rating: 4 },
|
|
Op::SetFlag { id: a, flag: Flag::Pick },
|
|
Op::SetLabel { id: a, label: Some(ColorLabel::Red) },
|
|
Op::SetDevelop {
|
|
id: a,
|
|
settings: Arc::new(DevelopSettings { treatment: lightcraft_develop::Treatment::Bw, ..Default::default() }),
|
|
label: "B&W".into(),
|
|
edited: Some("x".into()),
|
|
},
|
|
];
|
|
let mut invs = Vec::new();
|
|
for op in ops {
|
|
invs.push(c.apply(op).unwrap());
|
|
}
|
|
assert_eq!(c.photo(a).unwrap().rating, 4);
|
|
for inv in invs.into_iter().rev() {
|
|
c.apply(inv).unwrap();
|
|
}
|
|
assert_eq!(c.photos().collect::<Vec<_>>(), before.photos().collect::<Vec<_>>());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_ops_change_nothing() {
|
|
let mut c = Catalog::new();
|
|
let a = photo(&mut c, "a.jpg", "2026-04-01");
|
|
let snap = c.to_snapshot();
|
|
assert!(c.apply(Op::SetRating { id: a, rating: 9 }).is_err());
|
|
assert!(c.apply(Op::SetRating { id: PhotoId(999), rating: 1 }).is_err());
|
|
// batch with a failing op rolls back
|
|
let r = c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }, Op::SetFlag { id: PhotoId(77), flag: Flag::Pick }] });
|
|
assert!(r.is_err());
|
|
assert_eq!(c.to_snapshot(), snap);
|
|
}
|
|
|
|
#[test]
|
|
fn albums_and_folders() {
|
|
let mut c = Catalog::new();
|
|
let a = photo(&mut c, "a.jpg", "2026-04-01");
|
|
let f = c.alloc_album_id();
|
|
c.apply(Op::AddAlbum {
|
|
album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false, order: None },
|
|
})
|
|
.unwrap();
|
|
let al = c.alloc_album_id();
|
|
c.apply(Op::AddAlbum {
|
|
album: Album {
|
|
id: al,
|
|
name: "Alps".into(),
|
|
parent: Some(f),
|
|
folder: false,
|
|
photos: vec![a],
|
|
cover: None,
|
|
smart: None,
|
|
quick: false,
|
|
order: None,
|
|
},
|
|
})
|
|
.unwrap();
|
|
assert_eq!(c.albums_of(a), vec![al]);
|
|
assert!(c.apply(Op::RemoveAlbum { id: f }).is_err(), "non-empty folder");
|
|
assert!(c.apply(Op::SetAlbumPhotos { id: f, photos: vec![a] }).is_err(), "folders hold no photos");
|
|
assert!(c.apply(Op::MoveAlbum { id: f, parent: Some(f) }).is_err());
|
|
let del = c.delete_permanently_ops(a);
|
|
let inv = c.apply(del).unwrap();
|
|
assert!(c.photo(a).is_none() && c.album(al).unwrap().photos.is_empty());
|
|
c.apply(inv).unwrap();
|
|
assert!(c.photo(a).is_some() && c.album(al).unwrap().photos == vec![a]);
|
|
}
|
|
|
|
#[test]
|
|
fn filter_search_sort() {
|
|
let mut c = Catalog::new();
|
|
let a = photo(&mut c, "beach.jpg", "2026-05-01T10:00:00");
|
|
let b = photo(&mut c, "alps.jpg", "2026-04-01T10:00:00");
|
|
let d = photo(&mut c, "city.jpg", "2025-12-24T10:00:00");
|
|
c.apply(Op::SetRating { id: b, rating: 5 }).unwrap();
|
|
c.apply(Op::SetFlag { id: d, flag: Flag::Reject }).unwrap();
|
|
let mut m = c.photo(a).unwrap().meta.clone();
|
|
m.keywords = vec!["ocean".into(), "summer".into()];
|
|
m.iso = Some(1600);
|
|
c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
|
|
|
|
let all = c.query(&Filter::default(), &Sort::default());
|
|
assert_eq!(all, vec![a, b, d], "newest first");
|
|
let asc = c.query(&Filter::default(), &Sort { key: SortKey::FileName, ascending: true, ..Default::default() });
|
|
assert_eq!(asc, vec![b, a, d]);
|
|
assert_eq!(c.query(&Filter { rating: 4, ..Default::default() }, &Sort::default()), vec![b]);
|
|
assert_eq!(c.query(&Filter { flag: Some(Flag::Reject), ..Default::default() }, &Sort::default()), vec![d]);
|
|
assert_eq!(c.query(&Filter { text: "summer".into(), ..Default::default() }, &Sort::default()), vec![a]);
|
|
assert_eq!(c.query(&Filter { text: "iso:>800".into(), ..Default::default() }, &Sort::default()), vec![a]);
|
|
assert_eq!(c.query(&Filter { text: "date:2025".into(), ..Default::default() }, &Sort::default()), vec![d]);
|
|
assert_eq!(c.query(&Filter { date: Some("2026-04".into()), ..Default::default() }, &Sort::default()), vec![b]);
|
|
c.apply(Op::SetDeleted { id: d, deleted: true }).unwrap();
|
|
assert_eq!(c.query(&Filter::default(), &Sort::default()).len(), 2);
|
|
assert_eq!(c.query(&Filter { deleted: true, ..Default::default() }, &Sort::default()), vec![d]);
|
|
let g = c.date_groups();
|
|
assert_eq!(g[0].year, "2026");
|
|
assert_eq!(g[0].count, 2);
|
|
assert_eq!(g[0].months.iter().map(|m| m.1).sum::<usize>(), 2);
|
|
assert_eq!(g[0].days.iter().map(|d| d.1).sum::<usize>(), 2);
|
|
assert!(g[0].days.iter().all(|(d, _)| d.len() == 10 && d.starts_with("2026")));
|
|
assert!(g[0].days.windows(2).all(|w| w[0].0 > w[1].0), "newest first");
|
|
assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]);
|
|
}
|
|
|
|
/// A search word with a colon whose left part is not a known field ("10:30", "Re:") is plain
|
|
/// text, not a fielded token that matches nothing.
|
|
#[test]
|
|
fn free_text_with_a_colon_searches_the_text_fields() {
|
|
let mut c = Catalog::new();
|
|
let a = photo(&mut c, "meeting.jpg", "2026-05-01T10:00:00");
|
|
let _b = photo(&mut c, "other.jpg", "2026-04-01T10:00:00");
|
|
let mut m = c.photo(a).unwrap().meta.clone();
|
|
m.caption = "Meeting at 10:30 with Re: notes".into();
|
|
m.iso = Some(1600);
|
|
c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
|
|
let q = |text: &str| c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default());
|
|
assert_eq!(q("meeting"), vec![a]);
|
|
assert_eq!(q("10:30"), vec![a], "a free-text token with a colon");
|
|
assert_eq!(q("re:"), vec![a]);
|
|
assert_eq!(q("RE: notes"), vec![a], "case-insensitive, one token per word");
|
|
assert_eq!(q("iso:>800"), vec![a], "known fields stay fielded");
|
|
assert_eq!(q("iso:>2000"), Vec::<PhotoId>::new());
|
|
assert_eq!(q("11:45"), Vec::<PhotoId>::new(), "an unknown field matching no text finds nothing");
|
|
}
|
|
|
|
/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an
|
|
/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be
|
|
/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores
|
|
/// the rest instead of panicking on a char boundary.
|
|
#[test]
|
|
fn date_groups_ignores_a_non_ascii_captured() {
|
|
let mut c = Catalog::new();
|
|
let ok = photo(&mut c, "ok.jpg", "2026-05-01T10:00:00");
|
|
// `AddPhoto` takes the field as it is, like `library.importLightroom` building the op
|
|
let bad = photo(&mut c, "bad.jpg", "日本語日本語");
|
|
assert_ne!(ok, bad);
|
|
let g = c.date_groups();
|
|
assert_eq!(g.len(), 1, "the one well-formed date still groups");
|
|
assert_eq!((g[0].year.as_str(), g[0].count), ("2026", 1));
|
|
assert_eq!(g[0].days.iter().map(|d| d.1).sum::<usize>(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_and_log_replay() {
|
|
let mut c = Catalog::new();
|
|
let snap0 = c.to_snapshot();
|
|
let mut log = String::new();
|
|
let a = c.alloc_photo_id();
|
|
let ops = vec![
|
|
Op::AddPhoto { photo: Box::new(Photo::new(a, Source::File { path: "/x/a.jpg".into() }, "a.jpg", "JPEG", 10, 10, "t")) },
|
|
Op::SetRating { id: a, rating: 3 },
|
|
Op::SetFlag { id: a, flag: Flag::Pick },
|
|
];
|
|
for op in ops {
|
|
log.push_str(&Catalog::op_to_log_line(&op));
|
|
c.apply(op).unwrap();
|
|
}
|
|
let mut r = Catalog::from_snapshot(&snap0).unwrap();
|
|
// torn last line is tolerated
|
|
let torn = format!("{log}{{\"op\":\"setRat");
|
|
assert_eq!(r.replay(&torn).unwrap(), 3);
|
|
assert_eq!(r.to_snapshot(), c.to_snapshot());
|
|
assert!(Catalog::from_snapshot("{nope").is_err());
|
|
}
|
|
|
|
proptest::proptest! {
|
|
#[test]
|
|
fn random_ops_undo_to_start(ratings in proptest::collection::vec((0usize..3, 0u8..6, 0u8..3), 1..40)) {
|
|
let mut c = Catalog::new();
|
|
let ids: Vec<PhotoId> = (0..3).map(|i| photo(&mut c, &format!("p{i}.jpg"), "2026-01-01")).collect();
|
|
let start = c.to_snapshot();
|
|
let mut invs = Vec::new();
|
|
for (i, r, f) in ratings {
|
|
let op = if f == 0 { Op::SetRating { id: ids[i], rating: r } } else { Op::SetFlag { id: ids[i], flag: [Flag::None, Flag::Pick, Flag::Reject][f as usize] } };
|
|
if let Ok(inv) = c.apply(op) { invs.push(inv); }
|
|
}
|
|
for inv in invs.into_iter().rev() { c.apply(inv).unwrap(); }
|
|
proptest::prop_assert_eq!(c.to_snapshot(), start);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn smart_albums_update_live() {
|
|
let mut c = Catalog::new();
|
|
let a = photo(&mut c, "a.jpg", "2026-04-01T10:00:00");
|
|
let b = photo(&mut c, "b.jpg", "2026-05-02T10:00:00");
|
|
let z = photo(&mut c, "z.jpg", "2025-12-31T10:00:00");
|
|
let id = c.alloc_album_id();
|
|
let rules = Filter { rating: 3, date_from: Some("2026-01-01".into()), date_to: Some("2026-04".into()), ..Default::default() };
|
|
c.apply(Op::AddAlbum { album: Album { smart: Some(Box::new(rules)), ..Album::new(id, "Good spring") } }).unwrap();
|
|
assert!(c.album_photos(id).is_empty());
|
|
for p in [a, b, z] {
|
|
c.apply(Op::SetRating { id: p, rating: 4 }).unwrap();
|
|
}
|
|
// b is after the range, z before it
|
|
assert_eq!(c.album_photos(id), vec![a]);
|
|
assert_eq!(c.album_count(id), 1);
|
|
assert_eq!(c.albums_of(a), vec![id]);
|
|
// via the album filter (what the grid uses)
|
|
assert_eq!(c.query(&Filter { album: Some(id), ..Default::default() }, &Sort::default()), vec![a]);
|
|
// deleted photos drop out
|
|
c.apply(Op::SetDeleted { id: a, deleted: true }).unwrap();
|
|
assert!(c.album_photos(id).is_empty());
|
|
c.apply(Op::SetDeleted { id: a, deleted: false }).unwrap();
|
|
// change the rules; inverse restores
|
|
let inv = c.apply(Op::SetAlbumRules { id, rules: Box::new(Filter { rating: 4, rating_op: RatingOp::Exactly, ..Default::default() }) }).unwrap();
|
|
assert_eq!(c.album_photos(id), vec![a, b, z]);
|
|
c.apply(inv).unwrap();
|
|
assert_eq!(c.album_photos(id), vec![a]);
|
|
// smart albums hold no photos and can't nest smart rules
|
|
assert!(c.apply(Op::SetAlbumPhotos { id, photos: vec![b] }).is_err());
|
|
let id2 = c.alloc_album_id();
|
|
let bad = Filter { album: Some(id), ..Default::default() };
|
|
assert!(c.apply(Op::AddAlbum { album: Album { smart: Some(Box::new(bad)), ..Album::new(id2, "x") } }).is_err());
|
|
assert!(c.apply(Op::SetAlbumRules { id, rules: Box::new(Filter { deleted: true, ..Default::default() }) }).is_err());
|
|
// rules on a manual album are rejected
|
|
let manual = c.alloc_album_id();
|
|
c.apply(Op::AddAlbum { album: Album::new(manual, "m") }).unwrap();
|
|
assert!(c.apply(Op::SetAlbumRules { id: manual, rules: Box::new(Filter::default()) }).is_err());
|
|
// a smart album may narrow a manual album
|
|
c.apply(Op::SetAlbumPhotos { id: manual, photos: vec![b, z] }).unwrap();
|
|
c.apply(Op::SetAlbumRules { id, rules: Box::new(Filter { album: Some(manual), lens: Some(String::new()), ..Default::default() }) }).unwrap();
|
|
assert_eq!(c.album_photos(id), vec![b, z]);
|
|
// persisted in snapshots
|
|
let back = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
|
|
assert_eq!(back.album_photos(id), vec![b, z]);
|
|
assert!(Filter { rating: 3, keyword: Some("sea".into()), ..Default::default() }.describe().contains("rating ≥ 3, keyword sea"));
|
|
}
|
|
|
|
#[test]
|
|
fn capture_times_display_like_the_info_panel() {
|
|
use crate::dates::display_time;
|
|
assert_eq!(display_time("2022-03-30T10:11:11"), "March 30, 2022 at 10:11:11 AM");
|
|
assert_eq!(display_time("2022-03-30T22:05:01.5-04:00"), "March 30, 2022 at 10:05:01 PM");
|
|
assert_eq!(display_time("2022-01-02T00:00:00"), "January 2, 2022 at 12:00:00 AM");
|
|
assert_eq!(display_time("2022-07-04"), "July 4, 2022");
|
|
assert_eq!(display_time("someday"), "someday");
|
|
}
|
|
|
|
#[test]
|
|
fn folder_paths() {
|
|
use crate::query::in_folder;
|
|
assert!(in_folder("/a/b/c.jpg", "/a/b", false));
|
|
assert!(in_folder("/a/b/c.jpg", "/a/b/", false));
|
|
assert!(!in_folder("/a/b/x/c.jpg", "/a/b", false));
|
|
assert!(in_folder("/a/b/x/c.jpg", "/a/b", true));
|
|
assert!(!in_folder("/a/bc/d.jpg", "/a/b", true), "a sibling with the same prefix");
|
|
assert!(in_folder("C:\\Pics\\a.jpg", "C:\\Pics", false));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_results_are_recognised() {
|
|
use crate::query::merged_kind;
|
|
assert_eq!(merged_kind("IMG_1-HDR.dng"), Some("hdr"));
|
|
assert_eq!(merged_kind("IMG_1-Pano.dng"), Some("panorama"));
|
|
assert_eq!(merged_kind("IMG_1-HDR-Pano.dng"), Some("hdrPanorama"));
|
|
assert_eq!(merged_kind("IMG_1-HDR-2.dng"), Some("hdr"), "a second merge of the same photo");
|
|
assert_eq!(merged_kind("hdr.jpg"), None);
|
|
assert_eq!(merged_kind("panorama.jpg"), None);
|
|
}
|
|
|
|
/// Preview-only raws (an undecodable raw variant shown from its embedded JPEG): the reason is
|
|
/// stored with the photo, old catalogs without it still load, and Reload's op sets / clears it
|
|
/// with an exact inverse that survives serialisation (the journal).
|
|
#[test]
|
|
fn preview_only_survives_serde_and_set_content_undo() {
|
|
let mut c = Catalog::new();
|
|
let a = c.alloc_photo_id();
|
|
let mut p = Photo::new(a, Source::File { path: "/x/DSC_0001.NEF".into() }, "DSC_0001.NEF", "NEF", 6000, 4000, "2026-10-01T00:00:00");
|
|
p.kind = MediaKind::Raw;
|
|
p.preview_only = Some("Nikon Huffman-compressed NEF".into());
|
|
assert!(!p.develops_raw());
|
|
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
|
// the catalog round-trips it
|
|
let back: Catalog = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
|
|
assert_eq!(back.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
|
|
// an older catalog (no field) loads as decodable
|
|
let mut v = serde_json::to_value(&c).unwrap();
|
|
let photos = v["photos"].as_object_mut().unwrap();
|
|
let ph = photos.values_mut().next().unwrap();
|
|
ph.as_object_mut().unwrap().remove("preview_only").expect("serialised when set");
|
|
let old: Catalog = serde_json::from_value(v).unwrap();
|
|
assert_eq!(old.photo(a).unwrap().preview_only, None);
|
|
assert!(old.photo(a).unwrap().develops_raw());
|
|
// a newer build still can't decode it: the reason and the stamp of the version that decided
|
|
// it are recorded together (#662)
|
|
let op = Op::SetContent {
|
|
id: a,
|
|
width: 6000,
|
|
height: 4000,
|
|
file_size: 1,
|
|
content_hash: None,
|
|
preview_only: Some("Nikon Huffman-compressed NEF".into()),
|
|
preview_checked: "0.6.0".into(),
|
|
};
|
|
c.apply(op).unwrap();
|
|
assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0");
|
|
// Reload: the file decodes now → cleared, with its stamp; undo (through the journal's JSON)
|
|
// restores both
|
|
let op =
|
|
Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None, preview_checked: String::new() };
|
|
let inv = c.apply(op).unwrap();
|
|
assert!(c.photo(a).unwrap().develops_raw());
|
|
assert_eq!(c.photo(a).unwrap().preview_checked, "");
|
|
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
|
|
c.apply(inv).unwrap();
|
|
assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
|
|
assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0");
|
|
assert_eq!(c.photo(a).unwrap().width, 6000);
|
|
}
|
|
|
|
/// Equivalent spellings of one folder (separators, trailing or doubled separators, `.`/`..`,
|
|
/// drive-letter case, verbatim and UNC prefixes) share an identity; different folders don't.
|
|
#[test]
|
|
fn folder_identity_ignores_spelling() {
|
|
use crate::query::{folder_key, folder_within};
|
|
let same = |a: &str, b: &str| assert_eq!(folder_key(a), folder_key(b), "{a} vs {b}");
|
|
let differ = |a: &str, b: &str| assert_ne!(folder_key(a), folder_key(b), "{a} vs {b}");
|
|
for (a, b) in [
|
|
("D:/Example/Photos", "D:\\Example\\Photos"),
|
|
("D:/Example/Photos", "d:\\Example\\Photos\\"),
|
|
("D:\\Example/Photos", "D:/Example//Photos/."),
|
|
("D:/Example/Photos", "D:/Example/Other/../Photos"),
|
|
("D:/Example/Photos", "\\\\?\\D:\\Example\\Photos"),
|
|
("\\\\server\\share\\Photos", "//server/share/Photos/"),
|
|
("\\\\?\\UNC\\server\\share\\Photos", "//server/share/Photos"),
|
|
("/home/me/Photos", "/home/me/Photos/"),
|
|
("/home/me/Photos", "/home//me/./Photos"),
|
|
("/", "//"),
|
|
("C:\\", "c:/"),
|
|
] {
|
|
same(a, b);
|
|
}
|
|
differ("D:/Example/Photos", "D:/Example/Photos2");
|
|
differ("D:/Example/Photos", "E:/Example/Photos");
|
|
differ("/home/me/Photos", "/home/me");
|
|
assert_eq!(folder_key("/a/../../b"), "/b", "`..` stops at the root");
|
|
assert!(folder_within("D:\\Example\\Photos\\2026", "D:/Example/Photos/"));
|
|
assert!(folder_within("D:/Example/Photos", "d:\\Example\\Photos"));
|
|
assert!(!folder_within("D:/Example/Photos2", "D:/Example/Photos"));
|
|
assert!(folder_within("/a/b", "/"));
|
|
assert!(folder_within("C:\\x", "c:\\"));
|
|
}
|
|
|
|
/// People come from named *face* regions: counted once per photo, case-insensitive, most photos
|
|
/// first; pets and unnamed faces are not people; the `person` filter and `person:` token match.
|
|
#[test]
|
|
fn people_from_named_face_regions() {
|
|
use lightcraft_meta::{Rect, Region, RegionKind};
|
|
let region = |name: Option<&str>, kind: RegionKind| Region {
|
|
rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
|
|
kind,
|
|
name: name.map(str::to_string),
|
|
description: None,
|
|
};
|
|
let mut c = Catalog::new();
|
|
let mut add = |name: &str, regions: Vec<Region>| {
|
|
let id = c.alloc_photo_id();
|
|
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
|
|
p.meta.regions = regions;
|
|
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
|
id
|
|
};
|
|
let a = add(
|
|
"a.jpg",
|
|
vec![region(Some("Jane Doe"), RegionKind::Face), region(Some("jane doe"), RegionKind::Face), region(Some("Rex"), RegionKind::Pet)],
|
|
);
|
|
let b =
|
|
add("b.jpg", vec![region(Some("JANE DOE"), RegionKind::Face), region(Some("John Roe"), RegionKind::Face), region(None, RegionKind::Face)]);
|
|
let big = Rect { x0: 0.2, y0: 0.2, x1: 0.8, y1: 0.8 };
|
|
let d = add("d.jpg", vec![Region { rect: big, ..region(Some("John Roe"), RegionKind::Face) }]);
|
|
let e = add("e.jpg", vec![region(Some("Sam"), RegionKind::Face)]);
|
|
add("f.jpg", vec![]);
|
|
let people = c.people();
|
|
let summary: Vec<(&str, usize)> = people.iter().map(|p| (p.name.as_str(), p.count)).collect();
|
|
assert_eq!(summary, vec![("Jane Doe", 2), ("John Roe", 2), ("Sam", 1)], "once per photo, pets and unnamed faces left out");
|
|
// the picture is the person's largest face; ties go to the lower photo id
|
|
assert_eq!((people[0].photo, people[0].face), (a, Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }));
|
|
assert_eq!((people[1].photo, people[1].face), (d, big), "the larger face wins over an earlier, smaller one");
|
|
|
|
let q = |f: Filter| c.query(&f, &Sort::default());
|
|
assert_eq!(q(Filter { person: Some("jane doe".into()), ..Default::default() }).len(), 2);
|
|
let mut got = q(Filter { person: Some("John Roe".into()), ..Default::default() });
|
|
got.sort();
|
|
assert_eq!(got, vec![b, d]);
|
|
assert!(q(Filter { person: Some("Rex".into()), ..Default::default() }).is_empty(), "a pet is not a person");
|
|
assert_eq!(q(Filter { text: "person:SAM".into(), ..Default::default() }), vec![e], "the search token finds a person, any case");
|
|
assert!(q(Filter { person: Some("Jane Doe".into()), ..Default::default() }).contains(&a));
|
|
|
|
// other filters narrow who is offered (so picking a person never ends in an empty grid); the
|
|
// `person` filter itself does not
|
|
c.apply(Op::SetRating { id: b, rating: 3 }).unwrap();
|
|
let names = |f: Filter| c.people_in(&f).into_iter().map(|p| (p.name, p.count)).collect::<Vec<_>>();
|
|
let rated = Filter { rating: 1, ..Default::default() };
|
|
assert_eq!(
|
|
names(rated.clone()),
|
|
vec![("JANE DOE".to_string(), 1), ("John Roe".to_string(), 1)],
|
|
"only the rated photo's people, counted within it, spelled as first seen there"
|
|
);
|
|
assert_eq!(
|
|
names(Filter { person: Some("Sam".into()), ..rated }),
|
|
names(Filter { rating: 1, ..Default::default() }),
|
|
"the person filter is ignored"
|
|
);
|
|
assert_eq!(names(Filter { rating: 5, ..Default::default() }), vec![], "nobody in the filtered photos");
|
|
}
|
|
|
|
/// Most photos have no regions: the field is left out of the catalog JSON then, and a catalog written
|
|
/// before regions existed (no `regions` key) reads with none.
|
|
#[test]
|
|
fn empty_regions_are_not_serialized_and_default_when_missing() {
|
|
let m = Meta { keywords: vec!["k".into()], ..Default::default() };
|
|
let v = serde_json::to_value(&m).unwrap();
|
|
assert!(v.get("regions").is_none(), "{v}");
|
|
let back: Meta = serde_json::from_value(v).unwrap();
|
|
assert!(back.regions.is_empty());
|
|
let mut with = m.clone();
|
|
with.regions.push(lightcraft_meta::Region {
|
|
rect: lightcraft_meta::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 },
|
|
kind: lightcraft_meta::RegionKind::Face,
|
|
name: Some("A".into()),
|
|
description: None,
|
|
});
|
|
let back: Meta = serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap();
|
|
assert_eq!(back, with);
|
|
}
|
|
|
|
/// The camera's focus point is optional camera data: absent from the catalog JSON when unknown (so a
|
|
/// catalog from before it existed reads with none, and writes the same), kept exactly when known.
|
|
#[test]
|
|
fn focus_point_is_not_serialized_when_unknown_and_round_trips() {
|
|
let m = Meta { camera: "SONY ILCE-1".into(), ..Default::default() };
|
|
let v = serde_json::to_value(&m).unwrap();
|
|
assert!(v.get("focus").is_none(), "{v}");
|
|
let back: Meta = serde_json::from_value(v).unwrap();
|
|
assert!(back.focus.is_none());
|
|
let mut with = m.clone();
|
|
with.focus = Some(lightcraft_meta::FocusPoint {
|
|
rect: lightcraft_meta::Rect { x0: 0.36, y0: 0.33, x1: 0.38, y1: 0.36 },
|
|
mode: Some("AF-C".into()),
|
|
area: Some("Wide".into()),
|
|
tracking: None,
|
|
});
|
|
let json = serde_json::to_string(&with).unwrap();
|
|
assert!(json.contains("\"focus\"") && !json.contains("tracking"), "{json}");
|
|
let back: Meta = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, with);
|
|
}
|
|
|
|
// ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos
|
|
|
|
fn many(c: &mut Catalog, n: usize) -> Vec<PhotoId> {
|
|
(0..n).map(|i| photo(c, &format!("p{i:03}.jpg"), &format!("2026-04-{:02}T10:00:00", i % 28 + 1))).collect()
|
|
}
|
|
|
|
fn random(seed: u64) -> Sort {
|
|
Sort { key: SortKey::Random, seed, ..Default::default() }
|
|
}
|
|
|
|
#[test]
|
|
fn random_sort_is_a_reproducible_permutation() {
|
|
let mut c = Catalog::new();
|
|
let ids = many(&mut c, 50);
|
|
let a = c.query(&Filter::default(), &random(7));
|
|
assert_eq!(a, c.query(&Filter::default(), &random(7)), "same seed, same order");
|
|
let mut sorted = a.clone();
|
|
sorted.sort();
|
|
let mut all = ids.clone();
|
|
all.sort();
|
|
assert_eq!(sorted, all, "every photo exactly once");
|
|
assert_ne!(a, c.query(&Filter::default(), &random(8)), "another seed, another order");
|
|
assert_ne!(a, c.query(&Filter::default(), &Sort::default()), "not just the date order");
|
|
}
|
|
|
|
#[test]
|
|
fn random_sort_keeps_the_relative_order_when_the_set_changes() {
|
|
let mut c = Catalog::new();
|
|
let ids = many(&mut c, 30);
|
|
let before = c.query(&Filter::default(), &random(3));
|
|
let added = photo(&mut c, "new.jpg", "2026-05-01T10:00:00");
|
|
c.apply(Op::SetRating { id: ids[0], rating: 5 }).unwrap();
|
|
let after: Vec<PhotoId> = c.query(&Filter::default(), &random(3)).into_iter().filter(|id| *id != added).collect();
|
|
assert_eq!(after, before, "adding a photo or editing metadata must not reshuffle the others");
|
|
}
|
|
|
|
#[test]
|
|
fn random_sort_respects_the_filter_and_handles_tiny_sets() {
|
|
let mut c = Catalog::new();
|
|
assert!(c.query(&Filter::default(), &random(1)).is_empty());
|
|
let ids = many(&mut c, 10);
|
|
c.apply(Op::SetRating { id: ids[4], rating: 5 }).unwrap();
|
|
assert_eq!(c.query(&Filter { rating: 5, ..Default::default() }, &random(1)), vec![ids[4]]);
|
|
}
|
|
|
|
#[test]
|
|
fn random_sort_direction_reverses_and_serde_defaults_hold() {
|
|
let mut c = Catalog::new();
|
|
many(&mut c, 20);
|
|
let desc = c.query(&Filter::default(), &Sort { ascending: false, ..random(5) });
|
|
let mut asc = c.query(&Filter::default(), &Sort { ascending: true, ..random(5) });
|
|
asc.reverse();
|
|
assert_eq!(desc, asc);
|
|
// saved sorts from before the seed existed still load
|
|
let old: Sort = serde_json::from_str(r#"{"key":"fileName","ascending":true}"#).unwrap();
|
|
assert_eq!(old.seed, 0);
|
|
assert_eq!(serde_json::from_str::<Sort>(r#"{"key":"random","seed":9}"#).unwrap(), Sort { key: SortKey::Random, seed: 9, ..Default::default() });
|
|
}
|
|
|
|
#[test]
|
|
fn random_sort_has_no_date_headers() {
|
|
let mut c = Catalog::new();
|
|
let ids = many(&mut c, 5);
|
|
assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty());
|
|
}
|
|
|
|
/// Reload stores the lens data a file carries; the inverse (through the journal's JSON) takes it back.
|
|
#[test]
|
|
fn set_embedded_lens_is_undoable_and_journaled() {
|
|
let mut c = Catalog::default();
|
|
let a = c.alloc_photo_id();
|
|
c.apply(Op::AddPhoto {
|
|
photo: Box::new(Photo::new(a, Source::File { path: "/x.rw2".into() }, "x.rw2", "RW2", 4000, 3000, "2026-10-06T00:00:00")),
|
|
})
|
|
.unwrap();
|
|
let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None, stages: Default::default() };
|
|
let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) };
|
|
let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();
|
|
let inv = c.apply(op).unwrap();
|
|
assert_eq!(c.photo(a).unwrap().embedded_lens, Some(lens));
|
|
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
|
|
c.apply(inv).unwrap();
|
|
assert_eq!(c.photo(a).unwrap().embedded_lens, None);
|
|
}
|
|
|
|
/// A decode or Reload stores the as-shot white a raw reads through its camera's matrices; the inverse (through the
|
|
/// journal's JSON) takes it back, and a value that is not a number is refused.
|
|
#[test]
|
|
fn set_as_shot_wb_is_undoable_journaled_and_checked() {
|
|
let mut c = Catalog::default();
|
|
let a = c.alloc_photo_id();
|
|
let mut p = Photo::new(a, Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 4000, 3000, "2026-10-06T00:00:00");
|
|
p.as_shot_wb = Some((6500.0, 0.0));
|
|
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
|
let op = Op::SetAsShotWb { id: a, wb: Some((4986.0, -2.0)) };
|
|
let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap();
|
|
let inv = c.apply(op).unwrap();
|
|
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((4986.0, -2.0)));
|
|
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
|
|
c.apply(inv).unwrap();
|
|
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0)));
|
|
assert!(c.apply(Op::SetAsShotWb { id: a, wb: Some((f64::NAN, 0.0)) }).is_err());
|
|
assert!(c.apply(Op::SetAsShotWb { id: PhotoId(999), wb: None }).is_err());
|
|
assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0)));
|
|
}
|
|
|
|
/// A saved smart album whose rules no longer check (the album a rule tests was deleted) is found,
|
|
/// with its problems; a good one has none; an old album operator is read as it was meant.
|
|
#[test]
|
|
fn smart_album_problems_flag_stale_rules() {
|
|
let mut c = Catalog::new();
|
|
c.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap();
|
|
let smart = |id: u64, rules: serde_json::Value| {
|
|
let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": rules}})).unwrap();
|
|
Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "Smart") }
|
|
};
|
|
c.apply(Op::AddAlbum { album: smart(2, serde_json::json!([{"field": "album", "op": "is", "value": 1}])) }).unwrap();
|
|
c.apply(Op::AddAlbum { album: smart(3, serde_json::json!([{"field": "album", "op": "gte", "value": 1}])) }).unwrap();
|
|
assert!(c.smart_album_problems(AlbumId(2)).is_empty());
|
|
assert!(c.smart_album_problems(AlbumId(3)).is_empty(), "an old operator is upgraded, not a problem");
|
|
assert!(c.smart_album_problems(AlbumId(1)).is_empty(), "a plain album has no rules");
|
|
c.apply(Op::RemoveAlbum { id: AlbumId(1) }).unwrap();
|
|
let p = c.smart_album_problems(AlbumId(2));
|
|
assert_eq!(p.iter().map(ToString::to_string).collect::<Vec<_>>(), vec!["rule 1: no album 1".to_string()]);
|
|
}
|
|
|
|
/// A smart album can test another smart album: "Keywords contain travel" and "Album isn't Excluded
|
|
/// Photos" (red or rejected) leaves out the excluded photos, and follows that album's rules as they
|
|
/// change.
|
|
#[test]
|
|
fn a_smart_album_can_exclude_another() {
|
|
let mut c = Catalog::new();
|
|
let smart = |id: u64, name: &str, rules: serde_json::Value| {
|
|
let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": rules})).unwrap();
|
|
Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), name) }
|
|
};
|
|
c.apply(Op::AddAlbum {
|
|
album: smart(
|
|
1,
|
|
"Excluded Photos",
|
|
serde_json::json!({"match": "any", "rules": [
|
|
{"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}),
|
|
),
|
|
})
|
|
.unwrap();
|
|
let travel = |c: &mut Catalog, name: &str, label: Option<ColorLabel>, flag: Flag| {
|
|
let id = c.alloc_photo_id();
|
|
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
|
|
p.meta.keywords = vec!["travel".into()];
|
|
p.label = label;
|
|
p.flag = flag;
|
|
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
|
id
|
|
};
|
|
let keep = travel(&mut c, "keep.jpg", None, Flag::Pick);
|
|
let red = travel(&mut c, "red.jpg", Some(ColorLabel::Red), Flag::None);
|
|
let rejected = travel(&mut c, "rejected.jpg", None, Flag::Reject);
|
|
let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [
|
|
{"field": "keywords", "op": "contains", "value": "travel"}, {"field": "album", "op": "isNot", "value": 1}]}))
|
|
.unwrap();
|
|
assert!(rules.check(&c).is_empty(), "a smart album can be tested: {:?}", rules.check(&c));
|
|
let matched = |c: &Catalog| c.photos().filter(|p| rules.matches(p, c)).map(|p| p.id).collect::<Vec<_>>();
|
|
assert_eq!(matched(&c), vec![keep]);
|
|
// "is" works the other way round
|
|
let inside: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 1}]})).unwrap();
|
|
assert_eq!(c.photos().filter(|p| inside.matches(p, &c)).count(), 2);
|
|
let _ = (red, rejected);
|
|
}
|
|
|
|
/// An album can't include itself, directly or through other smart albums: the check refuses the
|
|
/// loop for the album being edited, and a loop saved anyway (an older version, a hand-edited file)
|
|
/// neither recurses forever nor crashes: inside it, the album being evaluated counts as holding no
|
|
/// photos, and every album in it is reported.
|
|
#[test]
|
|
fn smart_album_loops_are_refused_and_survived() {
|
|
let mut c = Catalog::new();
|
|
let refers = |to: u64| -> Filter {
|
|
serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": to}]}})).unwrap()
|
|
};
|
|
let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") };
|
|
// A → B → C
|
|
c.apply(Op::AddAlbum { album: smart(3, Filter::default()) }).unwrap();
|
|
c.apply(Op::AddAlbum { album: smart(2, refers(3)) }).unwrap();
|
|
c.apply(Op::AddAlbum { album: smart(1, refers(2)) }).unwrap();
|
|
let rules = |to: u64| refers(to).rule_set.unwrap();
|
|
assert!(rules(1).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "C → A closes A → B → C → A");
|
|
assert!(rules(3).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "an album testing itself");
|
|
assert!(rules(1).check_for(&c, None).is_empty(), "a new album can't be in a loop yet");
|
|
assert!(rules(3).check_for(&c, Some(AlbumId(1))).is_empty(), "A testing C again is no loop");
|
|
assert!(!c.album_reaches(AlbumId(3), AlbumId(1)) && c.album_reaches(AlbumId(1), AlbumId(3)));
|
|
// a loop saved anyway: C → A
|
|
c.apply(Op::SetAlbumRules { id: AlbumId(3), rules: Box::new(refers(1)) }).unwrap();
|
|
let id = photo(&mut c, "a.jpg", "2026-09-01T10:00:00");
|
|
let p = c.photo(id).unwrap().clone();
|
|
for a in [1, 2, 3] {
|
|
let _ = c.album_contains(AlbumId(a), &p); // returns, no stack overflow
|
|
assert!(c.smart_album_problems(AlbumId(a)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "album {a} is reported");
|
|
}
|
|
}
|
|
|
|
/// Summaries name the albums Album rules test ("album isn't “Excluded Photos”"), not their ids;
|
|
/// one that is gone shows as its number.
|
|
#[test]
|
|
fn summaries_name_albums() {
|
|
let mut c = Catalog::new();
|
|
c.apply(Op::AddAlbum { album: Album::new(AlbumId(4), "Excluded Photos") }).unwrap();
|
|
let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [
|
|
{"field": "keywords", "op": "contains", "value": "travel"},
|
|
{"group": {"match": "any", "rules": [{"field": "album", "op": "isNot", "value": 4}, {"field": "album", "op": "is", "value": 9}]}}]}))
|
|
.unwrap();
|
|
assert_eq!(rules.describe_with(&c), "keywords contains travel and (album isn't “Excluded Photos” or album is #9)");
|
|
let f = Filter { rule_set: Some(rules), ..Default::default() };
|
|
assert!(f.describe_with(&c).contains("album isn't “Excluded Photos”"));
|
|
}
|
|
|
|
/// A chain of smart albums each testing the one before twice ("all of: album is X(k-1), album is
|
|
/// X(k-1)") costs a pass per album, not 2^depth: each album's answer for a photo is worked out once
|
|
/// per question. 25 albums deep stays instant.
|
|
#[test]
|
|
fn chains_of_smart_albums_stay_linear() {
|
|
let mut c = Catalog::new();
|
|
let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "X") };
|
|
c.apply(Op::AddAlbum { album: smart(1, Filter::default()) }).unwrap();
|
|
for k in 2..=26u64 {
|
|
let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [
|
|
{"field": "album", "op": "is", "value": k - 1}, {"field": "album", "op": "is", "value": k - 1}]}}))
|
|
.unwrap();
|
|
c.apply(Op::AddAlbum { album: smart(k, f) }).unwrap();
|
|
}
|
|
for i in 0..20 {
|
|
photo(&mut c, &format!("p{i}.jpg"), "2026-09-01T10:00:00");
|
|
}
|
|
let start = std::time::Instant::now();
|
|
assert_eq!(c.album_count(AlbumId(26)), 20);
|
|
assert!(start.elapsed() < std::time::Duration::from_secs(2), "took {:?}", start.elapsed());
|
|
}
|
|
|
|
/// A smart album's own album filter (not only its rules) counts in loops: A filtered to "in A" is
|
|
/// reported, and so is A filtered to B while B's rules test A.
|
|
#[test]
|
|
fn an_album_filter_counts_in_loops() {
|
|
let mut c = Catalog::new();
|
|
let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") };
|
|
c.apply(Op::AddAlbum { album: smart(1, Filter { album: Some(AlbumId(1)), ..Default::default() }) }).unwrap();
|
|
let loops = |c: &Catalog, id: u64| c.smart_album_problems(AlbumId(id)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop);
|
|
assert!(loops(&c, 1), "filtered to itself");
|
|
let tests_3: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 3}]}})).unwrap();
|
|
c.apply(Op::AddAlbum { album: smart(2, Filter { album: Some(AlbumId(3)), ..Default::default() }) }).unwrap();
|
|
c.apply(Op::AddAlbum {
|
|
album: smart(3, serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 2}]}})).unwrap()),
|
|
})
|
|
.unwrap();
|
|
assert!(c.album_reaches(AlbumId(3), AlbumId(2)) && c.album_reaches(AlbumId(2), AlbumId(3)));
|
|
assert!(loops(&c, 2) && loops(&c, 3));
|
|
let _ = tests_3;
|
|
let shown: Vec<String> = c.smart_album_problems(AlbumId(1)).iter().map(ToString::to_string).collect();
|
|
assert!(shown.iter().all(|s| !s.starts_with("rule :")), "{shown:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn undated_photos_group_under_unknown_date_and_sort_together() {
|
|
let mut c = Catalog::new();
|
|
let add_photo = |c: &mut Catalog, name: &str, captured: Option<&str>, imported: &str| {
|
|
let id = c.alloc_photo_id();
|
|
let mut p = Photo::new(id, Source::Demo { scene: 0 }, name, "JPEG", 100, 100, imported);
|
|
p.captured = captured.map(str::to_string);
|
|
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
|
id
|
|
};
|
|
let p_dated1 = add_photo(&mut c, "dated1.jpg", Some("2026-05-10T12:00:00"), "2026-10-01T00:00:00");
|
|
let p_dated2 = add_photo(&mut c, "dated2.jpg", Some("2026-05-20T12:00:00"), "2026-10-01T00:00:00");
|
|
let p_undated1 = add_photo(&mut c, "undated1.jpg", None, "2026-10-05T00:00:00");
|
|
let p_undated2 = add_photo(&mut c, "undated2.jpg", None, "2026-10-02T00:00:00");
|
|
|
|
// Descending sort (default, newest first): dated photos first (newest to oldest), undated photos at the end
|
|
let desc = c.query(&Filter::default(), &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() });
|
|
assert_eq!(desc, vec![p_dated2, p_dated1, p_undated1, p_undated2]);
|
|
|
|
// Ascending sort (oldest first): undated photos at the beginning, dated photos from oldest to newest
|
|
let asc = c.query(&Filter::default(), &Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() });
|
|
assert_eq!(asc, vec![p_undated2, p_undated1, p_dated1, p_dated2]);
|
|
|
|
// date_runs groups undated photos under Unknown Date
|
|
let runs = c.date_runs(&desc, SortKey::CaptureDate, GroupBy::Day);
|
|
assert_eq!(runs.len(), 3);
|
|
assert_eq!(runs[0].key, "2026-05-20");
|
|
assert_eq!(runs[1].key, "2026-05-10");
|
|
assert_eq!(runs[2].key, "");
|
|
assert_eq!(runs[2].label, "Unknown Date");
|
|
assert_eq!(runs[2].count, 2);
|
|
|
|
// date_groups sidebar tree only counts photos with capture dates
|
|
let groups = c.date_groups();
|
|
assert_eq!(groups.len(), 1);
|
|
assert_eq!(groups[0].year, "2026");
|
|
assert_eq!(groups[0].count, 2);
|
|
|
|
// Filter by capture date prefix does not match undated photos
|
|
let filter_day = Filter { date: Some("2026-10-05".into()), ..Default::default() };
|
|
assert_eq!(c.query(&filter_day, &Sort::default()), vec![]);
|
|
let filter_year = Filter { date: Some("2026".into()), ..Default::default() };
|
|
assert_eq!(c.query(&filter_year, &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() }), vec![p_dated2, p_dated1]);
|
|
}
|
|
|
|
/// Photos with one import time and capture time, as one import batch or a burst gives them, added
|
|
/// with ids that run opposite to their names.
|
|
fn batch_with_reversed_ids(c: &mut Catalog) -> Vec<PhotoId> {
|
|
let names = ["DSC_0001.ARW", "DSC_0002.ARW", "DSC_0003.ARW", "DSC_0004.ARW"];
|
|
let mut ids = Vec::new();
|
|
for (n, name) in names.iter().enumerate() {
|
|
let id = PhotoId(1000 - n as u64);
|
|
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 10, 10, "2026-10-10T09:00:00");
|
|
p.captured = Some("2026-10-01T12:00:00".into());
|
|
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
|
ids.push(id);
|
|
}
|
|
ids // in name order
|
|
}
|
|
|
|
#[test]
|
|
fn one_import_batch_sorts_by_file_name_not_id() {
|
|
let mut c = Catalog::new();
|
|
let by_name = batch_with_reversed_ids(&mut c);
|
|
let sort = Sort { key: SortKey::ImportDate, ascending: true, ..Default::default() };
|
|
assert_eq!(c.query(&Filter::default(), &sort), by_name);
|
|
}
|
|
|
|
#[test]
|
|
fn burst_in_one_second_sorts_by_file_name_not_id() {
|
|
let mut c = Catalog::new();
|
|
let by_name = batch_with_reversed_ids(&mut c);
|
|
let sort = Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() };
|
|
assert_eq!(c.query(&Filter::default(), &sort), by_name);
|
|
}
|
|
|
|
#[test]
|
|
fn cmp_name_ignores_case_without_allocating() {
|
|
use crate::query::cmp_name;
|
|
use std::cmp::Ordering::*;
|
|
assert_eq!(cmp_name("IMG_1.JPG", "img_1.jpg"), Equal);
|
|
assert_eq!(cmp_name("Ärger.jpg", "ärger.jpg"), Equal);
|
|
assert_eq!(cmp_name("a", "B"), Less);
|
|
assert_eq!(cmp_name("B", "a"), Greater);
|
|
assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less);
|
|
assert_eq!(cmp_name("a", "ab"), Less);
|
|
}
|
|
|
|
/// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be
|
|
/// said photo by photo.
|
|
#[test]
|
|
fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() {
|
|
let mut c = Catalog::default();
|
|
let photo = |c: &mut Catalog, name: &str| {
|
|
let id = c.alloc_photo_id();
|
|
c.apply(Op::AddPhoto {
|
|
photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")),
|
|
})
|
|
.unwrap();
|
|
id
|
|
};
|
|
let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg"));
|
|
let start = c.revision;
|
|
assert_eq!(c.touched_since(start), Some(vec![]));
|
|
assert_eq!(c.aspects_since(start), Some(Aspects::NONE));
|
|
// a rating, then a flag on another photo, as one batch
|
|
c.apply(Op::SetRating { id: a, rating: 3 }).unwrap();
|
|
assert_eq!(c.touched_since(start), Some(vec![a]));
|
|
assert_eq!(c.aspects_since(start), Some(Aspects::RATING));
|
|
let after_rating = c.revision;
|
|
c.apply(Op::Batch { ops: vec![Op::SetFlag { id: b, flag: Flag::Pick }, Op::SetRating { id: a, rating: 4 }] }).unwrap();
|
|
assert_eq!(c.touched_since(start), Some(vec![a, b]), "each once");
|
|
assert_eq!(c.touched_since(after_rating), Some(vec![a, b]));
|
|
assert_eq!(c.aspects_since(after_rating), Some(Aspects::FLAG.with(Aspects::RATING)));
|
|
assert_eq!(c.touched_since(c.revision), Some(vec![]));
|
|
// a photo that goes counts for everything
|
|
let before = c.revision;
|
|
c.apply(Op::RemovePhoto { id: b }).unwrap();
|
|
assert_eq!(c.touched_since(before), Some(vec![b]));
|
|
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::PRESENCE) && x.any_of(Aspects::FILE)));
|
|
// an op that changes more than photos of its own: nothing is said of what came before it
|
|
let before = c.revision;
|
|
let album = c.alloc_album_id();
|
|
c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap();
|
|
assert_eq!(c.touched_since(before), None);
|
|
assert_eq!(c.aspects_since(start), None);
|
|
assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again");
|
|
// a revision that is not this catalog's past
|
|
assert_eq!(c.touched_since(c.revision + 5), None);
|
|
// a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after
|
|
// the next op nothing of before it
|
|
let old = c.revision;
|
|
c.revision = 7 << 32;
|
|
assert_eq!(c.touched_since(old), None);
|
|
assert_eq!(c.aspects_since(c.revision), None);
|
|
let loaded = c.revision;
|
|
c.apply(Op::SetRating { id: a, rating: 1 }).unwrap();
|
|
assert_eq!(c.touched_since(loaded), Some(vec![a]));
|
|
assert_eq!(c.touched_since(old), None);
|
|
// an op that fails changes nothing, and what is remembered never says less than what happened
|
|
let before = c.revision;
|
|
assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err());
|
|
assert_eq!(c.revision, before);
|
|
c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap();
|
|
assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a)));
|
|
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG)));
|
|
// a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch
|
|
// with an op that changes more than photos is not
|
|
let before = c.revision;
|
|
assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err());
|
|
assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1)));
|
|
c.apply(Op::Batch { ops: vec![Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }] }] }).unwrap();
|
|
// (the photo the failed batch asked for and never found may be named too: more is said, never less)
|
|
assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a)));
|
|
let before = c.revision;
|
|
c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 3 }, Op::RenameAlbum { id: album, name: "Journey".into() }] }).unwrap();
|
|
assert_eq!(c.touched_since(before), None);
|
|
// more ops than are remembered: the oldest are answered with "anything"
|
|
let long_ago = c.revision;
|
|
for i in 0..9000u32 {
|
|
c.apply(Op::SetRating { id: a, rating: (i % 5) as u8 }).unwrap();
|
|
}
|
|
assert_eq!(c.touched_since(long_ago), None);
|
|
assert_eq!(c.touched_since(c.revision - 10), Some(vec![a]));
|
|
}
|