diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 311f6dcde..7625c9ab6 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -157,13 +157,20 @@ fn token_matches(p: &Photo, tok: &str) -> bool { Some((_, v)) => (x - v).abs() < 1e-9, None => false, }; + // Fields stored as `f32` compare at f32 precision: 2.8f32 widened to f64 is not 2.8. + let cmp32 = |x: f32, s: &str| match num(s) { + Some(('>', v)) => x > v as f32, + Some(('<', v)) => x < v as f32, + Some((_, v)) => x == v as f32, + None => false, + }; return match field { "rating" | "stars" => cmp(p.rating as f64, val), "flag" => Flag::parse(val) == Some(p.flag), "label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)), "iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)), - "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)), - "focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)), + "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp32(a, val)), + "focal" => p.meta.focal_mm.is_some_and(|a| cmp32(a, val)), "camera" => p.meta.camera.to_lowercase().contains(val), "lens" => p.meta.lens.to_lowercase().contains(val), "keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)), diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 255417098..a32b5acf5 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -144,6 +144,27 @@ fn free_text_with_a_colon_searches_the_text_fields() { assert_eq!(q("11:45"), Vec::::new(), "an unknown field matching no text finds nothing"); } +/// `f:`/`aperture:` and `focal:` are stored as `f32`; a typed value that isn't exact in binary +/// (2.8, 5.6) must still match, and `<`/`>` must not count the photo's own value. +#[test] +fn search_tokens_match_f32_aperture_and_focal() { + let mut c = Catalog::new(); + let a = photo(&mut c, "a.jpg", "2026-05-01T10:00:00"); + let mut m = c.photo(a).unwrap().meta.clone(); + m.aperture = Some(2.8); + m.focal_mm = Some(5.6); + c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap(); + let q = |t: &str| c.query(&Filter { text: t.into(), ..Default::default() }, &Sort::default()); + assert_eq!(q("f:2.8"), vec![a], "f:2.8"); + assert_eq!(q("aperture:=2.8"), vec![a], "aperture:=2.8"); + assert_eq!(q("focal:5.6"), vec![a], "focal:5.6"); + assert!(q("f:2.9").is_empty(), "f:2.9"); + assert!(q("f:<2.8").is_empty(), "f:<2.8"); + assert!(q("f:>2.8").is_empty(), "f:>2.8"); + assert!(q("focal:<5.6").is_empty(), "focal:<5.6"); + assert_eq!(q("f:>2.7"), vec![a], "f:>2.7"); +} + /// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an /// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be /// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores