test(backend): add in-memory SQLite test harness with db and vector tests
A shared test_support module in db.rs provides the fixture: sqlite-vec registered via auto-extension, an in-memory connection with foreign keys on, and both migrations applied - no refactoring of production code needed since every query function already takes &Connection. db.rs coverage: folder idempotency, upsert_image update semantics (favorite/rating preserved, AI tag state invalidated), the get_images filter matrix with pagination and count_images agreement, tag merge/rename/delete, user-tag precedence over AI tags in update_ai_tags, album CRUD with FK cascade, the embedding job queue (backfill, retry, consistency repair), tag search, and delete_folder cascades. vector.rs coverage: pack/unpack round-trip, embedding upsert/delete with dimension validation, and find_similar_image_ids ranking on both the global KNN and folder-scoped brute-force paths.
This commit is contained in:
@@ -562,3 +562,81 @@ fn pack_f32(values: &[f32]) -> Vec<u8> {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::test_support::{test_conn, test_image};
|
||||
|
||||
#[test]
|
||||
fn pack_unpack_roundtrip() {
|
||||
let values = vec![0.0f32, 1.5, -2.25, f32::MIN_POSITIVE, 1e10];
|
||||
assert_eq!(unpack_f32(&pack_f32(&values)), values);
|
||||
assert!(unpack_f32(&pack_f32(&[])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_embedding_rejects_wrong_dimension() {
|
||||
let conn = test_conn();
|
||||
let error = upsert_embedding(&conn, 1, &[0.5f32; 3]).unwrap_err();
|
||||
assert!(error.to_string().contains("dimension"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_and_delete_embedding_roundtrip() {
|
||||
let conn = test_conn();
|
||||
let embedding = vec![0.25f32; CLIP_VECTOR_DIM];
|
||||
upsert_embedding(&conn, 42, &embedding).unwrap();
|
||||
assert!(has_image_vector(&conn, 42).unwrap());
|
||||
|
||||
// Upsert replaces rather than duplicates.
|
||||
upsert_embedding(&conn, 42, &embedding).unwrap();
|
||||
let rows: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM image_vec WHERE image_id = 42",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rows, 1);
|
||||
|
||||
delete_embedding(&conn, 42).unwrap();
|
||||
assert!(!has_image_vector(&conn, 42).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_similar_image_ids_ranks_by_cosine_distance() {
|
||||
let conn = test_conn();
|
||||
let folder_id = crate::db::insert_folder(&conn, "C:/a", "a").unwrap();
|
||||
let base_id =
|
||||
crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/base.jpg")).unwrap();
|
||||
let close_id =
|
||||
crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/close.jpg")).unwrap();
|
||||
let far_id =
|
||||
crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/far.jpg")).unwrap();
|
||||
|
||||
let mut base = vec![0.0f32; CLIP_VECTOR_DIM];
|
||||
base[0] = 1.0;
|
||||
let mut close = vec![0.0f32; CLIP_VECTOR_DIM];
|
||||
close[0] = 1.0;
|
||||
close[1] = 0.2;
|
||||
let mut far = vec![0.0f32; CLIP_VECTOR_DIM];
|
||||
far[1] = 1.0;
|
||||
upsert_embedding(&conn, base_id, &base).unwrap();
|
||||
upsert_embedding(&conn, close_id, &close).unwrap();
|
||||
upsert_embedding(&conn, far_id, &far).unwrap();
|
||||
|
||||
// Global KNN path: nearest first, query image excluded.
|
||||
let global = find_similar_image_ids(&conn, base_id, 2, None).unwrap();
|
||||
assert_eq!(global, vec![close_id, far_id]);
|
||||
|
||||
// Folder-scoped brute-force path returns the same ranking.
|
||||
let scoped = find_similar_image_ids(&conn, base_id, 2, Some(folder_id)).unwrap();
|
||||
assert_eq!(scoped, vec![close_id, far_id]);
|
||||
|
||||
// Images without an embedding yield no matches instead of an error.
|
||||
assert!(find_similar_image_ids(&conn, 9999, 5, None)
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user