Reject unavailable Windows file identities in strict comparisons

Return an error when either file index is zero. Keep the resolved-path fallback for lenient comparisons. Test both unknown-identity orderings and distinguish identity lookup failures from known-original refusals.

Merge main at 662df66f.
This commit is contained in:
dotCooCoo
2026-10-26 20:28:34 -07:00
434 changed files with 90302 additions and 67779 deletions
+1 -711
View File
@@ -822,714 +822,4 @@ impl Catalog {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Filter, Photo, PhotoId, Source};
fn lib(kws: &[&[&str]]) -> (Catalog, Vec<PhotoId>) {
let mut c = Catalog::new();
let mut ids = Vec::new();
for k in kws {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01");
p.meta.keywords = k.iter().map(|s| s.to_string()).collect();
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
(c, ids)
}
fn kws(c: &Catalog, id: PhotoId) -> Vec<String> {
c.photo(id).unwrap().meta.keywords.clone()
}
#[test]
fn tree_counts_photos_per_level() {
let (c, _) = lib(&[&["travel|Italy|Rome", "beach"], &["Travel|italy"], &["travel|France", "travel|italy|rome"], &["beach"]]);
let t = c.keyword_tree();
assert_eq!(t.iter().map(|n| (n.name.as_str(), n.count)).collect::<Vec<_>>(), vec![("beach", 2), ("travel", 3)]);
let travel = &t[1];
assert_eq!(travel.children.iter().map(|n| (n.path.as_str(), n.count)).collect::<Vec<_>>(), vec![("travel|France", 1), ("travel|Italy", 3)]);
assert_eq!(travel.children[1].children[0].path, "travel|Italy|Rome");
assert_eq!(travel.children[1].children[0].count, 2);
// filtering by a parent finds its children
let f = Filter { keyword: Some("travel|italy".into()), ..Default::default() };
assert_eq!(c.query(&f, &Default::default()).len(), 3);
}
/// A keyword written twice on a photo, or in two spellings, counts the photo once; the next
/// photo with it counts again.
#[test]
fn tree_counts_a_repeated_keyword_once_per_photo() {
let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]);
let t = c.keyword_tree();
assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]);
}
#[test]
fn rename_delete_merge_are_single_undoable_batches() {
let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]);
let before = c.to_snapshot();
let op = c.rename_keyword_ops("travel|italy", "Europe|Italy").unwrap();
let Op::Batch { ops } = &op else { panic!() };
assert_eq!(ops.len(), 3, "only photos that change");
let inv = c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Europe|Italy|rome", "beach"]);
assert_eq!(kws(&c, ids[1]), ["Europe|Italy"]);
c.apply(inv).unwrap();
assert_eq!(c.to_snapshot(), before);
// merge `italia` into the existing hierarchical keyword: duplicates collapse
let op = c.merge_keywords_ops(&["italia".into()], "travel|italy").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[2]), ["travel|italy"]);
// delete removes the keyword and its children everywhere
let op = c.delete_keyword_ops("TRAVEL").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["beach"]);
assert!(kws(&c, ids[1]).is_empty());
assert_eq!(c.keywords(), vec![("beach".to_string(), 1), ("sea".to_string(), 1)]);
// invalid requests
assert!(c.rename_keyword_ops("beach", " ").is_err());
assert!(c.rename_keyword_ops("a", "a|b").is_err());
assert!(c.merge_keywords_ops(&["a".into()], "a|b").is_err());
assert_eq!(clean(" a | |b "), "a|b");
}
fn tree_paths(nodes: &[KeywordNode]) -> Vec<(String, usize)> {
let mut out = Vec::new();
for n in nodes {
out.push((n.path.clone(), n.count));
out.extend(tree_paths(&n.children));
}
out
}
/// A keyword can be listed before any photo has it (Lightroom Classic's Create Keyword Tag):
/// it is in the tree with no photos, its parents too, and undo takes it away.
#[test]
fn a_keyword_listed_with_no_photos_is_in_the_tree() {
let (mut c, _) = lib(&[&["travel|France"]]);
let undo = c.apply(Op::SetKeyword { path: " travel | Italy ".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1), ("travel|Italy".to_string(), 0)]);
assert_eq!(c.keyword_info("TRAVEL|italy"), Some(&KeywordInfo::default()), "found whatever the case");
c.apply(undo).unwrap();
assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1)]);
assert_eq!(c.keyword_info("travel|Italy"), None);
}
/// A listed keyword needs a name, and its attributes start as Lightroom Classic's do: exported,
/// with the keywords containing it and its synonyms; not a person.
#[test]
fn a_listed_keyword_needs_a_name_and_starts_exported() {
let mut c = Catalog::new();
assert!(c.apply(Op::SetKeyword { path: " | ".into(), info: Some(KeywordInfo::default()) }).is_err());
let info = KeywordInfo::default();
assert!(info.include_on_export && info.export_containing && info.export_synonyms && !info.person && info.synonyms.is_empty());
}
/// Changing a listed keyword's attributes is undone to the attributes it had, under the name as
/// it was written.
#[test]
fn changing_a_listed_keyword_is_undone_to_what_it_was() {
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
let person = KeywordInfo { person: true, synonyms: vec!["Marriage".into()], ..KeywordInfo::default() };
let undo = c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(person.clone()) }).unwrap();
assert_eq!(c.keyword_info("Weddings"), Some(&person));
assert_eq!(undo, Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) });
c.apply(undo).unwrap();
assert_eq!(c.keyword_info("weddings"), Some(&KeywordInfo::default()));
}
fn listed(c: &Catalog) -> Vec<(String, KeywordInfo)> {
let mut v: Vec<(String, KeywordInfo)> = c.listed_keywords().map(|k| (k.path.clone(), k.info.clone())).collect();
v.sort_by_key(|(p, _)| p.to_lowercase());
v
}
fn with_synonyms(words: &[&str]) -> KeywordInfo {
KeywordInfo { synonyms: words.iter().map(|w| w.to_string()).collect(), ..KeywordInfo::default() }
}
/// Renaming (or moving) a keyword takes its attributes and its listed children along, photos
/// or not, in the same undo step.
#[test]
fn renaming_a_keyword_carries_its_listing_along() {
let (mut c, ids) = lib(&[&["travel|italy"]]);
c.apply(Op::SetKeyword { path: "travel".into(), info: Some(with_synonyms(&["trip"])) }).unwrap();
c.apply(Op::SetKeyword { path: "travel|Spain".into(), info: Some(KeywordInfo::default()) }).unwrap();
let before = c.to_snapshot();
let op = c.rename_keyword_ops("Travel", "Places|Europe").unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Places|Europe|italy"]);
assert_eq!(
listed(&c),
[("Places|Europe".to_string(), with_synonyms(&["trip"])), ("Places|Europe|Spain".to_string(), KeywordInfo::default())]
);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
/// A keyword only the list holds (no photos) can be renamed too.
#[test]
fn a_keyword_without_photos_can_be_renamed() {
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
let op = c.rename_keyword_ops("weddings", "Events|Weddings").unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c), [("Events|Weddings".to_string(), KeywordInfo::default())]);
}
/// Merging keeps the target's attributes and adds the merged keyword's synonyms to them; a target
/// not listed yet takes the merged keyword's attributes.
#[test]
fn merging_keeps_the_targets_attributes_and_gathers_synonyms() {
let (mut c, _) = lib(&[&["holiday"], &["travel"]]);
let person = KeywordInfo { person: true, ..with_synonyms(&["trip"]) };
c.apply(Op::SetKeyword { path: "travel".into(), info: Some(person.clone()) }).unwrap();
c.apply(Op::SetKeyword {
path: "holiday".into(),
info: Some(KeywordInfo { include_on_export: false, ..with_synonyms(&["vacation", "Trip"]) }),
})
.unwrap();
let op = c.merge_keywords_ops(&["holiday".into()], "travel").unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c), [("travel".to_string(), KeywordInfo { person: true, ..with_synonyms(&["trip", "vacation"]) })]);
// onto a keyword that isn't listed: it takes the merged one's attributes
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(with_synonyms(&["vacation"])) }).unwrap();
let op = c.merge_keywords_ops(&["holiday".into()], "Travel").unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c), [("Travel".to_string(), with_synonyms(&["vacation"]))]);
}
/// Deleting a keyword takes it, and the keywords below it, off the list as well as off photos.
#[test]
fn deleting_a_keyword_takes_it_off_the_list() {
let (mut c, _) = lib(&[&["travel|italy"], &["beach"]]);
for path in ["travel", "travel|spain", "beach"] {
c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap();
}
let before = c.to_snapshot();
let op = c.delete_keyword_ops("travel").unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(listed(&c), [("beach".to_string(), KeywordInfo::default())]);
assert_eq!(tree_paths(&c.keyword_tree()), [("beach".to_string(), 1)]);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
/// Creating a keyword lists it with its attributes, and can tag photos with it in the same undo
/// step. A keyword that exists already (listed, or on a photo) isn't created again.
#[test]
fn creating_a_keyword_lists_it_and_can_tag_photos() {
let (mut c, ids) = lib(&[&["beach"], &[]]);
let before = c.to_snapshot();
let op = c.create_keyword_ops("Events | Weddings", with_synonyms(&["marriage"]), &[ids[1]]).unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(listed(&c), [("Events|Weddings".to_string(), with_synonyms(&["marriage"]))]);
assert_eq!(kws(&c, ids[1]), ["Events|Weddings"]);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
for exists in ["BEACH", "events|weddings"] {
if exists == "events|weddings" {
let op = c.create_keyword_ops("Events|Weddings", KeywordInfo::default(), &[]).unwrap();
c.apply(op).unwrap();
}
assert!(c.create_keyword_ops(exists, KeywordInfo::default(), &[]).is_err(), "{exists}");
}
assert!(c.create_keyword_ops(" | ", KeywordInfo::default(), &[]).is_err());
}
/// Moving a keyword nests it inside another (or takes it to the top level), with its children,
/// on photos and in the list. It can't go inside itself or one of its children, and moving it
/// where it already is changes nothing.
#[test]
fn moving_a_keyword_nests_it_inside_another() {
let (mut c, ids) = lib(&[&["Italy|Rome"], &["Europe"]]);
// typed in any case, the keywords keep the spelling the library has
let op = c.move_keyword_ops("italy", Some("EUROPE"), false).unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Europe|Italy|Rome"]);
let op = c.move_keyword_ops("europe|italy", None, false).unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Italy|Rome"], "back at the top level");
assert!(c.move_keyword_ops("italy", Some("italy|rome"), false).is_err(), "inside its own child");
assert!(c.move_keyword_ops("italy", Some("Italy"), false).is_err(), "inside itself");
assert_eq!(c.move_keyword_ops("italy|rome", Some("italy"), false).unwrap(), Op::Batch { ops: vec![] }, "already there");
assert!(c.move_keyword_ops("lisbon", Some("europe"), false).is_err(), "no such keyword");
}
/// Moving onto a name that is taken (Europe already has a Rome) merges the two, so the move
/// asks first: refused unless merging is allowed.
#[test]
fn moving_onto_a_taken_name_merges_only_when_allowed() {
let (mut c, ids) = lib(&[&["rome"], &["europe|rome"]]);
let err = c.move_keyword_ops("rome", Some("europe"), false).unwrap_err();
assert!(matches!(err, CatalogError::KeywordExists(ref k) if k == "europe|rome"), "{err:?}");
let op = c.move_keyword_ops("rome", Some("europe"), true).unwrap();
c.apply(op).unwrap();
assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["europe|rome".to_string()], vec!["europe|rome".to_string()]));
}
/// Editing a keyword renames it (its last level: it stays where it is) and sets its attributes,
/// in one undo step. A name that is taken is refused: merging is its own action.
#[test]
fn editing_a_keyword_renames_it_and_sets_its_attributes() {
let (mut c, ids) = lib(&[&["travel|italy"], &["travel|spain"]]);
let before = c.to_snapshot();
let person = KeywordInfo { person: true, ..KeywordInfo::default() };
let op = c.edit_keyword_ops("travel|italy", "Italia", person.clone()).unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["travel|Italia"]);
assert_eq!(c.keyword_info("travel|italia"), Some(&person));
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
// attributes alone
let op = c.edit_keyword_ops("travel", "travel", with_synonyms(&["trip"])).unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c), [("travel".to_string(), with_synonyms(&["trip"]))]);
assert!(matches!(c.edit_keyword_ops("travel|italy", "Spain", KeywordInfo::default()), Err(CatalogError::KeywordExists(_))));
assert!(c.edit_keyword_ops("travel|italy", "a|b", KeywordInfo::default()).is_err(), "a name, not a path");
assert!(c.edit_keyword_ops("travel|italy", " ", KeywordInfo::default()).is_err());
}
/// Purging takes off the list the keywords no photo has (nor any keyword below them), in one
/// undo step; keywords photos carry stay.
#[test]
fn purging_takes_unused_keywords_off_the_list() {
let (mut c, _) = lib(&[&["travel|italy"]]);
for path in ["travel", "travel|italy", "travel|spain", "weddings"] {
c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap();
}
let op = c.purge_unused_keywords_ops();
c.apply(op).unwrap();
assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["travel", "travel|italy"]);
assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|italy".to_string(), 1)]);
}
/// Renaming matches names whatever their case, also where a letter's case changes its length
/// (ẞ is three bytes, ß two): the levels below are kept whole, never cut mid-letter.
#[test]
fn renaming_keeps_the_levels_below_whatever_the_case() {
let (mut c, ids) = lib(&[&["straße|nord"], &["ßa|ü"]]);
let op = c.rename_keyword_ops("STRAẞE", "Road").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Road|nord"]);
let op = c.rename_keyword_ops("ẞA", "Weg").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[1]), ["Weg|ü"]);
}
fn exported(c: &Catalog, keywords: &[&str]) -> (Vec<String>, Vec<String>) {
let e = c.export_keywords(&keywords.iter().map(|k| k.to_string()).collect::<Vec<_>>());
(e.flat, e.hierarchical)
}
/// By default a keyword is exported as its name together with the keywords containing it (flat,
/// `dc:subject`), and as its full path (`lr:hierarchicalSubject`); each name once.
#[test]
fn a_keyword_exports_its_name_its_parents_and_its_path() {
let c = Catalog::new();
let (flat, paths) = exported(&c, &["travel|Italy|Rome", "Travel|Spain", "beach"]);
assert_eq!(flat, ["Rome", "Italy", "travel", "Spain", "beach"]);
assert_eq!(paths, ["travel|Italy|Rome", "Travel|Spain", "beach"]);
}
/// A keyword not included on export is left out, path and all; a parent left out (a keyword that
/// only organizes others) isn't exported as a containing keyword, but its children are.
#[test]
fn keywords_left_out_of_export_stay_out() {
let mut c = Catalog::new();
let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() };
c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap();
c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap();
let (flat, paths) = exported(&c, &["Places|Lisbon", "draft"]);
assert_eq!(flat, ["Lisbon"]);
assert_eq!(paths, ["Places|Lisbon"]);
}
/// Without "export containing keywords" only the keyword's own name goes out flat; synonyms go
/// out with it unless "export synonyms" is off.
#[test]
fn containing_keywords_and_synonyms_follow_their_options() {
let mut c = Catalog::new();
let alone = KeywordInfo { export_containing: false, ..with_synonyms(&["Lisboa"]) };
c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(alone) }).unwrap();
let quiet = KeywordInfo { export_synonyms: false, ..with_synonyms(&["marriage"]) };
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(quiet) }).unwrap();
let (flat, _) = exported(&c, &["Places|Lisbon", "Events|Weddings"]);
assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]);
}
/// Names match whatever the case of any letter, not only ASCII ones: changing only the case of
/// "Ärzte" is a rename, not a clash with itself, and moving it where it is changes nothing.
#[test]
fn names_match_whatever_the_case_of_any_letter() {
let (mut c, ids) = lib(&[&["Ärzte|Wien"]]);
let op = c.edit_keyword_ops("ärzte", "ÄRZTE", KeywordInfo::default()).unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["ÄRZTE|Wien"]);
assert_eq!(c.move_keyword_ops("ärzte|wien", Some("ärzte"), false).unwrap(), Op::Batch { ops: vec![] });
let op = c.rename_keyword_ops("ärzte", "Ärzte").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Ärzte|Wien"]);
assert!(c.create_keyword_ops("ärzte", KeywordInfo::default(), &[]).is_err(), "exists");
}
/// A keyword that is only the parent of a listed one ("Events" of "Events|Weddings") is a
/// keyword like any other: it is in the tree, so it can be found, moved and renamed.
#[test]
fn the_parent_of_a_listed_keyword_is_a_keyword() {
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert!(c.has_keyword("events"));
assert_eq!(c.keyword_path("EVENTS").as_deref(), Some("Events"));
let op = c.edit_keyword_ops("events", "Occasions", KeywordInfo::default()).unwrap();
c.apply(op).unwrap();
assert_eq!(tree_paths(&c.keyword_tree()), [("Occasions".to_string(), 0), ("Occasions|Weddings".to_string(), 0)]);
}
/// Photos in Recently Deleted don't count: a keyword only they have isn't in the tree, so it
/// can be created again and is purged as unused, and its spelling there isn't the library's.
#[test]
fn deleted_photos_dont_hold_keywords() {
let (mut c, ids) = lib(&[&["Weddings"], &["travel"]]);
c.apply(Op::SetDeleted { id: ids[0], deleted: true }).unwrap();
assert!(!c.has_keyword("weddings"));
assert!(c.create_keyword_ops("weddings", KeywordInfo::default(), &[]).is_ok());
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
let op = c.purge_unused_keywords_ops();
c.apply(op).unwrap();
assert!(listed(&c).is_empty(), "only a deleted photo has it: unused");
}
/// Merging several keywords moves each keyword under them by the closest one, whatever the
/// order they are given in, and every listing lands where its keyword does: one landing where
/// another is about to leave isn't merged into it.
#[test]
fn merging_overlapping_keywords_keeps_every_listing() {
let (mut c, ids) = lib(&[&["a|b|c"], &["a|c"]]);
let person = KeywordInfo { person: true, ..KeywordInfo::default() };
c.apply(Op::SetKeyword { path: "a|b|c".into(), info: Some(person.clone()) }).unwrap();
c.apply(Op::SetKeyword { path: "a|c".into(), info: Some(with_synonyms(&["sea"])) }).unwrap();
let op = c.merge_keywords_ops(&["a|b".into(), "a|c".into()], "a").unwrap();
c.apply(op).unwrap();
assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["a|c".to_string()], vec!["a".to_string()]));
assert_eq!(listed(&c), [("a".to_string(), with_synonyms(&["sea"])), ("a|c".to_string(), person)]);
// the order of the keywords merged doesn't matter
let merged = |from: [&str; 2]| {
let (mut c, ids) = lib(&[&["a|b|c", "a|d"]]);
let op = c.merge_keywords_ops(&from.map(String::from), "x").unwrap();
c.apply(op).unwrap();
kws(&c, ids[0])
};
assert_eq!(merged(["a", "a|b"]), merged(["a|b", "a"]));
assert_eq!(merged(["a", "a|b"]), ["x|c", "x|d"]);
}
/// One keyword at most is where new keywords go (Put New Keywords Inside This Keyword). Making
/// another one so is one undo step; the mark moves with its keyword (also into a merge) and
/// goes with it.
#[test]
fn one_keyword_is_where_new_keywords_go() {
let (mut c, _) = lib(&[&["Events|Weddings"], &["Travel"]]);
let op = c.set_default_parent_ops(Some("events")).unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"));
let op = c.set_default_parent_ops(Some("travel")).unwrap();
c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent().as_deref(), Some("Travel"), "only one");
assert!(c.set_default_parent_ops(Some("lisbon")).is_err());
let op = c.rename_keyword_ops("travel", "Trips").unwrap();
c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent().as_deref(), Some("Trips"));
let op = c.merge_keywords_ops(&["trips".into()], "Events").unwrap();
c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"), "into the merge");
let op = c.delete_keyword_ops("events").unwrap();
c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent(), None);
let op = c.set_default_parent_ops(None).unwrap();
assert_eq!(op, Op::Batch { ops: vec![] }, "none to clear");
c.apply(undo).unwrap();
assert_eq!(c.default_keyword_parent(), None, "undo of the first: none was");
}
/// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, and the
/// names of `dc:subject` that aren't levels of those paths (an exported `travel|Italy` comes
/// back as one keyword, not three). Without paths, the names as they are.
#[test]
fn a_files_keywords_keep_their_hierarchy() {
let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(from_file(&v(&["Rome", "Italy", "travel", "beach"]), &v(&["travel|Italy|Rome"])), ["travel|Italy|Rome", "beach"]);
assert_eq!(from_file(&v(&["ROME"]), &v(&["travel|Rome", " travel | rome "])), ["travel|Rome"], "each once, cleaned");
assert_eq!(from_file(&v(&["beach", " sea "]), &[]), ["beach", "sea"]);
assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "LightCraft's own sidecars write paths flat");
}
/// A new keyword inside one the library has takes the library's spelling of it: creating
/// "TRAVEL|rome" next to "travel|italy" doesn't respell "travel".
#[test]
fn a_new_keyword_keeps_the_spelling_of_its_parents() {
let (mut c, _) = lib(&[&["travel|italy"]]);
let op = c.create_keyword_ops("TRAVEL|Rome", KeywordInfo::default(), &[]).unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["travel|Rome"]);
assert_eq!(tree_paths(&c.keyword_tree())[0].0, "travel");
}
/// An edit that changes nothing is nothing: no listing, no undo step.
#[test]
fn an_edit_that_changes_nothing_is_nothing() {
let (mut c, _) = lib(&[&["beach"]]);
assert_eq!(c.edit_keyword_ops("beach", "beach", KeywordInfo::default()).unwrap(), Op::Batch { ops: vec![] });
c.apply(Op::SetKeyword { path: "beach".into(), info: Some(with_synonyms(&["shore"])) }).unwrap();
assert_eq!(c.edit_keyword_ops("BEACH", "beach", with_synonyms(&["shore"])).unwrap(), Op::Batch { ops: vec![] });
// renamed with the default attributes: the photos change, nothing is listed
let (mut c, _) = lib(&[&["beach"]]);
let op = c.edit_keyword_ops("beach", "Shore", KeywordInfo::default()).unwrap();
c.apply(op).unwrap();
assert!(listed(&c).is_empty());
}
/// Typing suggests keywords no photo has yet too (created in the Keyword List).
#[test]
fn suggestions_include_keywords_without_photos() {
let (mut c, _) = lib(&[&["beach"]]);
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert_eq!(c.keyword_suggestions(&[], "wed", 5), ["Events|Weddings"]);
assert!(!c.keyword_suggestions(&["events|weddings".into()], "wed", 5).contains(&"Events|Weddings".to_string()), "not one it has");
}
/// A new name typed for a photo goes inside the default parent; a keyword the library has, and
/// a path typed whole, don't.
#[test]
fn typed_new_keywords_go_inside_the_default_parent() {
let (mut c, _) = lib(&[&["beach"], &["Events"]]);
assert_eq!(c.typed_keyword(" Weddings "), "Weddings", "no default parent");
let op = c.set_default_parent_ops(Some("events")).unwrap();
c.apply(op).unwrap();
assert_eq!(c.typed_keyword("Weddings"), "Events|Weddings");
assert_eq!(c.typed_keyword("BEACH"), "BEACH", "the library has it");
assert_eq!(c.typed_keyword("Places|Lisbon"), "Places|Lisbon");
}
/// The keyword list as a text file, as Lightroom Classic writes one: a keyword a line, the
/// keywords inside it indented one tab more, a synonym in braces on a line of its own under its
/// keyword, a keyword left out of export in square brackets; by name.
#[test]
fn the_keyword_list_is_written_as_tab_indented_text() {
let (mut c, _) = lib(&[&["Places|Lisbon", "beach"]]);
c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap();
c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(with_synonyms(&["Lisboa"])) }).unwrap();
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert_eq!(c.keyword_list_text().text, "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n");
}
/// Reading a list gives each keyword's path and attributes: what was written comes back, and a
/// file from elsewhere (Windows line ends, a byte-order mark, blank lines) reads too.
#[test]
fn a_keyword_list_reads_back() {
let read = parse_keyword_list("\u{feff}beach\r\n\r\nEvents\r\n\tWeddings\r\n\t\t{marriage}\r\n[Places]\r\n\tLisbon\r\n").unwrap();
let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() };
assert_eq!(
read,
[
("beach".to_string(), KeywordInfo::default()),
("Events".to_string(), KeywordInfo::default()),
("Events|Weddings".to_string(), with_synonyms(&["marriage"])),
("Places".to_string(), out),
("Places|Lisbon".to_string(), KeywordInfo::default()),
]
);
}
/// Photo Supreme's Formatted Vocabulary File is the same format: plain text with Windows line
/// ends and no byte-order mark, a top-level category in square brackets (an organizing keyword,
/// left out of export), keywords a tab further in at each level.
#[test]
fn a_photo_supreme_vocabulary_file_reads() {
let file = "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n\t\t\tAlfama\r\n\t\tPorto\r\n\tSpain\r\n\t\tSeville\r\n";
let read = parse_keyword_list(file).unwrap();
let paths: Vec<&str> = read.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
["Places", "Portugal", "Portugal|Lisbon", "Portugal|Lisbon|Alfama", "Portugal|Porto", "Spain", "Spain|Seville"]
.map(|p| if p == "Places" { p.to_string() } else { format!("Places|{p}") })
);
assert!(!read[0].1.include_on_export, "the category organizes: it isn't exported");
assert!(read[1..].iter().all(|(_, i)| i.include_on_export));
}
/// A list that can't be read says which line and why, and nothing of it is taken.
#[test]
fn a_bad_keyword_list_says_which_line() {
let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string();
assert!(err("Events\n\t\tWeddings\n").contains("line 2"), "two levels down at once");
assert!(err("{marriage}\n").contains("line 1"), "a synonym of nothing");
assert!(err("Events\nTravel|Lisbon\n").contains("line 2"), "“|” separates levels here");
let deep: String = (0..70).map(|i| format!("{}k{i}\n", "\t".repeat(i))).collect();
assert!(err(&deep).contains("line 65"), "a file is input: 64 levels at most");
}
/// Files from elsewhere read as they were meant or say why not: classic Mac line ends (a lone
/// carriage return) are lines; indenting with spaces, which would flatten the hierarchy, is
/// refused at its line; so are control characters in a name and "|" in a synonym.
#[test]
fn a_keyword_list_from_elsewhere_reads_or_says_why_not() {
let paths = |text: &str| parse_keyword_list(text).unwrap().into_iter().map(|(p, _)| p).collect::<Vec<_>>();
assert_eq!(paths("Events\r\tWeddings\rPlaces\r"), ["Events", "Events|Weddings", "Places"]);
let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string();
assert!(err("Events\n Weddings\n").contains("line 2"), "space indentation");
assert!(err("Events\n\t Weddings\n").contains("line 2"), "a space after the tabs");
assert!(err("Events\nx\u{0}y\n").contains("line 2"), "a control character");
assert!(err("Events\n\t{sea|shore}\n").contains("line 2"), "“|” in a synonym");
}
/// A keyword repeated in another case is one keyword, and the keywords inside either take the
/// first spelling.
#[test]
fn a_repeated_keyword_keeps_its_first_spelling() {
let read = parse_keyword_list("Events\n\tWeddings\nevents\n\tBirthdays\n").unwrap();
assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["Events", "Events|Weddings", "Events|Birthdays"]);
}
/// What the list can't hold is left out and named, so the file always reads back: a name the
/// format would read as something else (in brackets or braces), one with a line break, a
/// synonym with one, a keyword nested deeper than a list goes; and the keywords inside one left
/// out go with it.
#[test]
fn an_exported_keyword_list_always_reads_back() {
let deep = (0..=MAX_LEVELS).map(|i| format!("level {i}")).collect::<Vec<_>>().join("|");
let (mut c, _) = lib(&[&["Travel|[draft]", "{todo}|Lisbon", "Weddings", "Line\nbreak", deep.as_str()]]);
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["marriage", "two\nlines"])) }).unwrap();
let KeywordListFile { text, unwritable, .. } = c.keyword_list_text();
let read = parse_keyword_list(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
assert!(read.iter().any(|(p, _)| p == "Travel"));
assert!(read.iter().all(|(p, _)| !p.contains("draft") && !p.contains("todo") && !p.contains("Line")), "{text}");
let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone());
assert_eq!(weddings, Some(vec!["marriage".to_string()]));
let deepest = read.iter().map(|(p, _)| p.split(SEP).count()).max();
assert_eq!(deepest, Some(MAX_LEVELS));
assert!(unwritable.contains(&"Travel|[draft]".to_string()), "{unwritable:?}");
assert!(unwritable.contains(&"{todo}".to_string()), "{unwritable:?}");
assert!(unwritable.contains(&"Line\nbreak".to_string()), "{unwritable:?}");
assert!(unwritable.contains(&"Weddings {two\nlines}".to_string()), "{unwritable:?}");
assert!(unwritable.iter().any(|u| u.ends_with(&format!("level {MAX_LEVELS}"))), "{unwritable:?}");
}
/// The list leaves out only what it must, and names only what's lost: a name starting with a
/// byte-order mark would lose it, but a synonym in braces reads back, and a synonym that's empty
/// or another's in a different case is no loss. It also says which keywords it wrote.
#[test]
fn an_exported_keyword_list_names_only_what_it_loses() {
let (mut c, _) = lib(&[&["\u{feff}Albums", "{draft", "Weddings"]]);
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["Marriage", "marriage", " ", "{vows}"])) }).unwrap();
let out = c.keyword_list_text();
let read = parse_keyword_list(&out.text).unwrap_or_else(|e| panic!("{e}\n{}", out.text));
assert_eq!(out.unwritable, ["\u{feff}Albums"]);
assert_eq!(out.written, ["Weddings", "{draft"]);
assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["Weddings", "{draft"]);
let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone());
assert_eq!(weddings, Some(vec!["Marriage".to_string(), "{vows}".to_string()]));
}
/// A big list reads and imports at once, against a big library too: 40,000 keywords used to take
/// half a minute to read, and checking 5,000 new ones against 20,000 photos over a minute, with
/// the app frozen.
#[test]
fn a_big_keyword_list_imports_quickly() {
let mut text = String::new();
for g in 0..400 {
text.push_str(&format!("group {g}\n"));
for k in 0..99 {
text.push_str(&format!("\tkeyword {g}-{k}\n"));
}
}
let t0 = std::time::Instant::now();
let read = parse_keyword_list(&text).unwrap();
assert_eq!(read.len(), 40_000);
let mut c = Catalog::new();
let mut ops = Vec::new();
for i in 0..20_000u32 {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01");
p.meta.keywords = (0..5).map(|k| format!("library {}|tag {}", k, (i + k) % 3000)).collect();
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
c.apply(Op::Batch { ops }).unwrap();
let (op, added, _) = c.import_keywords_ops(&read[..5_000]);
c.apply(op).unwrap();
assert_eq!(added, 5_000);
let took = t0.elapsed();
assert!(took < std::time::Duration::from_secs(10), "{took:?}");
}
/// Importing a list adds the keywords the library doesn't have, with their attributes, and gives
/// those it has the list's synonyms (their own attributes stay), in one undo step.
#[test]
fn importing_a_keyword_list_adds_what_is_missing() {
let (mut c, _) = lib(&[&["Events|Weddings"]]);
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo { person: true, ..with_synonyms(&["marriage"]) }) }).unwrap();
let before = c.to_snapshot();
let read = parse_keyword_list("events\n\tweddings\n\t\t{nuptials}\n\tBirthdays\n[Drafts]\n").unwrap();
let (op, added, updated) = c.import_keywords_ops(&read);
assert_eq!((added, updated), (2, 1));
let undo = c.apply(op).unwrap();
assert_eq!(c.keyword_info("Events|Weddings"), Some(&KeywordInfo { person: true, ..with_synonyms(&["marriage", "nuptials"]) }));
assert!(c.has_keyword("Events|Birthdays"));
assert_eq!(
listed(&c).iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(),
["Drafts", "Events|Birthdays", "Events|Weddings"],
"in the library's spelling"
);
assert!(!c.keyword_info("Drafts").unwrap().include_on_export);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
#[test]
fn suggestions_rank_co_occurrence_and_prefixes() {
let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]);
// co-occurring with `dog` first (park twice), then the most used
let s = c.keyword_suggestions(&["dog".into()], "", 3);
assert_eq!(s, ["park", "ball", "beach"]);
let s = c.keyword_suggestions(&[], "", 2);
assert_eq!(s, ["cat", "dog"]);
let s = c.keyword_suggestions(&["park".into()], "pa", 5);
assert_eq!(s, ["parade"]);
let s = c.keyword_suggestions(&[], "E", 5);
assert_eq!(s, ["eagle", "beach", "parade"], "prefix matches before substring matches");
}
/// A flat keyword already covered by a hierarchical level only in another spelling is not kept
/// again, whatever the size of either list (the per-flat-keyword search of every level, twice
/// lower-cased per comparison, was quadratic: CWE-407).
#[test]
fn from_file_matches_levels_in_any_case() {
let flat = ["Rome".to_string(), "Seaside".to_string()];
let hierarchical = ["travel|ITALY|rome".to_string()];
assert_eq!(from_file(&flat, &hierarchical), ["travel|ITALY|rome", "Seaside"]);
}
/// The same synonym, in any spelling, is kept once (the search of each keyword's growing
/// synonym list per line was quadratic, with lower-cased copies made per comparison).(CWE-407)
#[test]
fn parse_keyword_list_keeps_a_synonym_once() {
let read = parse_keyword_list("Travel\n\t{Trip}\n\t{journey}\n\t{TRIP}\n").unwrap();
assert_eq!(read.len(), 1);
assert_eq!(read[0].1.synonyms, ["Trip", "journey"]);
}
/// A keyword split into far more levels than a list file accepts is cut at [`MAX_LEVELS`] in the
/// tree, whose level rows hold every prefix twice: unbounded it made the stored bytes quadratic
/// in the level count of one short keyword (CWE-400).
#[test]
fn keyword_tree_caps_a_keyword_at_the_list_depth() {
let deep: Vec<String> = (0..4096).map(|i| format!("k{i}")).collect();
let joined = deep.join("|");
let (c, _) = lib(&[&[joined.as_str()]]);
let deepest = tree_paths(&c.keyword_tree()).iter().map(|(p, _)| p.split(SEP).count()).max().unwrap_or(0);
assert_eq!(deepest, MAX_LEVELS);
}
}
mod tests;
+706
View File
@@ -0,0 +1,706 @@
use super::*;
use crate::{Filter, Photo, PhotoId, Source};
fn lib(kws: &[&[&str]]) -> (Catalog, Vec<PhotoId>) {
let mut c = Catalog::new();
let mut ids = Vec::new();
for k in kws {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01");
p.meta.keywords = k.iter().map(|s| s.to_string()).collect();
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
(c, ids)
}
fn kws(c: &Catalog, id: PhotoId) -> Vec<String> {
c.photo(id).unwrap().meta.keywords.clone()
}
#[test]
fn tree_counts_photos_per_level() {
let (c, _) = lib(&[&["travel|Italy|Rome", "beach"], &["Travel|italy"], &["travel|France", "travel|italy|rome"], &["beach"]]);
let t = c.keyword_tree();
assert_eq!(t.iter().map(|n| (n.name.as_str(), n.count)).collect::<Vec<_>>(), vec![("beach", 2), ("travel", 3)]);
let travel = &t[1];
assert_eq!(travel.children.iter().map(|n| (n.path.as_str(), n.count)).collect::<Vec<_>>(), vec![("travel|France", 1), ("travel|Italy", 3)]);
assert_eq!(travel.children[1].children[0].path, "travel|Italy|Rome");
assert_eq!(travel.children[1].children[0].count, 2);
// filtering by a parent finds its children
let f = Filter { keyword: Some("travel|italy".into()), ..Default::default() };
assert_eq!(c.query(&f, &Default::default()).len(), 3);
}
/// A keyword written twice on a photo, or in two spellings, counts the photo once; the next
/// photo with it counts again.
#[test]
fn tree_counts_a_repeated_keyword_once_per_photo() {
let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]);
let t = c.keyword_tree();
assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]);
}
#[test]
fn rename_delete_merge_are_single_undoable_batches() {
let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]);
let before = c.to_snapshot();
let op = c.rename_keyword_ops("travel|italy", "Europe|Italy").unwrap();
let Op::Batch { ops } = &op else { panic!() };
assert_eq!(ops.len(), 3, "only photos that change");
let inv = c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Europe|Italy|rome", "beach"]);
assert_eq!(kws(&c, ids[1]), ["Europe|Italy"]);
c.apply(inv).unwrap();
assert_eq!(c.to_snapshot(), before);
// merge `italia` into the existing hierarchical keyword: duplicates collapse
let op = c.merge_keywords_ops(&["italia".into()], "travel|italy").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[2]), ["travel|italy"]);
// delete removes the keyword and its children everywhere
let op = c.delete_keyword_ops("TRAVEL").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["beach"]);
assert!(kws(&c, ids[1]).is_empty());
assert_eq!(c.keywords(), vec![("beach".to_string(), 1), ("sea".to_string(), 1)]);
// invalid requests
assert!(c.rename_keyword_ops("beach", " ").is_err());
assert!(c.rename_keyword_ops("a", "a|b").is_err());
assert!(c.merge_keywords_ops(&["a".into()], "a|b").is_err());
assert_eq!(clean(" a | |b "), "a|b");
}
fn tree_paths(nodes: &[KeywordNode]) -> Vec<(String, usize)> {
let mut out = Vec::new();
for n in nodes {
out.push((n.path.clone(), n.count));
out.extend(tree_paths(&n.children));
}
out
}
/// A keyword can be listed before any photo has it (Lightroom Classic's Create Keyword Tag):
/// it is in the tree with no photos, its parents too, and undo takes it away.
#[test]
fn a_keyword_listed_with_no_photos_is_in_the_tree() {
let (mut c, _) = lib(&[&["travel|France"]]);
let undo = c.apply(Op::SetKeyword { path: " travel | Italy ".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1), ("travel|Italy".to_string(), 0)]);
assert_eq!(c.keyword_info("TRAVEL|italy"), Some(&KeywordInfo::default()), "found whatever the case");
c.apply(undo).unwrap();
assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1)]);
assert_eq!(c.keyword_info("travel|Italy"), None);
}
/// A listed keyword needs a name, and its attributes start as Lightroom Classic's do: exported,
/// with the keywords containing it and its synonyms; not a person.
#[test]
fn a_listed_keyword_needs_a_name_and_starts_exported() {
let mut c = Catalog::new();
assert!(c.apply(Op::SetKeyword { path: " | ".into(), info: Some(KeywordInfo::default()) }).is_err());
let info = KeywordInfo::default();
assert!(info.include_on_export && info.export_containing && info.export_synonyms && !info.person && info.synonyms.is_empty());
}
/// Changing a listed keyword's attributes is undone to the attributes it had, under the name as
/// it was written.
#[test]
fn changing_a_listed_keyword_is_undone_to_what_it_was() {
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
let person = KeywordInfo { person: true, synonyms: vec!["Marriage".into()], ..KeywordInfo::default() };
let undo = c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(person.clone()) }).unwrap();
assert_eq!(c.keyword_info("Weddings"), Some(&person));
assert_eq!(undo, Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) });
c.apply(undo).unwrap();
assert_eq!(c.keyword_info("weddings"), Some(&KeywordInfo::default()));
}
fn listed(c: &Catalog) -> Vec<(String, KeywordInfo)> {
let mut v: Vec<(String, KeywordInfo)> = c.listed_keywords().map(|k| (k.path.clone(), k.info.clone())).collect();
v.sort_by_key(|(p, _)| p.to_lowercase());
v
}
fn with_synonyms(words: &[&str]) -> KeywordInfo {
KeywordInfo { synonyms: words.iter().map(|w| w.to_string()).collect(), ..KeywordInfo::default() }
}
/// Renaming (or moving) a keyword takes its attributes and its listed children along, photos
/// or not, in the same undo step.
#[test]
fn renaming_a_keyword_carries_its_listing_along() {
let (mut c, ids) = lib(&[&["travel|italy"]]);
c.apply(Op::SetKeyword { path: "travel".into(), info: Some(with_synonyms(&["trip"])) }).unwrap();
c.apply(Op::SetKeyword { path: "travel|Spain".into(), info: Some(KeywordInfo::default()) }).unwrap();
let before = c.to_snapshot();
let op = c.rename_keyword_ops("Travel", "Places|Europe").unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Places|Europe|italy"]);
assert_eq!(listed(&c), [("Places|Europe".to_string(), with_synonyms(&["trip"])), ("Places|Europe|Spain".to_string(), KeywordInfo::default())]);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
/// A keyword only the list holds (no photos) can be renamed too.
#[test]
fn a_keyword_without_photos_can_be_renamed() {
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
let op = c.rename_keyword_ops("weddings", "Events|Weddings").unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c), [("Events|Weddings".to_string(), KeywordInfo::default())]);
}
/// Merging keeps the target's attributes and adds the merged keyword's synonyms to them; a target
/// not listed yet takes the merged keyword's attributes.
#[test]
fn merging_keeps_the_targets_attributes_and_gathers_synonyms() {
let (mut c, _) = lib(&[&["holiday"], &["travel"]]);
let person = KeywordInfo { person: true, ..with_synonyms(&["trip"]) };
c.apply(Op::SetKeyword { path: "travel".into(), info: Some(person.clone()) }).unwrap();
c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(KeywordInfo { include_on_export: false, ..with_synonyms(&["vacation", "Trip"]) }) })
.unwrap();
let op = c.merge_keywords_ops(&["holiday".into()], "travel").unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c), [("travel".to_string(), KeywordInfo { person: true, ..with_synonyms(&["trip", "vacation"]) })]);
// onto a keyword that isn't listed: it takes the merged one's attributes
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(with_synonyms(&["vacation"])) }).unwrap();
let op = c.merge_keywords_ops(&["holiday".into()], "Travel").unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c), [("Travel".to_string(), with_synonyms(&["vacation"]))]);
}
/// Deleting a keyword takes it, and the keywords below it, off the list as well as off photos.
#[test]
fn deleting_a_keyword_takes_it_off_the_list() {
let (mut c, _) = lib(&[&["travel|italy"], &["beach"]]);
for path in ["travel", "travel|spain", "beach"] {
c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap();
}
let before = c.to_snapshot();
let op = c.delete_keyword_ops("travel").unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(listed(&c), [("beach".to_string(), KeywordInfo::default())]);
assert_eq!(tree_paths(&c.keyword_tree()), [("beach".to_string(), 1)]);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
/// Creating a keyword lists it with its attributes, and can tag photos with it in the same undo
/// step. A keyword that exists already (listed, or on a photo) isn't created again.
#[test]
fn creating_a_keyword_lists_it_and_can_tag_photos() {
let (mut c, ids) = lib(&[&["beach"], &[]]);
let before = c.to_snapshot();
let op = c.create_keyword_ops("Events | Weddings", with_synonyms(&["marriage"]), &[ids[1]]).unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(listed(&c), [("Events|Weddings".to_string(), with_synonyms(&["marriage"]))]);
assert_eq!(kws(&c, ids[1]), ["Events|Weddings"]);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
for exists in ["BEACH", "events|weddings"] {
if exists == "events|weddings" {
let op = c.create_keyword_ops("Events|Weddings", KeywordInfo::default(), &[]).unwrap();
c.apply(op).unwrap();
}
assert!(c.create_keyword_ops(exists, KeywordInfo::default(), &[]).is_err(), "{exists}");
}
assert!(c.create_keyword_ops(" | ", KeywordInfo::default(), &[]).is_err());
}
/// Moving a keyword nests it inside another (or takes it to the top level), with its children,
/// on photos and in the list. It can't go inside itself or one of its children, and moving it
/// where it already is changes nothing.
#[test]
fn moving_a_keyword_nests_it_inside_another() {
let (mut c, ids) = lib(&[&["Italy|Rome"], &["Europe"]]);
// typed in any case, the keywords keep the spelling the library has
let op = c.move_keyword_ops("italy", Some("EUROPE"), false).unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Europe|Italy|Rome"]);
let op = c.move_keyword_ops("europe|italy", None, false).unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Italy|Rome"], "back at the top level");
assert!(c.move_keyword_ops("italy", Some("italy|rome"), false).is_err(), "inside its own child");
assert!(c.move_keyword_ops("italy", Some("Italy"), false).is_err(), "inside itself");
assert_eq!(c.move_keyword_ops("italy|rome", Some("italy"), false).unwrap(), Op::Batch { ops: vec![] }, "already there");
assert!(c.move_keyword_ops("lisbon", Some("europe"), false).is_err(), "no such keyword");
}
/// Moving onto a name that is taken (Europe already has a Rome) merges the two, so the move
/// asks first: refused unless merging is allowed.
#[test]
fn moving_onto_a_taken_name_merges_only_when_allowed() {
let (mut c, ids) = lib(&[&["rome"], &["europe|rome"]]);
let err = c.move_keyword_ops("rome", Some("europe"), false).unwrap_err();
assert!(matches!(err, CatalogError::KeywordExists(ref k) if k == "europe|rome"), "{err:?}");
let op = c.move_keyword_ops("rome", Some("europe"), true).unwrap();
c.apply(op).unwrap();
assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["europe|rome".to_string()], vec!["europe|rome".to_string()]));
}
/// Editing a keyword renames it (its last level: it stays where it is) and sets its attributes,
/// in one undo step. A name that is taken is refused: merging is its own action.
#[test]
fn editing_a_keyword_renames_it_and_sets_its_attributes() {
let (mut c, ids) = lib(&[&["travel|italy"], &["travel|spain"]]);
let before = c.to_snapshot();
let person = KeywordInfo { person: true, ..KeywordInfo::default() };
let op = c.edit_keyword_ops("travel|italy", "Italia", person.clone()).unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["travel|Italia"]);
assert_eq!(c.keyword_info("travel|italia"), Some(&person));
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
// attributes alone
let op = c.edit_keyword_ops("travel", "travel", with_synonyms(&["trip"])).unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c), [("travel".to_string(), with_synonyms(&["trip"]))]);
assert!(matches!(c.edit_keyword_ops("travel|italy", "Spain", KeywordInfo::default()), Err(CatalogError::KeywordExists(_))));
assert!(c.edit_keyword_ops("travel|italy", "a|b", KeywordInfo::default()).is_err(), "a name, not a path");
assert!(c.edit_keyword_ops("travel|italy", " ", KeywordInfo::default()).is_err());
}
/// Purging takes off the list the keywords no photo has (nor any keyword below them), in one
/// undo step; keywords photos carry stay.
#[test]
fn purging_takes_unused_keywords_off_the_list() {
let (mut c, _) = lib(&[&["travel|italy"]]);
for path in ["travel", "travel|italy", "travel|spain", "weddings"] {
c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap();
}
let op = c.purge_unused_keywords_ops();
c.apply(op).unwrap();
assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["travel", "travel|italy"]);
assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|italy".to_string(), 1)]);
}
/// Renaming matches names whatever their case, also where a letter's case changes its length
/// (ẞ is three bytes, ß two): the levels below are kept whole, never cut mid-letter.
#[test]
fn renaming_keeps_the_levels_below_whatever_the_case() {
let (mut c, ids) = lib(&[&["straße|nord"], &["ßa|ü"]]);
let op = c.rename_keyword_ops("STRAẞE", "Road").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Road|nord"]);
let op = c.rename_keyword_ops("ẞA", "Weg").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[1]), ["Weg|ü"]);
}
fn exported(c: &Catalog, keywords: &[&str]) -> (Vec<String>, Vec<String>) {
let e = c.export_keywords(&keywords.iter().map(|k| k.to_string()).collect::<Vec<_>>());
(e.flat, e.hierarchical)
}
/// By default a keyword is exported as its name together with the keywords containing it (flat,
/// `dc:subject`), and as its full path (`lr:hierarchicalSubject`); each name once.
#[test]
fn a_keyword_exports_its_name_its_parents_and_its_path() {
let c = Catalog::new();
let (flat, paths) = exported(&c, &["travel|Italy|Rome", "Travel|Spain", "beach"]);
assert_eq!(flat, ["Rome", "Italy", "travel", "Spain", "beach"]);
assert_eq!(paths, ["travel|Italy|Rome", "Travel|Spain", "beach"]);
}
/// A keyword not included on export is left out, path and all; a parent left out (a keyword that
/// only organizes others) isn't exported as a containing keyword, but its children are.
#[test]
fn keywords_left_out_of_export_stay_out() {
let mut c = Catalog::new();
let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() };
c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap();
c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap();
let (flat, paths) = exported(&c, &["Places|Lisbon", "draft"]);
assert_eq!(flat, ["Lisbon"]);
assert_eq!(paths, ["Places|Lisbon"]);
}
/// Without "export containing keywords" only the keyword's own name goes out flat; synonyms go
/// out with it unless "export synonyms" is off.
#[test]
fn containing_keywords_and_synonyms_follow_their_options() {
let mut c = Catalog::new();
let alone = KeywordInfo { export_containing: false, ..with_synonyms(&["Lisboa"]) };
c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(alone) }).unwrap();
let quiet = KeywordInfo { export_synonyms: false, ..with_synonyms(&["marriage"]) };
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(quiet) }).unwrap();
let (flat, _) = exported(&c, &["Places|Lisbon", "Events|Weddings"]);
assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]);
}
/// Names match whatever the case of any letter, not only ASCII ones: changing only the case of
/// "Ärzte" is a rename, not a clash with itself, and moving it where it is changes nothing.
#[test]
fn names_match_whatever_the_case_of_any_letter() {
let (mut c, ids) = lib(&[&["Ärzte|Wien"]]);
let op = c.edit_keyword_ops("ärzte", "ÄRZTE", KeywordInfo::default()).unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["ÄRZTE|Wien"]);
assert_eq!(c.move_keyword_ops("ärzte|wien", Some("ärzte"), false).unwrap(), Op::Batch { ops: vec![] });
let op = c.rename_keyword_ops("ärzte", "Ärzte").unwrap();
c.apply(op).unwrap();
assert_eq!(kws(&c, ids[0]), ["Ärzte|Wien"]);
assert!(c.create_keyword_ops("ärzte", KeywordInfo::default(), &[]).is_err(), "exists");
}
/// A keyword that is only the parent of a listed one ("Events" of "Events|Weddings") is a
/// keyword like any other: it is in the tree, so it can be found, moved and renamed.
#[test]
fn the_parent_of_a_listed_keyword_is_a_keyword() {
let mut c = Catalog::new();
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert!(c.has_keyword("events"));
assert_eq!(c.keyword_path("EVENTS").as_deref(), Some("Events"));
let op = c.edit_keyword_ops("events", "Occasions", KeywordInfo::default()).unwrap();
c.apply(op).unwrap();
assert_eq!(tree_paths(&c.keyword_tree()), [("Occasions".to_string(), 0), ("Occasions|Weddings".to_string(), 0)]);
}
/// Photos in Recently Deleted don't count: a keyword only they have isn't in the tree, so it
/// can be created again and is purged as unused, and its spelling there isn't the library's.
#[test]
fn deleted_photos_dont_hold_keywords() {
let (mut c, ids) = lib(&[&["Weddings"], &["travel"]]);
c.apply(Op::SetDeleted { id: ids[0], deleted: true }).unwrap();
assert!(!c.has_keyword("weddings"));
assert!(c.create_keyword_ops("weddings", KeywordInfo::default(), &[]).is_ok());
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
let op = c.purge_unused_keywords_ops();
c.apply(op).unwrap();
assert!(listed(&c).is_empty(), "only a deleted photo has it: unused");
}
/// Merging several keywords moves each keyword under them by the closest one, whatever the
/// order they are given in, and every listing lands where its keyword does: one landing where
/// another is about to leave isn't merged into it.
#[test]
fn merging_overlapping_keywords_keeps_every_listing() {
let (mut c, ids) = lib(&[&["a|b|c"], &["a|c"]]);
let person = KeywordInfo { person: true, ..KeywordInfo::default() };
c.apply(Op::SetKeyword { path: "a|b|c".into(), info: Some(person.clone()) }).unwrap();
c.apply(Op::SetKeyword { path: "a|c".into(), info: Some(with_synonyms(&["sea"])) }).unwrap();
let op = c.merge_keywords_ops(&["a|b".into(), "a|c".into()], "a").unwrap();
c.apply(op).unwrap();
assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["a|c".to_string()], vec!["a".to_string()]));
assert_eq!(listed(&c), [("a".to_string(), with_synonyms(&["sea"])), ("a|c".to_string(), person)]);
// the order of the keywords merged doesn't matter
let merged = |from: [&str; 2]| {
let (mut c, ids) = lib(&[&["a|b|c", "a|d"]]);
let op = c.merge_keywords_ops(&from.map(String::from), "x").unwrap();
c.apply(op).unwrap();
kws(&c, ids[0])
};
assert_eq!(merged(["a", "a|b"]), merged(["a|b", "a"]));
assert_eq!(merged(["a", "a|b"]), ["x|c", "x|d"]);
}
/// One keyword at most is where new keywords go (Put New Keywords Inside This Keyword). Making
/// another one so is one undo step; the mark moves with its keyword (also into a merge) and
/// goes with it.
#[test]
fn one_keyword_is_where_new_keywords_go() {
let (mut c, _) = lib(&[&["Events|Weddings"], &["Travel"]]);
let op = c.set_default_parent_ops(Some("events")).unwrap();
let undo = c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"));
let op = c.set_default_parent_ops(Some("travel")).unwrap();
c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent().as_deref(), Some("Travel"), "only one");
assert!(c.set_default_parent_ops(Some("lisbon")).is_err());
let op = c.rename_keyword_ops("travel", "Trips").unwrap();
c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent().as_deref(), Some("Trips"));
let op = c.merge_keywords_ops(&["trips".into()], "Events").unwrap();
c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"), "into the merge");
let op = c.delete_keyword_ops("events").unwrap();
c.apply(op).unwrap();
assert_eq!(c.default_keyword_parent(), None);
let op = c.set_default_parent_ops(None).unwrap();
assert_eq!(op, Op::Batch { ops: vec![] }, "none to clear");
c.apply(undo).unwrap();
assert_eq!(c.default_keyword_parent(), None, "undo of the first: none was");
}
/// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, and the
/// names of `dc:subject` that aren't levels of those paths (an exported `travel|Italy` comes
/// back as one keyword, not three). Without paths, the names as they are.
#[test]
fn a_files_keywords_keep_their_hierarchy() {
let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(from_file(&v(&["Rome", "Italy", "travel", "beach"]), &v(&["travel|Italy|Rome"])), ["travel|Italy|Rome", "beach"]);
assert_eq!(from_file(&v(&["ROME"]), &v(&["travel|Rome", " travel | rome "])), ["travel|Rome"], "each once, cleaned");
assert_eq!(from_file(&v(&["beach", " sea "]), &[]), ["beach", "sea"]);
assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "LightCraft's own sidecars write paths flat");
}
/// A new keyword inside one the library has takes the library's spelling of it: creating
/// "TRAVEL|rome" next to "travel|italy" doesn't respell "travel".
#[test]
fn a_new_keyword_keeps_the_spelling_of_its_parents() {
let (mut c, _) = lib(&[&["travel|italy"]]);
let op = c.create_keyword_ops("TRAVEL|Rome", KeywordInfo::default(), &[]).unwrap();
c.apply(op).unwrap();
assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["travel|Rome"]);
assert_eq!(tree_paths(&c.keyword_tree())[0].0, "travel");
}
/// An edit that changes nothing is nothing: no listing, no undo step.
#[test]
fn an_edit_that_changes_nothing_is_nothing() {
let (mut c, _) = lib(&[&["beach"]]);
assert_eq!(c.edit_keyword_ops("beach", "beach", KeywordInfo::default()).unwrap(), Op::Batch { ops: vec![] });
c.apply(Op::SetKeyword { path: "beach".into(), info: Some(with_synonyms(&["shore"])) }).unwrap();
assert_eq!(c.edit_keyword_ops("BEACH", "beach", with_synonyms(&["shore"])).unwrap(), Op::Batch { ops: vec![] });
// renamed with the default attributes: the photos change, nothing is listed
let (mut c, _) = lib(&[&["beach"]]);
let op = c.edit_keyword_ops("beach", "Shore", KeywordInfo::default()).unwrap();
c.apply(op).unwrap();
assert!(listed(&c).is_empty());
}
/// Typing suggests keywords no photo has yet too (created in the Keyword List).
#[test]
fn suggestions_include_keywords_without_photos() {
let (mut c, _) = lib(&[&["beach"]]);
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert_eq!(c.keyword_suggestions(&[], "wed", 5), ["Events|Weddings"]);
assert!(!c.keyword_suggestions(&["events|weddings".into()], "wed", 5).contains(&"Events|Weddings".to_string()), "not one it has");
}
/// A new name typed for a photo goes inside the default parent; a keyword the library has, and
/// a path typed whole, don't.
#[test]
fn typed_new_keywords_go_inside_the_default_parent() {
let (mut c, _) = lib(&[&["beach"], &["Events"]]);
assert_eq!(c.typed_keyword(" Weddings "), "Weddings", "no default parent");
let op = c.set_default_parent_ops(Some("events")).unwrap();
c.apply(op).unwrap();
assert_eq!(c.typed_keyword("Weddings"), "Events|Weddings");
assert_eq!(c.typed_keyword("BEACH"), "BEACH", "the library has it");
assert_eq!(c.typed_keyword("Places|Lisbon"), "Places|Lisbon");
}
/// The keyword list as a text file, as Lightroom Classic writes one: a keyword a line, the
/// keywords inside it indented one tab more, a synonym in braces on a line of its own under its
/// keyword, a keyword left out of export in square brackets; by name.
#[test]
fn the_keyword_list_is_written_as_tab_indented_text() {
let (mut c, _) = lib(&[&["Places|Lisbon", "beach"]]);
c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap();
c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(with_synonyms(&["Lisboa"])) }).unwrap();
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert_eq!(c.keyword_list_text().text, "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n");
}
/// Reading a list gives each keyword's path and attributes: what was written comes back, and a
/// file from elsewhere (Windows line ends, a byte-order mark, blank lines) reads too.
#[test]
fn a_keyword_list_reads_back() {
let read = parse_keyword_list("\u{feff}beach\r\n\r\nEvents\r\n\tWeddings\r\n\t\t{marriage}\r\n[Places]\r\n\tLisbon\r\n").unwrap();
let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() };
assert_eq!(
read,
[
("beach".to_string(), KeywordInfo::default()),
("Events".to_string(), KeywordInfo::default()),
("Events|Weddings".to_string(), with_synonyms(&["marriage"])),
("Places".to_string(), out),
("Places|Lisbon".to_string(), KeywordInfo::default()),
]
);
}
/// Photo Supreme's Formatted Vocabulary File is the same format: plain text with Windows line
/// ends and no byte-order mark, a top-level category in square brackets (an organizing keyword,
/// left out of export), keywords a tab further in at each level.
#[test]
fn a_photo_supreme_vocabulary_file_reads() {
let file = "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n\t\t\tAlfama\r\n\t\tPorto\r\n\tSpain\r\n\t\tSeville\r\n";
let read = parse_keyword_list(file).unwrap();
let paths: Vec<&str> = read.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
["Places", "Portugal", "Portugal|Lisbon", "Portugal|Lisbon|Alfama", "Portugal|Porto", "Spain", "Spain|Seville"].map(|p| if p == "Places" {
p.to_string()
} else {
format!("Places|{p}")
})
);
assert!(!read[0].1.include_on_export, "the category organizes: it isn't exported");
assert!(read[1..].iter().all(|(_, i)| i.include_on_export));
}
/// A list that can't be read says which line and why, and nothing of it is taken.
#[test]
fn a_bad_keyword_list_says_which_line() {
let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string();
assert!(err("Events\n\t\tWeddings\n").contains("line 2"), "two levels down at once");
assert!(err("{marriage}\n").contains("line 1"), "a synonym of nothing");
assert!(err("Events\nTravel|Lisbon\n").contains("line 2"), "“|” separates levels here");
let deep: String = (0..70).map(|i| format!("{}k{i}\n", "\t".repeat(i))).collect();
assert!(err(&deep).contains("line 65"), "a file is input: 64 levels at most");
}
/// Files from elsewhere read as they were meant or say why not: classic Mac line ends (a lone
/// carriage return) are lines; indenting with spaces, which would flatten the hierarchy, is
/// refused at its line; so are control characters in a name and "|" in a synonym.
#[test]
fn a_keyword_list_from_elsewhere_reads_or_says_why_not() {
let paths = |text: &str| parse_keyword_list(text).unwrap().into_iter().map(|(p, _)| p).collect::<Vec<_>>();
assert_eq!(paths("Events\r\tWeddings\rPlaces\r"), ["Events", "Events|Weddings", "Places"]);
let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string();
assert!(err("Events\n Weddings\n").contains("line 2"), "space indentation");
assert!(err("Events\n\t Weddings\n").contains("line 2"), "a space after the tabs");
assert!(err("Events\nx\u{0}y\n").contains("line 2"), "a control character");
assert!(err("Events\n\t{sea|shore}\n").contains("line 2"), "“|” in a synonym");
}
/// A keyword repeated in another case is one keyword, and the keywords inside either take the
/// first spelling.
#[test]
fn a_repeated_keyword_keeps_its_first_spelling() {
let read = parse_keyword_list("Events\n\tWeddings\nevents\n\tBirthdays\n").unwrap();
assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["Events", "Events|Weddings", "Events|Birthdays"]);
}
/// What the list can't hold is left out and named, so the file always reads back: a name the
/// format would read as something else (in brackets or braces), one with a line break, a
/// synonym with one, a keyword nested deeper than a list goes; and the keywords inside one left
/// out go with it.
#[test]
fn an_exported_keyword_list_always_reads_back() {
let deep = (0..=MAX_LEVELS).map(|i| format!("level {i}")).collect::<Vec<_>>().join("|");
let (mut c, _) = lib(&[&["Travel|[draft]", "{todo}|Lisbon", "Weddings", "Line\nbreak", deep.as_str()]]);
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["marriage", "two\nlines"])) }).unwrap();
let KeywordListFile { text, unwritable, .. } = c.keyword_list_text();
let read = parse_keyword_list(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
assert!(read.iter().any(|(p, _)| p == "Travel"));
assert!(read.iter().all(|(p, _)| !p.contains("draft") && !p.contains("todo") && !p.contains("Line")), "{text}");
let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone());
assert_eq!(weddings, Some(vec!["marriage".to_string()]));
let deepest = read.iter().map(|(p, _)| p.split(SEP).count()).max();
assert_eq!(deepest, Some(MAX_LEVELS));
assert!(unwritable.contains(&"Travel|[draft]".to_string()), "{unwritable:?}");
assert!(unwritable.contains(&"{todo}".to_string()), "{unwritable:?}");
assert!(unwritable.contains(&"Line\nbreak".to_string()), "{unwritable:?}");
assert!(unwritable.contains(&"Weddings {two\nlines}".to_string()), "{unwritable:?}");
assert!(unwritable.iter().any(|u| u.ends_with(&format!("level {MAX_LEVELS}"))), "{unwritable:?}");
}
/// The list leaves out only what it must, and names only what's lost: a name starting with a
/// byte-order mark would lose it, but a synonym in braces reads back, and a synonym that's empty
/// or another's in a different case is no loss. It also says which keywords it wrote.
#[test]
fn an_exported_keyword_list_names_only_what_it_loses() {
let (mut c, _) = lib(&[&["\u{feff}Albums", "{draft", "Weddings"]]);
c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["Marriage", "marriage", " ", "{vows}"])) }).unwrap();
let out = c.keyword_list_text();
let read = parse_keyword_list(&out.text).unwrap_or_else(|e| panic!("{e}\n{}", out.text));
assert_eq!(out.unwritable, ["\u{feff}Albums"]);
assert_eq!(out.written, ["Weddings", "{draft"]);
assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["Weddings", "{draft"]);
let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone());
assert_eq!(weddings, Some(vec!["Marriage".to_string(), "{vows}".to_string()]));
}
/// A big list reads and imports at once, against a big library too: 40,000 keywords used to take
/// half a minute to read, and checking 5,000 new ones against 20,000 photos over a minute, with
/// the app frozen.
#[test]
fn a_big_keyword_list_imports_quickly() {
let mut text = String::new();
for g in 0..400 {
text.push_str(&format!("group {g}\n"));
for k in 0..99 {
text.push_str(&format!("\tkeyword {g}-{k}\n"));
}
}
let t0 = std::time::Instant::now();
let read = parse_keyword_list(&text).unwrap();
assert_eq!(read.len(), 40_000);
let mut c = Catalog::new();
let mut ops = Vec::new();
for i in 0..20_000u32 {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01");
p.meta.keywords = (0..5).map(|k| format!("library {}|tag {}", k, (i + k) % 3000)).collect();
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
c.apply(Op::Batch { ops }).unwrap();
let (op, added, _) = c.import_keywords_ops(&read[..5_000]);
c.apply(op).unwrap();
assert_eq!(added, 5_000);
let took = t0.elapsed();
assert!(took < std::time::Duration::from_secs(10), "{took:?}");
}
/// Importing a list adds the keywords the library doesn't have, with their attributes, and gives
/// those it has the list's synonyms (their own attributes stay), in one undo step.
#[test]
fn importing_a_keyword_list_adds_what_is_missing() {
let (mut c, _) = lib(&[&["Events|Weddings"]]);
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo { person: true, ..with_synonyms(&["marriage"]) }) }).unwrap();
let before = c.to_snapshot();
let read = parse_keyword_list("events\n\tweddings\n\t\t{nuptials}\n\tBirthdays\n[Drafts]\n").unwrap();
let (op, added, updated) = c.import_keywords_ops(&read);
assert_eq!((added, updated), (2, 1));
let undo = c.apply(op).unwrap();
assert_eq!(c.keyword_info("Events|Weddings"), Some(&KeywordInfo { person: true, ..with_synonyms(&["marriage", "nuptials"]) }));
assert!(c.has_keyword("Events|Birthdays"));
assert_eq!(
listed(&c).iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(),
["Drafts", "Events|Birthdays", "Events|Weddings"],
"in the library's spelling"
);
assert!(!c.keyword_info("Drafts").unwrap().include_on_export);
c.apply(undo).unwrap();
assert_eq!(c.to_snapshot(), before);
}
#[test]
fn suggestions_rank_co_occurrence_and_prefixes() {
let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]);
// co-occurring with `dog` first (park twice), then the most used
let s = c.keyword_suggestions(&["dog".into()], "", 3);
assert_eq!(s, ["park", "ball", "beach"]);
let s = c.keyword_suggestions(&[], "", 2);
assert_eq!(s, ["cat", "dog"]);
let s = c.keyword_suggestions(&["park".into()], "pa", 5);
assert_eq!(s, ["parade"]);
let s = c.keyword_suggestions(&[], "E", 5);
assert_eq!(s, ["eagle", "beach", "parade"], "prefix matches before substring matches");
}
/// A flat keyword already covered by a hierarchical level only in another spelling is not kept
/// again, whatever the size of either list (the per-flat-keyword search of every level, twice
/// lower-cased per comparison, was quadratic: CWE-407).
#[test]
fn from_file_matches_levels_in_any_case() {
let flat = ["Rome".to_string(), "Seaside".to_string()];
let hierarchical = ["travel|ITALY|rome".to_string()];
assert_eq!(from_file(&flat, &hierarchical), ["travel|ITALY|rome", "Seaside"]);
}
/// The same synonym, in any spelling, is kept once (the search of each keyword's growing
/// synonym list per line was quadratic, with lower-cased copies made per comparison).(CWE-407)
#[test]
fn parse_keyword_list_keeps_a_synonym_once() {
let read = parse_keyword_list("Travel\n\t{Trip}\n\t{journey}\n\t{TRIP}\n").unwrap();
assert_eq!(read.len(), 1);
assert_eq!(read[0].1.synonyms, ["Trip", "journey"]);
}
/// A keyword split into far more levels than a list file accepts is cut at [`MAX_LEVELS`] in the
/// tree, whose level rows hold every prefix twice: unbounded it made the stored bytes quadratic
/// in the level count of one short keyword (CWE-400).
#[test]
fn keyword_tree_caps_a_keyword_at_the_list_depth() {
let deep: Vec<String> = (0..4096).map(|i| format!("k{i}")).collect();
let joined = deep.join("|");
let (c, _) = lib(&[&[joined.as_str()]]);
let deepest = tree_paths(&c.keyword_tree()).iter().map(|(p, _)| p.split(SEP).count()).max().unwrap_or(0);
assert_eq!(deepest, MAX_LEVELS);
}
+1 -786
View File
@@ -1179,789 +1179,4 @@ impl RuleSet {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ColorLabel, Flag, PhotoId, Source};
use serde_json::json;
fn photo(id: u64) -> Photo {
let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00");
p.rating = 4;
p.flag = Flag::Pick;
p.label = Some(ColorLabel::Red);
p.captured = Some("2026-08-14T18:30:00".into());
p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()];
p.meta.camera = "Model X2".into();
p.meta.iso = Some(1600);
p.meta.aperture = Some(2.8);
p
}
fn rs(v: serde_json::Value) -> RuleSet {
serde_json::from_value(v).unwrap()
}
#[test]
fn f32_boundary_values_match_gte_lte() {
let cat = Catalog::new();
let matches =
|p: &Photo, field: &str, op: &str, value: f64| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat);
for v in [1.4f32, 1.8, 2.8, 5.6, 0.95] {
let mut p = photo(1);
p.meta.aperture = Some(v);
p.meta.focal_mm = Some(v);
let w = v.to_string().parse::<f64>().unwrap();
for field in ["aperture", "focalLength"] {
assert!(matches(&p, field, "gte", w), "{field} gte {w}");
assert!(matches(&p, field, "lte", w), "{field} lte {w}");
assert!(!matches(&p, field, "gt", w), "{field} gt {w}");
assert!(!matches(&p, field, "lt", w), "{field} lt {w}");
}
}
}
#[test]
fn fields_ops_and_groups() {
let cat = Catalog::new();
let p = photo(1);
let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}");
let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}");
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]}));
no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]}));
yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]}));
no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]}));
yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]}));
yes(
json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}),
);
no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
// file path: the whole string (spaces included), either separator, case-insensitive
let mut fp = photo(3);
fp.source = Source::File { path: "D:\\Photos\\Lake Como Wedding-Day\\2026\\IMG_0042.CR2".into() };
let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat);
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake Como Wedding-Day/"}]})));
assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Como Lake/"}]})));
assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Wedding Day"}]})));
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]})));
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]})));
no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake/"}]})); // demo photo has no path
yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]}));
yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]}));
yes(
json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}),
);
yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]}));
no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]}));
yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]}));
// copyright status (unknown until set)
yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]}));
no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]}));
let mut pd = photo(2);
pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat));
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat));
// nested: rating ≥ 4 and (label is blue or keywords contain food)
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [
{"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]}));
// in the last N days, relative to now
set_now(Some("2026-09-01T00:00:00".into()));
yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]}));
no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]}));
yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]}));
set_now(None);
// an empty rule list: all → everything, any → nothing
yes(json!({"rules": []}));
no(json!({"match": "any", "rules": []}));
}
#[test]
fn problems_and_description() {
let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]}));
let messages: Vec<String> = r.check(&Catalog::new()).into_iter().map(|p| p.message).collect();
assert_eq!(messages, vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]);
let r = rs(
json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}),
);
assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks");
// every field has a kind with operators
for (f, _, k) in FIELDS {
assert!(!ops_for(*k).is_empty(), "{f}");
}
}
/// Keyword Count: a photo with 2 keywords is in "≥ 2", "≤ 2" and "is 2"; a hierarchical path
/// (travel|italy|rome) is one keyword, and the same keyword twice in different case is one.
#[test]
fn keyword_count_rules() {
let cat = Catalog::new();
let mut p = photo(1); // travel|italy|rome, food
let m = |p: &Photo, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&p, "gte", json!(2)) && m(&p, "lte", json!(2)) && m(&p, "is", json!(2)));
assert!(!m(&p, "gte", json!(3)) && !m(&p, "lt", json!(2)) && m(&p, "between", json!([1, 2])));
p.meta.keywords.push("Food".into());
assert!(m(&p, "is", json!(2)), "Food and food are one keyword");
p.meta.keywords.clear();
assert!(m(&p, "is", json!(0)) && m(&p, "lte", json!(2)));
assert_eq!(field_group("keywordCount"), Some("Keywords & People"));
}
/// People: the names on face regions. "People contains ana" finds Ana Lima; pets and unnamed
/// faces are not people; People Count counts each person once.
#[test]
fn people_rules() {
use lightcraft_meta::{Rect, Region, RegionKind};
let cat = Catalog::new();
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 p = photo(1);
let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&p, "person", "isEmpty", json!(null)) && m(&p, "personCount", "is", json!(0)));
p.meta.regions = vec![
region(Some("Ana Lima"), RegionKind::Face),
region(Some(" ana lima "), RegionKind::Face),
region(Some("Rex"), RegionKind::Pet),
region(None, RegionKind::Face),
region(Some("Bo"), RegionKind::Face),
];
assert!(m(&p, "person", "contains", json!("ana")));
assert!(m(&p, "person", "is", json!("ANA LIMA")));
assert!(!m(&p, "person", "is", json!("rex")), "a pet isn't a person");
assert!(m(&p, "person", "notContains", json!("rex")));
assert!(m(&p, "person", "isNotEmpty", json!(null)));
assert!(m(&p, "personCount", "is", json!(2)), "Ana Lima once, Bo; not the pet or the unnamed face");
assert_eq!(field_group("person"), Some("Keywords & People"));
}
/// Shutter Speed compares exposure times in seconds, written as the camera shows them: a
/// photo at 1/250 is in "≤ 1/60" (faster) and not in "≥ 1/60"; "between 1/1000 and 1/125".
#[test]
fn shutter_speed_rules() {
let cat = Catalog::new();
let mut p = photo(1);
let m = |p: &Photo, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat)
};
assert!(!m(&p, "gte", json!(0)), "no shutter speed recorded");
p.meta.shutter = "1/250".into();
assert!(m(&p, "is", json!("1/250")) && m(&p, "lte", json!("1/60")) && !m(&p, "gte", json!("1/60")));
assert!(m(&p, "between", json!(["1/1000", "1/125"])) && m(&p, "lt", json!(0.01)));
assert!(!m(&p, "is", json!("1/0")) && !m(&p, "is", json!("fast")), "a value that isn't a time matches nothing");
p.meta.shutter = "2\"".into();
assert!(m(&p, "gte", json!("1")) && m(&p, "is", json!(2)));
assert_eq!(field_group("shutterSpeed"), Some("Camera Info"));
}
#[test]
fn shutter_seconds_parses_camera_notation() {
for (s, want) in [("1/250", Some(0.004)), (" 1/250 s", Some(0.004)), ("0.5", Some(0.5)), ("2\"", Some(2.0)), ("30s", Some(30.0))] {
assert_eq!(crate::parse_shutter_seconds(s), want, "{s}");
}
for s in ["", "1/0", "0", "-1/250", "fast", "inf", "NaN", "1/x"] {
assert_eq!(crate::parse_shutter_seconds(s), None, "{s}");
}
}
/// File Extension ignores case and a leading dot; Copy Name, Alt Text, City, State /
/// Province and Country are text fields of their own (Location still matches all of them).
#[test]
fn file_source_description_and_location_rules() {
let cat = Catalog::new();
let mut p = photo(1); // IMG_0042.CR2
let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&p, "extension", "is", json!("cr2")) && m(&p, "extension", "is", json!(".CR2")));
assert!(!m(&p, "extension", "is", json!("jpg")));
p.file_name = "README".into();
assert!(m(&p, "extension", "isEmpty", json!(null)), "no dot, no extension");
p.file_name = "archive.tar.gz".into();
assert!(m(&p, "extension", "is", json!("gz")));
p.file_name = ".hidden".into();
assert!(m(&p, "extension", "isEmpty", json!(null)), "a dot file has no extension");
assert!(m(&p, "copyName", "isEmpty", json!(null)));
p.copy_name = Some("Black and white".into());
assert!(m(&p, "copyName", "contains", json!("black")));
p.meta.alt_text = "A red kite over a hill".into();
assert!(m(&p, "altText", "contains", json!("kite")));
p.meta.city = "Porto".into();
p.meta.state = "Porto District".into();
p.meta.country = "Portugal".into();
assert!(m(&p, "city", "is", json!("porto")) && m(&p, "state", "startsWith", json!("porto")) && m(&p, "country", "is", json!("portugal")));
assert!(!m(&p, "city", "is", json!("portugal")), "City tests only the city");
assert!(m(&p, "location", "contains", json!("portugal")), "Location still covers the country");
}
/// In a Stack, Video Duration (seconds; photos have none), Cropped and Treatment.
#[test]
fn source_file_and_develop_rules() {
use crate::{Op, Stack, StackId};
let mut cat = Catalog::new();
let a = photo(1);
let b = photo(2);
let c = photo(3);
for p in [&a, &b, &c] {
cat.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
}
cat.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![a.id, b.id], collapsed: false } }).unwrap();
let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&a, "stacked", "is", json!(true)) && m(&b, "stacked", "is", json!(true)));
assert!(m(&c, "stacked", "is", json!(false)));
let mut v = photo(4);
assert!(!m(&v, "duration", "gte", json!(0)), "a photo has no duration");
v.duration = Some(95.0);
assert!(m(&v, "duration", "gt", json!(60)) && m(&v, "duration", "between", json!([90, 120])));
let mut d = photo(5);
assert!(m(&d, "cropped", "is", json!(false)) && m(&d, "treatment", "is", json!("color")));
let mut s = (*d.develop).clone();
s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9, 1.0);
s.treatment = lightcraft_develop::Treatment::Bw;
d.develop = std::sync::Arc::new(s);
assert!(m(&d, "cropped", "is", json!(true)) && m(&d, "treatment", "is", json!("monochrome")));
assert!(m(&d, "treatment", "isNot", json!("color")));
let mut s = (*d.develop).clone();
s.crop = Default::default();
s.crop.geometry.angle = 2.0;
d.develop = std::sync::Arc::new(s);
assert!(m(&d, "cropped", "is", json!(true)), "straightening counts as a crop");
}
/// Size follows the photo as shown: rotated a quarter turn a landscape frame is a portrait, and
/// a crop changes its edges and its megapixels. Long / Short Edge are in pixels.
#[test]
fn size_rules_follow_orientation_and_crop() {
let cat = Catalog::new();
let mut p = photo(1); // 6000 × 4000
let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&p, "aspect", "is", json!("landscape")) && m(&p, "longEdge", "is", json!(6000)) && m(&p, "shortEdge", "is", json!(4000)));
assert!(m(&p, "megapixels", "is", json!(24)));
let mut s = (*p.develop).clone();
s.orientation = lightcraft_geom::Orientation::Rotate90;
p.develop = std::sync::Arc::new(s.clone());
assert!(m(&p, "aspect", "is", json!("portrait")) && m(&p, "longEdge", "is", json!(6000)));
// a square crop of the rotated frame: 4000 wide, 4000 of its 6000 tall
s.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 1.0 / 6.0, 1.0, 5.0 / 6.0);
p.develop = std::sync::Arc::new(s);
assert!(m(&p, "aspect", "is", json!("square")) && m(&p, "longEdge", "is", json!(4000)));
assert!(m(&p, "megapixels", "is", json!(16)), "a 4000 × 4000 crop is 16 MP, not the sensor's 24");
assert!(m(&p, "megapixels", "lt", json!(20)) && !m(&p, "megapixels", "gte", json!(24)));
assert!(m(&p, "aspect", "isNot", json!("portrait")));
let empty = Photo::new(PhotoId(9), Source::Demo { scene: 0 }, "x.jpg", "JPEG", 0, 0, "2026-09-20T10:00:00");
assert!(
!m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0))
);
assert!(m(&empty, "megapixels", "is", json!(0)));
}
/// Any Searchable Text also finds the state / province, the alt text, a person on a face and
/// a virtual copy's name, as it already finds the city and country.
#[test]
fn any_searchable_text_covers_new_fields() {
let cat = Catalog::new();
let mut p = photo(1);
let m = |p: &Photo, value: &str| rs(json!({"rules": [{"field": "text", "op": "contains", "value": value}]})).matches(p, &cat);
for word in ["oregon", "kite", "ana", "bluish"] {
assert!(!m(&p, word), "{word}");
}
p.meta.state = "Oregon".into();
p.meta.alt_text = "A kite".into();
p.meta.regions = vec![lightcraft_meta::Region {
rect: lightcraft_meta::Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
kind: lightcraft_meta::RegionKind::Face,
name: Some("Ana".into()),
description: None,
}];
p.copy_name = Some("Bluish".into());
for word in ["oregon", "kite", "ana", "bluish"] {
assert!(m(&p, word), "{word}");
}
}
/// Choice values have readable labels: the editor and summaries show "Public Domain" and
/// "Black & White", never ids like `publicDomain`; rules still store and accept the ids.
#[test]
fn choices_have_readable_labels() {
assert_eq!(choice_label("copyrightStatus", "publicDomain"), Some("Public Domain"));
assert_eq!(choice_label("treatment", "monochrome"), Some("Black & White"));
// own labels where a shared word would translate wrongly: "Color" is the colour noun (the
// Color panel) and "None" agrees with other nouns in gendered languages
assert_eq!(choice_label("treatment", "color"), Some("In Color"));
assert_eq!(choice_label("label", "none"), Some("No Label"));
assert_eq!(choice_label("flag", "pick"), Some("Picked"));
assert_eq!(choice_label("aspect", "landscape"), Some("Landscape (wide)"));
assert_eq!(choice_label("flag", "nope"), None);
assert_eq!(choice_label("rating", "pick"), None, "not a choice field");
for (field, _, kind) in FIELDS {
let Kind::Choice(choices) = kind else { continue };
for (id, label) in *choices {
assert!(!label.is_empty() && label != id, "{field}: {id} has no label of its own");
assert!(label.starts_with(|c: char| c.is_uppercase()), "{field}: {label}");
}
}
let r = rs(
json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}, {"field": "flag", "op": "isNot", "value": "reject"}]}),
);
assert_eq!(r.describe(), "copyright status is public domain and pick flag isn't rejected");
let mut p = photo(1);
p.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
assert!(r.matches(&p, &Catalog::new()), "ids still match");
}
/// Yes/no fields read "Yes" / "No", and a value means the same to matching and to every
/// summary: `false`, `"false"`, `"no"` and `0` are no; `true`, `"yes"`, `1` and a missing value
/// are yes. Anything else is reported, not silently taken as yes.
#[test]
fn yes_no_values() {
for v in [json!(true), json!("true"), json!("Yes"), json!(1), json!(1.0), json!(null)] {
assert_eq!(bool_value(&v), Some(true), "{v}");
}
for v in [json!(false), json!("false"), json!(" NO "), json!(0), json!(0.0)] {
assert_eq!(bool_value(&v), Some(false), "{v}");
}
for v in [json!("maybe"), json!(2), json!(0.5), json!(-1), json!([true]), json!({"x": 1})] {
assert_eq!(bool_value(&v), None, "{v}");
}
assert_eq!((bool_label(true), bool_label(false)), ("Yes", "No"));
let cat = Catalog::new();
let p = photo(1); // unedited
assert!(rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}]})).matches(&p, &cat), "\"false\" means no");
assert!(
!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}, {"field": "hasGps", "op": "is", "value": "yes"}]}))
.matches(&p, &cat)
);
assert!(!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})).matches(&p, &cat), "an unreadable value matches nothing");
let r = rs(json!({"rules": [{"field": "edited", "op": "is", "value": true}, {"field": "cropped", "op": "is", "value": "false"}]}));
assert_eq!(r.describe(), "has edits is yes and cropped is no");
let bad = rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]}));
let messages: Vec<String> = bad.check(&cat).into_iter().map(|p| p.message).collect();
assert_eq!(messages, vec!["`edited` is yes or no, not \"maybe\"".to_string()]);
}
/// "In the last N" never overflows, whatever N an agent or a saved file holds: a zero,
/// negative or unreadable N matches nothing (either way round); an N beyond 10,000 years means
/// "ever", so every dated photo is in it and none is outside it.
#[test]
fn in_the_last_survives_hostile_counts() {
set_now(Some("2026-09-01T00:00:00".into()));
let cat = Catalog::new();
let p = photo(1); // captured 2026-08-14
let m = |op: &str, n: serde_json::Value, unit: &str| {
rs(json!({"rules": [{"field": "captureDate", "op": op, "value": {"n": n, "unit": unit}}]})).matches(&p, &cat)
};
for n in [json!(0), json!(-5), json!(-1e30), json!(-9.3e18), json!("soon")] {
for op in ["inLast", "notInLast"] {
assert!(!m(op, n.clone(), "years"), "{op} {n}");
}
}
for n in [json!(1e30), json!(9.3e18), json!(u64::MAX), json!(20_000)] {
assert!(m("inLast", n.clone(), "years"), "{n} years is ever");
assert!(!m("notInLast", n.clone(), "years"), "{n}");
}
assert!(m("inLast", json!(1e30), "hours") && m("inLast", json!(30), "days"));
set_now(None);
}
/// Every rule's value is checked against its field, so a rule that can't mean anything is
/// reported instead of quietly matching nothing (or everything). Each problem says which rule
/// it is about: "rule 2", or "rule 2.1" inside a group.
#[test]
fn values_are_checked_against_their_field() {
use crate::{Album, AlbumId, Op};
let mut cat = Catalog::new();
cat.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap();
let smart = Album { smart: Some(Box::default()), ..Album::new(AlbumId(2), "Best") };
cat.apply(Op::AddAlbum { album: smart }).unwrap();
cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Client".into()) }).unwrap();
let check = |rule: serde_json::Value| rs(json!({"rules": [rule]})).check(&cat);
let ok = |rule: serde_json::Value| assert!(check(rule.clone()).is_empty(), "{rule}: {:?}", check(rule.clone()));
let bad = |rule: serde_json::Value, want: &str| {
let p = check(rule.clone());
assert!(p.iter().any(|p| p.message.contains(want)), "{rule}: wanted {want:?}, got {p:?}");
};
let r = |field: &str, op: &str, value: serde_json::Value| json!({"field": field, "op": op, "value": value});
// numbers
ok(r("rating", "gte", json!(3)));
ok(r("rating", "gte", json!("4")));
ok(r("rating", "between", json!([1, 5])));
ok(r("aperture", "lte", json!("f/4")));
ok(r("focalLength", "is", json!("50mm")));
bad(r("rating", "gte", json!("abc")), "needs a number");
bad(r("rating", "gte", json!(7)), "no rating 0–5 is ≥ 7");
bad(r("iso", "between", json!([100])), "two");
bad(r("iso", "between", json!("100")), "two");
ok(r("shutterSpeed", "lte", json!("1/60")));
bad(r("shutterSpeed", "lte", json!("fast")), "1/250");
// dates
for d in ["2026", "2026-08", "2026-08-14", "2026-08-14T10:00:00"] {
ok(r("captureDate", "is", json!(d)));
}
ok(r("captureDate", "between", json!(["2026-01", "2026-12"])));
for d in [json!("banana"), json!("2026-13"), json!("2026-02-30"), json!(""), json!(2026)] {
bad(r("captureDate", "after", d), "needs a date");
}
bad(r("captureDate", "between", json!(["2026-12", "2026-01"])), "after the second");
bad(r("captureDate", "between", json!(["", "2026"])), "needs a date");
ok(r("captureDate", "inLast", json!({"n": 2, "unit": "weeks"})));
ok(r("captureDate", "inLast", json!(7)));
bad(r("captureDate", "inLast", json!({"n": 0, "unit": "days"})), "more than 0");
bad(r("captureDate", "notInLast", json!({"n": 3, "unit": "fortnights"})), "fortnights");
ok(r("captureDate", "isEmpty", json!(null)));
// choices: the ids, the flag's aliases and a colour label's custom name
ok(r("kind", "is", json!("raw")));
ok(r("flag", "is", json!("picked")));
ok(r("label", "is", json!("client")));
bad(r("label", "is", json!("pink")), "pink");
bad(r("kind", "isNot", json!("photo")), "photo");
// text needs something to look for
ok(r("title", "contains", json!("kite")));
ok(r("title", "isEmpty", json!(null)));
bad(r("title", "contains", json!(" ")), "something to look for");
bad(r("keywords", "startsWith", json!("")), "something to look for");
// albums: one that exists, plain or smart
ok(r("album", "is", json!(1)));
bad(r("album", "is", json!(999)), "no album 999");
ok(r("album", "is", json!(2)));
// groups: an empty one is a mistake; problems inside one say where they are
let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"match": "any", "rules": []}}]})).check(&cat);
assert_eq!(p.iter().map(ToString::to_string).collect::<Vec<_>>(), vec!["rule 2: an empty group".to_string()]);
let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"rules": [r("iso", "is", json!(100)), r("rating", "is", json!(9))]}}]}))
.check(&cat);
assert_eq!(p.len(), 1);
assert_eq!(p[0].path, vec![1, 1]);
assert_eq!(p[0].to_string(), "rule 2.2: no rating 0–5 is 9");
// an empty rule list at the top is "all photos" (or none, for any), not a mistake
assert!(rs(json!({"rules": []})).check(&cat).is_empty());
}
/// The check and the matcher agree. Units are any case, singular or plural, and missing means
/// days; an unknown unit is refused and matches nothing (it used to count as days, so "week" was
/// one day). A rating rule is refused only when no rating 0–5 could meet it. A colour label's
/// custom name matches in any case, accents included. Empty text hints at the right operator.
#[test]
fn check_and_matcher_agree() {
use crate::Op;
set_now(Some("2026-09-01T00:00:00".into()));
let mut cat = Catalog::new();
cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Été".into()) }).unwrap();
let p = photo(1); // captured 2026-08-14 (18 days before now), red label, rating 4
let rule = |field: &str, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": field, "op": op, "value": value}]}));
let valid = |r: &RuleSet| r.check(&cat).is_empty();
for unit in [json!("Days"), json!("DAY"), json!("days"), json!(null)] {
let r = rule("captureDate", "inLast", json!({"n": 30, "unit": unit}));
assert!(valid(&r) && r.matches(&p, &cat), "{unit}");
}
let r = rule("captureDate", "inLast", json!({"n": 30}));
assert!(valid(&r) && r.matches(&p, &cat), "no unit is days");
let week = rule("captureDate", "inLast", json!({"n": 1, "unit": "week"}));
assert!(valid(&week) && !week.matches(&p, &cat), "a week, not a day: 18 days ago is outside it");
assert!(rule("captureDate", "inLast", json!({"n": 3, "unit": "Weeks"})).matches(&p, &cat));
let odd = rule("captureDate", "inLast", json!({"n": 30, "unit": "fortnights"}));
assert!(!valid(&odd) && !odd.matches(&p, &cat), "an unknown unit is refused and matches nothing");
// rating: only a rule no rating could meet
for (op, v) in [("lt", json!(6)), ("gt", json!(-1)), ("isNot", json!(9)), ("between", json!([3, 8]))] {
assert!(valid(&rule("rating", op, v.clone())), "{op} {v}");
}
for (op, v) in [("is", json!(9)), ("gte", json!(7)), ("gt", json!(5)), ("lt", json!(0)), ("between", json!([6, 9]))] {
assert!(!valid(&rule("rating", op, v.clone())), "{op} {v}");
}
// a custom label name, any case, accents too
let r = rule("label", "is", json!("été"));
assert!(valid(&r) && r.matches(&p, &cat));
// "isn't" with nothing points at "isn't empty", the others at "is empty"
let hint = |op: &str| rule("title", op, json!("")).check(&cat).first().map(|p| p.message.clone()).unwrap_or_default();
assert!(hint("isNot").contains("isn't empty"), "{}", hint("isNot"));
assert!(hint("contains").contains("“is empty”"), "{}", hint("contains"));
set_now(None);
}
/// An album is in it or not: Album offers "is" and "isn't", never "≥" or "between".
#[test]
fn album_rules_are_is_or_isnt() {
assert_eq!(field_kind("album"), Some(Kind::Album));
let ops: Vec<&str> = ops_for(Kind::Album).iter().map(|o| o.0).collect();
assert_eq!(ops, ["is", "isNot"]);
let r = rs(json!({"rules": [{"field": "album", "op": "gte", "value": 1}]}));
assert_eq!(r.check(&Catalog::new()).first().map(|p| p.message.as_str()), Some("`album` has no operator `gte`"));
}
/// An Album rule names its album by id, written as a number or a numeric string, the same way
/// for the check, the matcher and the editor. A folder holds albums, not photos, so it can't be
/// tested.
#[test]
fn album_rule_ids() {
use crate::{Album, AlbumId, Op};
assert_eq!(album_rule_id(&json!(3)), Some(AlbumId(3)));
assert_eq!(album_rule_id(&json!("3")), Some(AlbumId(3)));
assert_eq!(album_rule_id(&json!(3.0)), Some(AlbumId(3)));
for v in [json!(1.5), json!(-1), json!("x"), json!(null)] {
assert_eq!(album_rule_id(&v), None, "{v}");
}
let mut cat = Catalog::new();
cat.apply(Op::AddAlbum { album: Album { folder: true, ..Album::new(AlbumId(4), "Trips") } }).unwrap();
let p = rs(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})).check(&cat);
assert!(p.first().is_some_and(|p| p.message.contains("folder")), "{p:?}");
}
/// Album rules saved before Album offered only "is" / "isn't" (≥, between… from when it was a
/// number field) always meant "isn't" to the matcher; upgrading says so, so they check again.
#[test]
fn old_album_operators_upgrade_to_isnt() {
let mut r = rs(json!({"rules": [
{"field": "album", "op": "gte", "value": 1},
{"field": "album", "op": "is", "value": 1},
{"field": "rating", "op": "gte", "value": 3},
{"group": {"rules": [{"field": "album", "op": "between", "value": [1, 2]}]}}
]}));
r.upgrade();
let ops = serde_json::to_value(&r).unwrap().to_string();
assert!(ops.contains(r#""field":"album","op":"isNot","value":1"#), "{ops}");
assert!(ops.contains(r#""field":"album","op":"is","value":1"#), "{ops}");
assert!(ops.contains(r#""field":"rating","op":"gte""#), "other fields keep theirs: {ops}");
assert!(ops.contains(r#""field":"album","op":"isNot","value":[1,2]"#), "inside groups too: {ops}");
}
/// Besides its English message (for agents), a problem names its field and the kind of issue,
/// so the editor can show "Title: needs something to look for" in any language. Every issue
/// has its own short text.
#[test]
fn problems_name_their_field_and_issue() {
let cat = Catalog::new();
let check = |rules: serde_json::Value| rs(json!({"rules": rules})).check(&cat);
let p = check(json!([{"field": "title", "op": "contains", "value": ""}]));
assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("title"), Issue::NeedsTextOrEmpty));
let p = check(json!([{"field": "title", "op": "isNot", "value": ""}]));
assert_eq!(p[0].issue, Issue::NeedsTextOrNotEmpty);
// keywords and people say "are empty", and so does their hint
let p = check(json!([{"field": "keywords", "op": "contains", "value": ""}, {"field": "person", "op": "notContains", "value": " "}]));
assert_eq!((p[0].issue, p[1].issue), (Issue::NeedsNamesOrEmpty, Issue::NeedsNamesOrNotEmpty));
assert!(p[0].issue.text().contains("“are empty”") && p[0].message.contains("“are empty”"));
let p = check(json!([{"field": "rating", "op": "is", "value": 9}, {"group": {"rules": []}}]));
assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("rating"), Issue::NoRatingMatches));
assert_eq!((p[1].field.as_deref(), p[1].issue), (None, Issue::EmptyGroup));
let p = check(json!([{"field": "captureDate", "op": "between", "value": ["2026-12", "2026-01"]}]));
assert_eq!(p[0].issue, Issue::DatesReversed);
let p = check(json!([{"field": "album", "op": "is", "value": null}]));
assert_eq!(p[0].issue, Issue::ChooseAlbum);
let mut texts: Vec<&str> = Issue::ALL.iter().map(|i| i.text()).collect();
assert!(texts.iter().all(|t| !t.is_empty()));
texts.sort_unstable();
texts.dedup();
assert_eq!(texts.len(), Issue::ALL.len(), "each issue reads differently");
}
/// A rule date reads one way for the check and the matcher: a space works as the "T"
/// ("2026-10-01 10:00" matches a photo taken then). Imported capture times can carry fractions
/// of a second and a time zone (EXIF SubSecTime / OffsetTime: "…T10:00:00.250+02:00"), so a
/// rule may too, after the seconds; anywhere else they are no date.
#[test]
fn rule_dates_have_one_reading() {
let cat = Catalog::new();
let mut p = photo(1);
p.captured = Some("2026-10-01T10:00:00".into());
let rule = |op: &str, v: serde_json::Value| rs(json!({"rules": [{"field": "captureDate", "op": op, "value": v}]}));
for (op, v) in [
("is", json!("2026-10-01 10:00")),
("is", json!(" 2026-10-01T10 ")),
("between", json!(["2026-10-01 09:00", "2026-10-01 11:00"])),
("after", json!("2026-10-01 09:59")),
] {
let r = rule(op, v.clone());
assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}");
}
// a capture time with a fraction and a zone, as imports write it
let mut zoned = photo(2);
zoned.captured = Some("2026-08-14T10:00:00.250+02:00".into());
for v in ["2026-08-14T10:00:00.250", "2026-08-14T10:00:00.250+02:00", "2026-08-14T10:00:00"] {
let r = rule("is", json!(v));
assert!(r.check(&cat).is_empty() && r.matches(&zoned, &cat), "{v}");
}
assert!(rule("is", json!("2026-08-14T10:00:00Z")).check(&cat).is_empty());
for v in [
"2026-8-14",
"2026-08-14T1",
"2026-08-14X10",
"26-08-14",
"2026-08-14T25:00",
"2026-08-14Z",
"2026-08-14 10:00-05:00",
"2026-08-14T10:00:00.",
"2026-08-14T10:00:00+2",
] {
let p = rule("is", json!(v)).check(&cat);
assert_eq!(p.first().map(|p| p.issue), Some(Issue::NeedsDate), "{v}");
}
assert_eq!(rule_date("2026-10-01 10:00"), Ok("2026-10-01T10:00".to_string()));
assert_eq!(rule_date("2026"), Ok("2026".to_string()));
}
/// The field menu shows every rule field once: at the top level or in exactly one group,
/// and [`FIELDS`] lists them in menu order.
#[test]
fn every_field_is_in_the_menu_exactly_once() {
let menu: Vec<&str> = TOP_LEVEL_FIELDS.iter().chain(FIELD_GROUPS.iter().flat_map(|g| g.1.iter())).copied().collect();
let fields: Vec<&str> = FIELDS.iter().map(|f| f.0).collect();
assert_eq!(menu, fields, "the menu and FIELDS list the same fields in the same order");
for (label, group) in FIELD_GROUPS {
assert!(!group.is_empty(), "group {label} is empty");
}
let mut labels: Vec<&str> = FIELD_GROUPS.iter().map(|g| g.0).collect();
labels.dedup();
assert_eq!(labels.len(), FIELD_GROUPS.len(), "group labels are unique");
}
/// Related fields sit together: all the file fields, all the camera (EXIF) fields, all the
/// dates, all the keyword fields.
#[test]
fn related_fields_share_a_group() {
let same = |fields: &[&str], group: &str| {
for f in fields {
assert_eq!(field_group(f), Some(group), "{f}");
}
};
same(&["fileName", "extension", "filePath", "kind", "format", "duration"], "File");
same(&["album", "virtualCopy", "copyName", "stacked"], "Source");
same(&["longEdge", "shortEdge", "aspect", "megapixels"], "Size");
same(&["edited", "cropped", "treatment"], "Develop");
same(&["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"], "Camera Info");
same(&["captureDate", "importDate", "editDate"], "Date");
same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People");
same(&["title", "caption", "altText", "creator", "copyright", "copyrightStatus"], "Description");
same(&["location", "city", "state", "country", "hasGps"], "Location");
for f in ["rating", "flag", "label", "text"] {
assert_eq!(field_group(f), None, "{f} stays at the top level");
}
assert_eq!(field_group("nope"), None);
assert_eq!(field_label("filePath"), Some("File Path"));
assert_eq!(field_label("nope"), None);
}
/// The `filePath` rule exactly as it was before the in-place ASCII path: new strings per photo.
fn file_path_old(op: &str, source: &Source, value: &serde_json::Value) -> bool {
let want = lower(value);
let path = match source {
Source::File { path } => path.replace('\\', "/"),
Source::Demo { .. } => String::new(),
};
let want = want.replace('\\', "/");
match op {
"contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()),
"notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()),
_ => text_op(op, &path, &want),
}
}
#[test]
fn file_path_rules_match_the_old_strings_for_any_path() {
let paths = [
"",
" ",
" /a/b.jpg \t",
"/Volumes/share/photos/Trip/IMG_0001.jpg",
"/Volumes/share/PHOTOS/trip/img_0001.JPG",
r"D:\Photos\Lake Como\IMG_0001.JPG",
r"d:/photos/lake como/img_0001.jpg",
r"\\nas\share\x.jpg",
"/photos/Straße/a.jpg",
"/photos/STRASSE/a.jpg",
"/photos/İstanbul/a.jpg",
"/photos/i̇stanbul/a.jpg",
"/photos/ΟΔΟΣ/a.jpg",
"/photos/οδος/a.jpg",
"/photos/ΟΔΌΣ/a.jpg",
"/photos/日本/東京.jpg",
"/p/\u{0B}x\u{0B}",
"/p/\u{85}x\u{85}",
"a",
];
let wants = [
"",
" ",
"a",
"A",
"/",
r"\",
r"\photos\",
"/photos/",
"photos",
"PHOTOS",
"Lake Como",
" lake como ",
"lake como",
r"d:\photos\lake",
"IMG_0001.JPG",
".jpg",
"straße",
"STRASSE",
"strasse",
"İstanbul",
"i̇stanbul",
"istanbul",
"ΟΔΟΣ",
"οδος",
"οδοσ",
"ΟΔΌΣ",
"東京",
"日本",
"x",
"\u{0B}x",
"/a/b.jpg",
"/a/b.jpg \t",
"\u{85}x",
];
let ops = ["contains", "notContains", "is", "isNot", "startsWith", "endsWith", "isEmpty", "isNotEmpty", "bogus"];
let mut values: Vec<serde_json::Value> = wants.iter().map(|w| json!(w)).collect();
values.extend([json!(null), json!(5), json!(true), json!(["a", "b"])]);
let mut sources: Vec<Source> = paths.iter().map(|p| Source::File { path: (*p).into() }).collect();
sources.push(Source::Demo { scene: 0 });
for source in &sources {
for op in ops {
for value in &values {
assert_eq!(file_path_matches(op, source, value), file_path_old(op, source, value), "{source:?} {op} {value}");
}
}
}
}
#[test]
fn file_path_rule_through_a_rule_set_ignores_case_and_slash_direction() {
let mut p = photo(1);
p.source = Source::File { path: r"D:\Photos\Lake Como\IMG_0001.JPG".into() };
let r = rs(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/LAKE como/"}]}));
assert!(r.matches(&p, &Catalog::default()));
}
/// Every piece of a long path, in both cases and both slash directions, wherever it starts
/// (the search skips eight bytes at a time, so starts and ends at every offset matter).
#[test]
fn file_path_contains_finds_every_piece_of_a_long_path() {
let path = r"D:\Photos\Lake Como\Wedding-Day_2026\IMG_0001 (copy).JPG";
let source = Source::File { path: path.into() };
let shout = |s: &str| s.to_uppercase();
let swap = |s: &str| s.replace('\\', "/");
for from in 0..path.len() {
for to in from + 1..=path.len() {
let piece = &path[from..to];
for want in [piece.to_string(), shout(piece), swap(piece), piece.replace('/', r"\"), format!("{piece}x"), format!("x{piece}")] {
let value = json!(want);
for op in ["contains", "notContains"] {
assert_eq!(file_path_matches(op, &source, &value), file_path_old(op, &source, &value), "{op} {want:?}");
}
}
}
}
}
}
mod tests;
+776
View File
@@ -0,0 +1,776 @@
use super::*;
use crate::{ColorLabel, Flag, PhotoId, Source};
use serde_json::json;
fn photo(id: u64) -> Photo {
let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00");
p.rating = 4;
p.flag = Flag::Pick;
p.label = Some(ColorLabel::Red);
p.captured = Some("2026-08-14T18:30:00".into());
p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()];
p.meta.camera = "Model X2".into();
p.meta.iso = Some(1600);
p.meta.aperture = Some(2.8);
p
}
fn rs(v: serde_json::Value) -> RuleSet {
serde_json::from_value(v).unwrap()
}
#[test]
fn f32_boundary_values_match_gte_lte() {
let cat = Catalog::new();
let matches = |p: &Photo, field: &str, op: &str, value: f64| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat);
for v in [1.4f32, 1.8, 2.8, 5.6, 0.95] {
let mut p = photo(1);
p.meta.aperture = Some(v);
p.meta.focal_mm = Some(v);
let w = v.to_string().parse::<f64>().unwrap();
for field in ["aperture", "focalLength"] {
assert!(matches(&p, field, "gte", w), "{field} gte {w}");
assert!(matches(&p, field, "lte", w), "{field} lte {w}");
assert!(!matches(&p, field, "gt", w), "{field} gt {w}");
assert!(!matches(&p, field, "lt", w), "{field} lt {w}");
}
}
}
#[test]
fn fields_ops_and_groups() {
let cat = Catalog::new();
let p = photo(1);
let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}");
let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}");
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]}));
no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]}));
yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]}));
no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]}));
yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]}));
yes(json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}));
no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
// file path: the whole string (spaces included), either separator, case-insensitive
let mut fp = photo(3);
fp.source = Source::File { path: "D:\\Photos\\Lake Como Wedding-Day\\2026\\IMG_0042.CR2".into() };
let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat);
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake Como Wedding-Day/"}]})));
assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Como Lake/"}]})));
assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Wedding Day"}]})));
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]})));
assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]})));
no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake/"}]})); // demo photo has no path
yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]}));
yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]}));
yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]}));
yes(
json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}),
);
yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]}));
no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]}));
yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]}));
// copyright status (unknown until set)
yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]}));
no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]}));
let mut pd = photo(2);
pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat));
assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat));
// nested: rating ≥ 4 and (label is blue or keywords contain food)
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [
{"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]}));
// in the last N days, relative to now
set_now(Some("2026-09-01T00:00:00".into()));
yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]}));
no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]}));
yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]}));
set_now(None);
// an empty rule list: all → everything, any → nothing
yes(json!({"rules": []}));
no(json!({"match": "any", "rules": []}));
}
#[test]
fn problems_and_description() {
let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]}));
let messages: Vec<String> = r.check(&Catalog::new()).into_iter().map(|p| p.message).collect();
assert_eq!(messages, vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]);
let r = rs(
json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}),
);
assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks");
// every field has a kind with operators
for (f, _, k) in FIELDS {
assert!(!ops_for(*k).is_empty(), "{f}");
}
}
/// Keyword Count: a photo with 2 keywords is in "≥ 2", "≤ 2" and "is 2"; a hierarchical path
/// (travel|italy|rome) is one keyword, and the same keyword twice in different case is one.
#[test]
fn keyword_count_rules() {
let cat = Catalog::new();
let mut p = photo(1); // travel|italy|rome, food
let m =
|p: &Photo, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat);
assert!(m(&p, "gte", json!(2)) && m(&p, "lte", json!(2)) && m(&p, "is", json!(2)));
assert!(!m(&p, "gte", json!(3)) && !m(&p, "lt", json!(2)) && m(&p, "between", json!([1, 2])));
p.meta.keywords.push("Food".into());
assert!(m(&p, "is", json!(2)), "Food and food are one keyword");
p.meta.keywords.clear();
assert!(m(&p, "is", json!(0)) && m(&p, "lte", json!(2)));
assert_eq!(field_group("keywordCount"), Some("Keywords & People"));
}
/// People: the names on face regions. "People contains ana" finds Ana Lima; pets and unnamed
/// faces are not people; People Count counts each person once.
#[test]
fn people_rules() {
use lightcraft_meta::{Rect, Region, RegionKind};
let cat = Catalog::new();
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 p = photo(1);
let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&p, "person", "isEmpty", json!(null)) && m(&p, "personCount", "is", json!(0)));
p.meta.regions = vec![
region(Some("Ana Lima"), RegionKind::Face),
region(Some(" ana lima "), RegionKind::Face),
region(Some("Rex"), RegionKind::Pet),
region(None, RegionKind::Face),
region(Some("Bo"), RegionKind::Face),
];
assert!(m(&p, "person", "contains", json!("ana")));
assert!(m(&p, "person", "is", json!("ANA LIMA")));
assert!(!m(&p, "person", "is", json!("rex")), "a pet isn't a person");
assert!(m(&p, "person", "notContains", json!("rex")));
assert!(m(&p, "person", "isNotEmpty", json!(null)));
assert!(m(&p, "personCount", "is", json!(2)), "Ana Lima once, Bo; not the pet or the unnamed face");
assert_eq!(field_group("person"), Some("Keywords & People"));
}
/// Shutter Speed compares exposure times in seconds, written as the camera shows them: a
/// photo at 1/250 is in "≤ 1/60" (faster) and not in "≥ 1/60"; "between 1/1000 and 1/125".
#[test]
fn shutter_speed_rules() {
let cat = Catalog::new();
let mut p = photo(1);
let m =
|p: &Photo, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat);
assert!(!m(&p, "gte", json!(0)), "no shutter speed recorded");
p.meta.shutter = "1/250".into();
assert!(m(&p, "is", json!("1/250")) && m(&p, "lte", json!("1/60")) && !m(&p, "gte", json!("1/60")));
assert!(m(&p, "between", json!(["1/1000", "1/125"])) && m(&p, "lt", json!(0.01)));
assert!(!m(&p, "is", json!("1/0")) && !m(&p, "is", json!("fast")), "a value that isn't a time matches nothing");
p.meta.shutter = "2\"".into();
assert!(m(&p, "gte", json!("1")) && m(&p, "is", json!(2)));
assert_eq!(field_group("shutterSpeed"), Some("Camera Info"));
}
#[test]
fn shutter_seconds_parses_camera_notation() {
for (s, want) in [("1/250", Some(0.004)), (" 1/250 s", Some(0.004)), ("0.5", Some(0.5)), ("2\"", Some(2.0)), ("30s", Some(30.0))] {
assert_eq!(crate::parse_shutter_seconds(s), want, "{s}");
}
for s in ["", "1/0", "0", "-1/250", "fast", "inf", "NaN", "1/x"] {
assert_eq!(crate::parse_shutter_seconds(s), None, "{s}");
}
}
/// File Extension ignores case and a leading dot; Copy Name, Alt Text, City, State /
/// Province and Country are text fields of their own (Location still matches all of them).
#[test]
fn file_source_description_and_location_rules() {
let cat = Catalog::new();
let mut p = photo(1); // IMG_0042.CR2
let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&p, "extension", "is", json!("cr2")) && m(&p, "extension", "is", json!(".CR2")));
assert!(!m(&p, "extension", "is", json!("jpg")));
p.file_name = "README".into();
assert!(m(&p, "extension", "isEmpty", json!(null)), "no dot, no extension");
p.file_name = "archive.tar.gz".into();
assert!(m(&p, "extension", "is", json!("gz")));
p.file_name = ".hidden".into();
assert!(m(&p, "extension", "isEmpty", json!(null)), "a dot file has no extension");
assert!(m(&p, "copyName", "isEmpty", json!(null)));
p.copy_name = Some("Black and white".into());
assert!(m(&p, "copyName", "contains", json!("black")));
p.meta.alt_text = "A red kite over a hill".into();
assert!(m(&p, "altText", "contains", json!("kite")));
p.meta.city = "Porto".into();
p.meta.state = "Porto District".into();
p.meta.country = "Portugal".into();
assert!(m(&p, "city", "is", json!("porto")) && m(&p, "state", "startsWith", json!("porto")) && m(&p, "country", "is", json!("portugal")));
assert!(!m(&p, "city", "is", json!("portugal")), "City tests only the city");
assert!(m(&p, "location", "contains", json!("portugal")), "Location still covers the country");
}
/// In a Stack, Video Duration (seconds; photos have none), Cropped and Treatment.
#[test]
fn source_file_and_develop_rules() {
use crate::{Op, Stack, StackId};
let mut cat = Catalog::new();
let a = photo(1);
let b = photo(2);
let c = photo(3);
for p in [&a, &b, &c] {
cat.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
}
cat.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![a.id, b.id], collapsed: false } }).unwrap();
let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&a, "stacked", "is", json!(true)) && m(&b, "stacked", "is", json!(true)));
assert!(m(&c, "stacked", "is", json!(false)));
let mut v = photo(4);
assert!(!m(&v, "duration", "gte", json!(0)), "a photo has no duration");
v.duration = Some(95.0);
assert!(m(&v, "duration", "gt", json!(60)) && m(&v, "duration", "between", json!([90, 120])));
let mut d = photo(5);
assert!(m(&d, "cropped", "is", json!(false)) && m(&d, "treatment", "is", json!("color")));
let mut s = (*d.develop).clone();
s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9, 1.0);
s.treatment = lightcraft_develop::Treatment::Bw;
d.develop = std::sync::Arc::new(s);
assert!(m(&d, "cropped", "is", json!(true)) && m(&d, "treatment", "is", json!("monochrome")));
assert!(m(&d, "treatment", "isNot", json!("color")));
let mut s = (*d.develop).clone();
s.crop = Default::default();
s.crop.geometry.angle = 2.0;
d.develop = std::sync::Arc::new(s);
assert!(m(&d, "cropped", "is", json!(true)), "straightening counts as a crop");
}
/// Size follows the photo as shown: rotated a quarter turn a landscape frame is a portrait, and
/// a crop changes its edges and its megapixels. Long / Short Edge are in pixels.
#[test]
fn size_rules_follow_orientation_and_crop() {
let cat = Catalog::new();
let mut p = photo(1); // 6000 × 4000
let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| {
rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat)
};
assert!(m(&p, "aspect", "is", json!("landscape")) && m(&p, "longEdge", "is", json!(6000)) && m(&p, "shortEdge", "is", json!(4000)));
assert!(m(&p, "megapixels", "is", json!(24)));
let mut s = (*p.develop).clone();
s.orientation = lightcraft_geom::Orientation::Rotate90;
p.develop = std::sync::Arc::new(s.clone());
assert!(m(&p, "aspect", "is", json!("portrait")) && m(&p, "longEdge", "is", json!(6000)));
// a square crop of the rotated frame: 4000 wide, 4000 of its 6000 tall
s.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 1.0 / 6.0, 1.0, 5.0 / 6.0);
p.develop = std::sync::Arc::new(s);
assert!(m(&p, "aspect", "is", json!("square")) && m(&p, "longEdge", "is", json!(4000)));
assert!(m(&p, "megapixels", "is", json!(16)), "a 4000 × 4000 crop is 16 MP, not the sensor's 24");
assert!(m(&p, "megapixels", "lt", json!(20)) && !m(&p, "megapixels", "gte", json!(24)));
assert!(m(&p, "aspect", "isNot", json!("portrait")));
let empty = Photo::new(PhotoId(9), Source::Demo { scene: 0 }, "x.jpg", "JPEG", 0, 0, "2026-09-20T10:00:00");
assert!(!m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0)));
assert!(m(&empty, "megapixels", "is", json!(0)));
}
/// Any Searchable Text also finds the state / province, the alt text, a person on a face and
/// a virtual copy's name, as it already finds the city and country.
#[test]
fn any_searchable_text_covers_new_fields() {
let cat = Catalog::new();
let mut p = photo(1);
let m = |p: &Photo, value: &str| rs(json!({"rules": [{"field": "text", "op": "contains", "value": value}]})).matches(p, &cat);
for word in ["oregon", "kite", "ana", "bluish"] {
assert!(!m(&p, word), "{word}");
}
p.meta.state = "Oregon".into();
p.meta.alt_text = "A kite".into();
p.meta.regions = vec![lightcraft_meta::Region {
rect: lightcraft_meta::Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
kind: lightcraft_meta::RegionKind::Face,
name: Some("Ana".into()),
description: None,
}];
p.copy_name = Some("Bluish".into());
for word in ["oregon", "kite", "ana", "bluish"] {
assert!(m(&p, word), "{word}");
}
}
/// Choice values have readable labels: the editor and summaries show "Public Domain" and
/// "Black & White", never ids like `publicDomain`; rules still store and accept the ids.
#[test]
fn choices_have_readable_labels() {
assert_eq!(choice_label("copyrightStatus", "publicDomain"), Some("Public Domain"));
assert_eq!(choice_label("treatment", "monochrome"), Some("Black & White"));
// own labels where a shared word would translate wrongly: "Color" is the colour noun (the
// Color panel) and "None" agrees with other nouns in gendered languages
assert_eq!(choice_label("treatment", "color"), Some("In Color"));
assert_eq!(choice_label("label", "none"), Some("No Label"));
assert_eq!(choice_label("flag", "pick"), Some("Picked"));
assert_eq!(choice_label("aspect", "landscape"), Some("Landscape (wide)"));
assert_eq!(choice_label("flag", "nope"), None);
assert_eq!(choice_label("rating", "pick"), None, "not a choice field");
for (field, _, kind) in FIELDS {
let Kind::Choice(choices) = kind else { continue };
for (id, label) in *choices {
assert!(!label.is_empty() && label != id, "{field}: {id} has no label of its own");
assert!(label.starts_with(|c: char| c.is_uppercase()), "{field}: {label}");
}
}
let r = rs(
json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}, {"field": "flag", "op": "isNot", "value": "reject"}]}),
);
assert_eq!(r.describe(), "copyright status is public domain and pick flag isn't rejected");
let mut p = photo(1);
p.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
assert!(r.matches(&p, &Catalog::new()), "ids still match");
}
/// Yes/no fields read "Yes" / "No", and a value means the same to matching and to every
/// summary: `false`, `"false"`, `"no"` and `0` are no; `true`, `"yes"`, `1` and a missing value
/// are yes. Anything else is reported, not silently taken as yes.
#[test]
fn yes_no_values() {
for v in [json!(true), json!("true"), json!("Yes"), json!(1), json!(1.0), json!(null)] {
assert_eq!(bool_value(&v), Some(true), "{v}");
}
for v in [json!(false), json!("false"), json!(" NO "), json!(0), json!(0.0)] {
assert_eq!(bool_value(&v), Some(false), "{v}");
}
for v in [json!("maybe"), json!(2), json!(0.5), json!(-1), json!([true]), json!({"x": 1})] {
assert_eq!(bool_value(&v), None, "{v}");
}
assert_eq!((bool_label(true), bool_label(false)), ("Yes", "No"));
let cat = Catalog::new();
let p = photo(1); // unedited
assert!(rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}]})).matches(&p, &cat), "\"false\" means no");
assert!(
!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}, {"field": "hasGps", "op": "is", "value": "yes"}]})).matches(&p, &cat)
);
assert!(!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})).matches(&p, &cat), "an unreadable value matches nothing");
let r = rs(json!({"rules": [{"field": "edited", "op": "is", "value": true}, {"field": "cropped", "op": "is", "value": "false"}]}));
assert_eq!(r.describe(), "has edits is yes and cropped is no");
let bad = rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]}));
let messages: Vec<String> = bad.check(&cat).into_iter().map(|p| p.message).collect();
assert_eq!(messages, vec!["`edited` is yes or no, not \"maybe\"".to_string()]);
}
/// "In the last N" never overflows, whatever N an agent or a saved file holds: a zero,
/// negative or unreadable N matches nothing (either way round); an N beyond 10,000 years means
/// "ever", so every dated photo is in it and none is outside it.
#[test]
fn in_the_last_survives_hostile_counts() {
set_now(Some("2026-09-01T00:00:00".into()));
let cat = Catalog::new();
let p = photo(1); // captured 2026-08-14
let m = |op: &str, n: serde_json::Value, unit: &str| {
rs(json!({"rules": [{"field": "captureDate", "op": op, "value": {"n": n, "unit": unit}}]})).matches(&p, &cat)
};
for n in [json!(0), json!(-5), json!(-1e30), json!(-9.3e18), json!("soon")] {
for op in ["inLast", "notInLast"] {
assert!(!m(op, n.clone(), "years"), "{op} {n}");
}
}
for n in [json!(1e30), json!(9.3e18), json!(u64::MAX), json!(20_000)] {
assert!(m("inLast", n.clone(), "years"), "{n} years is ever");
assert!(!m("notInLast", n.clone(), "years"), "{n}");
}
assert!(m("inLast", json!(1e30), "hours") && m("inLast", json!(30), "days"));
set_now(None);
}
/// Every rule's value is checked against its field, so a rule that can't mean anything is
/// reported instead of quietly matching nothing (or everything). Each problem says which rule
/// it is about: "rule 2", or "rule 2.1" inside a group.
#[test]
fn values_are_checked_against_their_field() {
use crate::{Album, AlbumId, Op};
let mut cat = Catalog::new();
cat.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap();
let smart = Album { smart: Some(Box::default()), ..Album::new(AlbumId(2), "Best") };
cat.apply(Op::AddAlbum { album: smart }).unwrap();
cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Client".into()) }).unwrap();
let check = |rule: serde_json::Value| rs(json!({"rules": [rule]})).check(&cat);
let ok = |rule: serde_json::Value| assert!(check(rule.clone()).is_empty(), "{rule}: {:?}", check(rule.clone()));
let bad = |rule: serde_json::Value, want: &str| {
let p = check(rule.clone());
assert!(p.iter().any(|p| p.message.contains(want)), "{rule}: wanted {want:?}, got {p:?}");
};
let r = |field: &str, op: &str, value: serde_json::Value| json!({"field": field, "op": op, "value": value});
// numbers
ok(r("rating", "gte", json!(3)));
ok(r("rating", "gte", json!("4")));
ok(r("rating", "between", json!([1, 5])));
ok(r("aperture", "lte", json!("f/4")));
ok(r("focalLength", "is", json!("50mm")));
bad(r("rating", "gte", json!("abc")), "needs a number");
bad(r("rating", "gte", json!(7)), "no rating 0–5 is ≥ 7");
bad(r("iso", "between", json!([100])), "two");
bad(r("iso", "between", json!("100")), "two");
ok(r("shutterSpeed", "lte", json!("1/60")));
bad(r("shutterSpeed", "lte", json!("fast")), "1/250");
// dates
for d in ["2026", "2026-08", "2026-08-14", "2026-08-14T10:00:00"] {
ok(r("captureDate", "is", json!(d)));
}
ok(r("captureDate", "between", json!(["2026-01", "2026-12"])));
for d in [json!("banana"), json!("2026-13"), json!("2026-02-30"), json!(""), json!(2026)] {
bad(r("captureDate", "after", d), "needs a date");
}
bad(r("captureDate", "between", json!(["2026-12", "2026-01"])), "after the second");
bad(r("captureDate", "between", json!(["", "2026"])), "needs a date");
ok(r("captureDate", "inLast", json!({"n": 2, "unit": "weeks"})));
ok(r("captureDate", "inLast", json!(7)));
bad(r("captureDate", "inLast", json!({"n": 0, "unit": "days"})), "more than 0");
bad(r("captureDate", "notInLast", json!({"n": 3, "unit": "fortnights"})), "fortnights");
ok(r("captureDate", "isEmpty", json!(null)));
// choices: the ids, the flag's aliases and a colour label's custom name
ok(r("kind", "is", json!("raw")));
ok(r("flag", "is", json!("picked")));
ok(r("label", "is", json!("client")));
bad(r("label", "is", json!("pink")), "pink");
bad(r("kind", "isNot", json!("photo")), "photo");
// text needs something to look for
ok(r("title", "contains", json!("kite")));
ok(r("title", "isEmpty", json!(null)));
bad(r("title", "contains", json!(" ")), "something to look for");
bad(r("keywords", "startsWith", json!("")), "something to look for");
// albums: one that exists, plain or smart
ok(r("album", "is", json!(1)));
bad(r("album", "is", json!(999)), "no album 999");
ok(r("album", "is", json!(2)));
// groups: an empty one is a mistake; problems inside one say where they are
let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"match": "any", "rules": []}}]})).check(&cat);
assert_eq!(p.iter().map(ToString::to_string).collect::<Vec<_>>(), vec!["rule 2: an empty group".to_string()]);
let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"rules": [r("iso", "is", json!(100)), r("rating", "is", json!(9))]}}]}))
.check(&cat);
assert_eq!(p.len(), 1);
assert_eq!(p[0].path, vec![1, 1]);
assert_eq!(p[0].to_string(), "rule 2.2: no rating 0–5 is 9");
// an empty rule list at the top is "all photos" (or none, for any), not a mistake
assert!(rs(json!({"rules": []})).check(&cat).is_empty());
}
/// The check and the matcher agree. Units are any case, singular or plural, and missing means
/// days; an unknown unit is refused and matches nothing (it used to count as days, so "week" was
/// one day). A rating rule is refused only when no rating 0–5 could meet it. A colour label's
/// custom name matches in any case, accents included. Empty text hints at the right operator.
#[test]
fn check_and_matcher_agree() {
use crate::Op;
set_now(Some("2026-09-01T00:00:00".into()));
let mut cat = Catalog::new();
cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Été".into()) }).unwrap();
let p = photo(1); // captured 2026-08-14 (18 days before now), red label, rating 4
let rule = |field: &str, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": field, "op": op, "value": value}]}));
let valid = |r: &RuleSet| r.check(&cat).is_empty();
for unit in [json!("Days"), json!("DAY"), json!("days"), json!(null)] {
let r = rule("captureDate", "inLast", json!({"n": 30, "unit": unit}));
assert!(valid(&r) && r.matches(&p, &cat), "{unit}");
}
let r = rule("captureDate", "inLast", json!({"n": 30}));
assert!(valid(&r) && r.matches(&p, &cat), "no unit is days");
let week = rule("captureDate", "inLast", json!({"n": 1, "unit": "week"}));
assert!(valid(&week) && !week.matches(&p, &cat), "a week, not a day: 18 days ago is outside it");
assert!(rule("captureDate", "inLast", json!({"n": 3, "unit": "Weeks"})).matches(&p, &cat));
let odd = rule("captureDate", "inLast", json!({"n": 30, "unit": "fortnights"}));
assert!(!valid(&odd) && !odd.matches(&p, &cat), "an unknown unit is refused and matches nothing");
// rating: only a rule no rating could meet
for (op, v) in [("lt", json!(6)), ("gt", json!(-1)), ("isNot", json!(9)), ("between", json!([3, 8]))] {
assert!(valid(&rule("rating", op, v.clone())), "{op} {v}");
}
for (op, v) in [("is", json!(9)), ("gte", json!(7)), ("gt", json!(5)), ("lt", json!(0)), ("between", json!([6, 9]))] {
assert!(!valid(&rule("rating", op, v.clone())), "{op} {v}");
}
// a custom label name, any case, accents too
let r = rule("label", "is", json!("été"));
assert!(valid(&r) && r.matches(&p, &cat));
// "isn't" with nothing points at "isn't empty", the others at "is empty"
let hint = |op: &str| rule("title", op, json!("")).check(&cat).first().map(|p| p.message.clone()).unwrap_or_default();
assert!(hint("isNot").contains("isn't empty"), "{}", hint("isNot"));
assert!(hint("contains").contains("“is empty”"), "{}", hint("contains"));
set_now(None);
}
/// An album is in it or not: Album offers "is" and "isn't", never "≥" or "between".
#[test]
fn album_rules_are_is_or_isnt() {
assert_eq!(field_kind("album"), Some(Kind::Album));
let ops: Vec<&str> = ops_for(Kind::Album).iter().map(|o| o.0).collect();
assert_eq!(ops, ["is", "isNot"]);
let r = rs(json!({"rules": [{"field": "album", "op": "gte", "value": 1}]}));
assert_eq!(r.check(&Catalog::new()).first().map(|p| p.message.as_str()), Some("`album` has no operator `gte`"));
}
/// An Album rule names its album by id, written as a number or a numeric string, the same way
/// for the check, the matcher and the editor. A folder holds albums, not photos, so it can't be
/// tested.
#[test]
fn album_rule_ids() {
use crate::{Album, AlbumId, Op};
assert_eq!(album_rule_id(&json!(3)), Some(AlbumId(3)));
assert_eq!(album_rule_id(&json!("3")), Some(AlbumId(3)));
assert_eq!(album_rule_id(&json!(3.0)), Some(AlbumId(3)));
for v in [json!(1.5), json!(-1), json!("x"), json!(null)] {
assert_eq!(album_rule_id(&v), None, "{v}");
}
let mut cat = Catalog::new();
cat.apply(Op::AddAlbum { album: Album { folder: true, ..Album::new(AlbumId(4), "Trips") } }).unwrap();
let p = rs(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})).check(&cat);
assert!(p.first().is_some_and(|p| p.message.contains("folder")), "{p:?}");
}
/// Album rules saved before Album offered only "is" / "isn't" (≥, between… from when it was a
/// number field) always meant "isn't" to the matcher; upgrading says so, so they check again.
#[test]
fn old_album_operators_upgrade_to_isnt() {
let mut r = rs(json!({"rules": [
{"field": "album", "op": "gte", "value": 1},
{"field": "album", "op": "is", "value": 1},
{"field": "rating", "op": "gte", "value": 3},
{"group": {"rules": [{"field": "album", "op": "between", "value": [1, 2]}]}}
]}));
r.upgrade();
let ops = serde_json::to_value(&r).unwrap().to_string();
assert!(ops.contains(r#""field":"album","op":"isNot","value":1"#), "{ops}");
assert!(ops.contains(r#""field":"album","op":"is","value":1"#), "{ops}");
assert!(ops.contains(r#""field":"rating","op":"gte""#), "other fields keep theirs: {ops}");
assert!(ops.contains(r#""field":"album","op":"isNot","value":[1,2]"#), "inside groups too: {ops}");
}
/// Besides its English message (for agents), a problem names its field and the kind of issue,
/// so the editor can show "Title: needs something to look for" in any language. Every issue
/// has its own short text.
#[test]
fn problems_name_their_field_and_issue() {
let cat = Catalog::new();
let check = |rules: serde_json::Value| rs(json!({"rules": rules})).check(&cat);
let p = check(json!([{"field": "title", "op": "contains", "value": ""}]));
assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("title"), Issue::NeedsTextOrEmpty));
let p = check(json!([{"field": "title", "op": "isNot", "value": ""}]));
assert_eq!(p[0].issue, Issue::NeedsTextOrNotEmpty);
// keywords and people say "are empty", and so does their hint
let p = check(json!([{"field": "keywords", "op": "contains", "value": ""}, {"field": "person", "op": "notContains", "value": " "}]));
assert_eq!((p[0].issue, p[1].issue), (Issue::NeedsNamesOrEmpty, Issue::NeedsNamesOrNotEmpty));
assert!(p[0].issue.text().contains("“are empty”") && p[0].message.contains("“are empty”"));
let p = check(json!([{"field": "rating", "op": "is", "value": 9}, {"group": {"rules": []}}]));
assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("rating"), Issue::NoRatingMatches));
assert_eq!((p[1].field.as_deref(), p[1].issue), (None, Issue::EmptyGroup));
let p = check(json!([{"field": "captureDate", "op": "between", "value": ["2026-12", "2026-01"]}]));
assert_eq!(p[0].issue, Issue::DatesReversed);
let p = check(json!([{"field": "album", "op": "is", "value": null}]));
assert_eq!(p[0].issue, Issue::ChooseAlbum);
let mut texts: Vec<&str> = Issue::ALL.iter().map(|i| i.text()).collect();
assert!(texts.iter().all(|t| !t.is_empty()));
texts.sort_unstable();
texts.dedup();
assert_eq!(texts.len(), Issue::ALL.len(), "each issue reads differently");
}
/// A rule date reads one way for the check and the matcher: a space works as the "T"
/// ("2026-10-01 10:00" matches a photo taken then). Imported capture times can carry fractions
/// of a second and a time zone (EXIF SubSecTime / OffsetTime: "…T10:00:00.250+02:00"), so a
/// rule may too, after the seconds; anywhere else they are no date.
#[test]
fn rule_dates_have_one_reading() {
let cat = Catalog::new();
let mut p = photo(1);
p.captured = Some("2026-10-01T10:00:00".into());
let rule = |op: &str, v: serde_json::Value| rs(json!({"rules": [{"field": "captureDate", "op": op, "value": v}]}));
for (op, v) in [
("is", json!("2026-10-01 10:00")),
("is", json!(" 2026-10-01T10 ")),
("between", json!(["2026-10-01 09:00", "2026-10-01 11:00"])),
("after", json!("2026-10-01 09:59")),
] {
let r = rule(op, v.clone());
assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}");
}
// a capture time with a fraction and a zone, as imports write it
let mut zoned = photo(2);
zoned.captured = Some("2026-08-14T10:00:00.250+02:00".into());
for v in ["2026-08-14T10:00:00.250", "2026-08-14T10:00:00.250+02:00", "2026-08-14T10:00:00"] {
let r = rule("is", json!(v));
assert!(r.check(&cat).is_empty() && r.matches(&zoned, &cat), "{v}");
}
assert!(rule("is", json!("2026-08-14T10:00:00Z")).check(&cat).is_empty());
for v in [
"2026-8-14",
"2026-08-14T1",
"2026-08-14X10",
"26-08-14",
"2026-08-14T25:00",
"2026-08-14Z",
"2026-08-14 10:00-05:00",
"2026-08-14T10:00:00.",
"2026-08-14T10:00:00+2",
] {
let p = rule("is", json!(v)).check(&cat);
assert_eq!(p.first().map(|p| p.issue), Some(Issue::NeedsDate), "{v}");
}
assert_eq!(rule_date("2026-10-01 10:00"), Ok("2026-10-01T10:00".to_string()));
assert_eq!(rule_date("2026"), Ok("2026".to_string()));
}
/// The field menu shows every rule field once: at the top level or in exactly one group,
/// and [`FIELDS`] lists them in menu order.
#[test]
fn every_field_is_in_the_menu_exactly_once() {
let menu: Vec<&str> = TOP_LEVEL_FIELDS.iter().chain(FIELD_GROUPS.iter().flat_map(|g| g.1.iter())).copied().collect();
let fields: Vec<&str> = FIELDS.iter().map(|f| f.0).collect();
assert_eq!(menu, fields, "the menu and FIELDS list the same fields in the same order");
for (label, group) in FIELD_GROUPS {
assert!(!group.is_empty(), "group {label} is empty");
}
let mut labels: Vec<&str> = FIELD_GROUPS.iter().map(|g| g.0).collect();
labels.dedup();
assert_eq!(labels.len(), FIELD_GROUPS.len(), "group labels are unique");
}
/// Related fields sit together: all the file fields, all the camera (EXIF) fields, all the
/// dates, all the keyword fields.
#[test]
fn related_fields_share_a_group() {
let same = |fields: &[&str], group: &str| {
for f in fields {
assert_eq!(field_group(f), Some(group), "{f}");
}
};
same(&["fileName", "extension", "filePath", "kind", "format", "duration"], "File");
same(&["album", "virtualCopy", "copyName", "stacked"], "Source");
same(&["longEdge", "shortEdge", "aspect", "megapixels"], "Size");
same(&["edited", "cropped", "treatment"], "Develop");
same(&["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"], "Camera Info");
same(&["captureDate", "importDate", "editDate"], "Date");
same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People");
same(&["title", "caption", "altText", "creator", "copyright", "copyrightStatus"], "Description");
same(&["location", "city", "state", "country", "hasGps"], "Location");
for f in ["rating", "flag", "label", "text"] {
assert_eq!(field_group(f), None, "{f} stays at the top level");
}
assert_eq!(field_group("nope"), None);
assert_eq!(field_label("filePath"), Some("File Path"));
assert_eq!(field_label("nope"), None);
}
/// The `filePath` rule exactly as it was before the in-place ASCII path: new strings per photo.
fn file_path_old(op: &str, source: &Source, value: &serde_json::Value) -> bool {
let want = lower(value);
let path = match source {
Source::File { path } => path.replace('\\', "/"),
Source::Demo { .. } => String::new(),
};
let want = want.replace('\\', "/");
match op {
"contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()),
"notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()),
_ => text_op(op, &path, &want),
}
}
#[test]
fn file_path_rules_match_the_old_strings_for_any_path() {
let paths = [
"",
" ",
" /a/b.jpg \t",
"/Volumes/share/photos/Trip/IMG_0001.jpg",
"/Volumes/share/PHOTOS/trip/img_0001.JPG",
r"D:\Photos\Lake Como\IMG_0001.JPG",
r"d:/photos/lake como/img_0001.jpg",
r"\\nas\share\x.jpg",
"/photos/Straße/a.jpg",
"/photos/STRASSE/a.jpg",
"/photos/İstanbul/a.jpg",
"/photos/i̇stanbul/a.jpg",
"/photos/ΟΔΟΣ/a.jpg",
"/photos/οδος/a.jpg",
"/photos/ΟΔΌΣ/a.jpg",
"/photos/日本/東京.jpg",
"/p/\u{0B}x\u{0B}",
"/p/\u{85}x\u{85}",
"a",
];
let wants = [
"",
" ",
"a",
"A",
"/",
r"\",
r"\photos\",
"/photos/",
"photos",
"PHOTOS",
"Lake Como",
" lake como ",
"lake como",
r"d:\photos\lake",
"IMG_0001.JPG",
".jpg",
"straße",
"STRASSE",
"strasse",
"İstanbul",
"i̇stanbul",
"istanbul",
"ΟΔΟΣ",
"οδος",
"οδοσ",
"ΟΔΌΣ",
"東京",
"日本",
"x",
"\u{0B}x",
"/a/b.jpg",
"/a/b.jpg \t",
"\u{85}x",
];
let ops = ["contains", "notContains", "is", "isNot", "startsWith", "endsWith", "isEmpty", "isNotEmpty", "bogus"];
let mut values: Vec<serde_json::Value> = wants.iter().map(|w| json!(w)).collect();
values.extend([json!(null), json!(5), json!(true), json!(["a", "b"])]);
let mut sources: Vec<Source> = paths.iter().map(|p| Source::File { path: (*p).into() }).collect();
sources.push(Source::Demo { scene: 0 });
for source in &sources {
for op in ops {
for value in &values {
assert_eq!(file_path_matches(op, source, value), file_path_old(op, source, value), "{source:?} {op} {value}");
}
}
}
}
#[test]
fn file_path_rule_through_a_rule_set_ignores_case_and_slash_direction() {
let mut p = photo(1);
p.source = Source::File { path: r"D:\Photos\Lake Como\IMG_0001.JPG".into() };
let r = rs(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/LAKE como/"}]}));
assert!(r.matches(&p, &Catalog::default()));
}
/// Every piece of a long path, in both cases and both slash directions, wherever it starts
/// (the search skips eight bytes at a time, so starts and ends at every offset matter).
#[test]
fn file_path_contains_finds_every_piece_of_a_long_path() {
let path = r"D:\Photos\Lake Como\Wedding-Day_2026\IMG_0001 (copy).JPG";
let source = Source::File { path: path.into() };
let shout = |s: &str| s.to_uppercase();
let swap = |s: &str| s.replace('\\', "/");
for from in 0..path.len() {
for to in from + 1..=path.len() {
let piece = &path[from..to];
for want in [piece.to_string(), shout(piece), swap(piece), piece.replace('/', r"\"), format!("{piece}x"), format!("x{piece}")] {
let value = json!(want);
for op in ["contains", "notContains"] {
assert_eq!(file_path_matches(op, &source, &value), file_path_old(op, &source, &value), "{op} {want:?}");
}
}
}
}
}
+19 -13
View File
@@ -277,7 +277,7 @@ pub fn same_file(a: &Path, b: &Path) -> bool {
/// [`same_file`] for a check that must not let an unknown identity pass: `Err` when the metadata
/// of either path cannot be read for a reason other than the path not existing (for example a
/// folder it cannot enter), or when both paths exist with the same length but the file identity
/// of either cannot be read (on Windows, when either file cannot be opened). A path that does not
/// of either cannot be read (on Windows, when either file cannot be opened or reports file index 0). A path that does not
/// exist is still `Ok(false)`.
pub fn same_file_strict(a: &Path, b: &Path) -> io::Result<bool> {
compare_files(a, b, true)
@@ -301,12 +301,12 @@ fn compare_files(a: &Path, b: &Path, strict: bool) -> io::Result<bool> {
return Ok(false);
}
// A hard link has its own path but shares the volume serial number and file index with
// its original. A volume that reports file index 0 is compared by resolved path below.
// its original. A file index of 0 produces an error in strict mode. Lenient mode compares resolved paths.
// On ReFS two files can share the 64-bit file index, so the lengths are compared first.
#[cfg(windows)]
{
match file_ids(a, b) {
Ok((ia, ib)) if same_file_id(ia, ib) => return Ok(true),
match file_ids(a, b).and_then(|(ia, ib)| same_file_id(ia, ib, strict)) {
Ok(true) => return Ok(true),
Ok(_) => {}
Err(e) if strict => return Err(e),
Err(_) => {}
@@ -328,11 +328,13 @@ fn file_ids(a: &Path, b: &Path) -> io::Result<((u64, u64), (u64, u64))> {
Ok(((ia.volume_serial_number(), ia.file_index()), (ib.volume_serial_number(), ib.file_index())))
}
/// Whether two (volume serial number, file index) pairs name one file. A file index of 0 is not
/// an identity and matches nothing.
/// The comparison uses each file's volume serial number and file index. Strict mode returns an error for file index 0.
#[cfg(any(windows, test))]
fn same_file_id(a: (u64, u64), b: (u64, u64)) -> bool {
a.1 != 0 && b.1 != 0 && a == b
fn same_file_id(a: (u64, u64), b: (u64, u64), strict: bool) -> io::Result<bool> {
if strict && (a.1 == 0 || b.1 == 0) {
return Err(io::Error::other("file identity is unavailable"));
}
Ok(a.1 != 0 && b.1 != 0 && a == b)
}
#[cfg(test)]
@@ -499,10 +501,14 @@ mod tests {
#[test]
fn a_zero_file_index_is_not_a_file_identity() {
assert!(same_file_id((7, 42), (7, 42)));
assert!(!same_file_id((7, 42), (7, 43)));
assert!(!same_file_id((7, 42), (8, 42)));
assert!(!same_file_id((7, 0), (7, 0)), "two files on a volume that reports index 0 are not the same file");
assert!(!same_file_id((0, 0), (0, 0)));
for strict in [false, true] {
assert!(same_file_id((7, 42), (7, 42), strict).unwrap());
assert!(!same_file_id((7, 42), (7, 43), strict).unwrap());
assert!(!same_file_id((7, 42), (8, 42), strict).unwrap());
}
for (a, b) in [((7, 0), (7, 0)), ((0, 0), (0, 0)), ((7, 42), (7, 0)), ((7, 0), (7, 42))] {
assert!(same_file_id(a, b, true).is_err(), "strict comparison must reject an unavailable identity: {a:?}, {b:?}");
assert!(!same_file_id(a, b, false).unwrap(), "lenient comparison can fall back to resolved paths");
}
}
}