faces: fix PathExcluder false-positive + cover face_client/crop in tests
PathExcluder was iterating every component of the absolute path,
including the system prefix. Two of the existing memories tests had
been failing on master because tempdir() lives under /tmp on Linux
and a pattern like "tmp" then matched the system /tmp component
rather than anything the user actually asked to exclude. Phase 3's
file-watch hook will use the same code to skip @eaDir / .thumbnails
under each library's BASE_PATH, so the bug would hide every photo
on a host whose BASE_PATH passes through a directory named the same
as a user pattern.
Fix: store base in PathExcluder and strip it before scanning
components. A path that lives outside base falls through to the
no-match branch (defensive — nothing legit hits that today).
Also extracted the face_client error classification into a pure
classify_error_response(status, body) so the marker-row contract
with Apollo (422 → Permanent / 'failed', 5xx → Transient / defer)
is unit-testable without spinning up an HTTP server.
New tests:
memories::tests::test_path_excluder_* — 2 previously
failing tests now pass.
ai::face_client::tests::classify_* — 4 cases:
422 decode_failed → Permanent, 503 cuda_oom → Transient
(handles both string and {code:..} detail shapes), 5xx →
Transient + other 4xx → Permanent, unparseable HTML body still
classifies on status.
faces::tests::crop_* — 3 cases:
invalid bbox rejected, valid bbox round-trips through JPEG
decode, corner crop with 10% padding clamps inside source.
cargo test --lib: 165 passed / 0 failed (was 156 / 2 failed).
cargo fmt and clippy on new code clean. The remaining
sort_by clippy warnings in pre-existing files (memories.rs,
files.rs, exif.rs) are unrelated and present on master.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
61
src/faces.rs
61
src/faces.rs
@@ -1860,4 +1860,65 @@ mod tests {
|
||||
assert_eq!(faces.len(), 1);
|
||||
assert_eq!(faces[0].person_id, Some(alice.id));
|
||||
}
|
||||
|
||||
// ── crop_image_to_bbox ──────────────────────────────────────────────
|
||||
// Pure helper used by the manual face-create handler. Generate a tiny
|
||||
// image in memory, write it to a temp file, then exercise the bbox
|
||||
// validation + crop math.
|
||||
|
||||
fn write_solid_image(w: u32, h: u32) -> tempfile::NamedTempFile {
|
||||
let mut img = image::RgbImage::new(w, h);
|
||||
for p in img.pixels_mut() {
|
||||
*p = image::Rgb([200, 200, 200]);
|
||||
}
|
||||
let f = tempfile::Builder::new()
|
||||
.suffix(".jpg")
|
||||
.tempfile()
|
||||
.expect("tempfile");
|
||||
image::DynamicImage::ImageRgb8(img)
|
||||
.save(f.path())
|
||||
.expect("save jpg");
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_rejects_invalid_bbox() {
|
||||
let f = write_solid_image(64, 64);
|
||||
// x out of [0,1]
|
||||
assert!(crop_image_to_bbox(f.path(), -0.1, 0.0, 0.5, 0.5).is_err());
|
||||
assert!(crop_image_to_bbox(f.path(), 1.5, 0.0, 0.5, 0.5).is_err());
|
||||
// zero / negative dimensions
|
||||
assert!(crop_image_to_bbox(f.path(), 0.0, 0.0, 0.0, 0.5).is_err());
|
||||
assert!(crop_image_to_bbox(f.path(), 0.0, 0.0, 0.5, -0.1).is_err());
|
||||
// overflows the image
|
||||
assert!(crop_image_to_bbox(f.path(), 0.7, 0.0, 0.5, 0.5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_returns_decodable_jpeg() {
|
||||
let f = write_solid_image(200, 200);
|
||||
let bytes = crop_image_to_bbox(f.path(), 0.25, 0.25, 0.5, 0.5).expect("center crop");
|
||||
// Re-decode to confirm the pipeline produced a valid JPEG. Exact
|
||||
// dimensions depend on the 10% padding clamp, so just assert
|
||||
// sanity bounds rather than pinning numbers (padding math can
|
||||
// legitimately drift if we tweak the heuristic later).
|
||||
let img = image::load_from_memory(&bytes).expect("decode crop");
|
||||
let (w, h) = (img.width(), img.height());
|
||||
assert!((80..=200).contains(&w), "unexpected crop width: {w}");
|
||||
assert!((80..=200).contains(&h), "unexpected crop height: {h}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_padding_clamps_to_image_bounds() {
|
||||
// A bbox right at the corner should pad inward as far as it can,
|
||||
// never outside the image — otherwise we'd pass invalid coords
|
||||
// to the embedding service.
|
||||
let f = write_solid_image(100, 100);
|
||||
let bytes = crop_image_to_bbox(f.path(), 0.9, 0.9, 0.1, 0.1).expect("corner crop");
|
||||
let img = image::load_from_memory(&bytes).expect("decode corner crop");
|
||||
// Padded crop must fit within the source's 100x100.
|
||||
assert!(img.width() <= 100);
|
||||
assert!(img.height() <= 100);
|
||||
assert!(img.width() > 0 && img.height() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user