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:
@@ -3265,3 +3265,483 @@ fn folder_exclusion_clause(
|
|||||||
.join(",");
|
.join(",");
|
||||||
format!("AND {image_alias}.folder_id NOT IN ({id_list})")
|
format!("AND {image_alias}.folder_id NOT IN ({id_list})")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shared fixtures for unit tests across modules (db, vector, …).
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_support {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(crate) fn test_conn() -> Connection {
|
||||||
|
vector::register_sqlite_vec();
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
// The r2d2 pool customizer normally sets this; mirror it so FK cascades
|
||||||
|
// (album deletion, folder deletion) behave like production.
|
||||||
|
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
|
||||||
|
migrate(&conn).unwrap();
|
||||||
|
vector::migrate(&conn).unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn test_image(folder_id: i64, path: &str) -> ImageRecord {
|
||||||
|
ImageRecord {
|
||||||
|
id: 0,
|
||||||
|
folder_id,
|
||||||
|
path: path.to_string(),
|
||||||
|
filename: path.rsplit('/').next().unwrap_or(path).to_string(),
|
||||||
|
thumbnail_path: None,
|
||||||
|
width: Some(100),
|
||||||
|
height: Some(100),
|
||||||
|
file_size: 1024,
|
||||||
|
created_at: None,
|
||||||
|
modified_at: Some("2026-01-01T00:00:00Z".into()),
|
||||||
|
taken_at: None,
|
||||||
|
mime_type: "image/jpeg".into(),
|
||||||
|
media_kind: "image".into(),
|
||||||
|
duration_ms: None,
|
||||||
|
video_codec: None,
|
||||||
|
audio_codec: None,
|
||||||
|
metadata_updated_at: None,
|
||||||
|
metadata_error: None,
|
||||||
|
favorite: false,
|
||||||
|
rating: 0,
|
||||||
|
embedding_status: "pending".into(),
|
||||||
|
embedding_model: None,
|
||||||
|
embedding_updated_at: None,
|
||||||
|
embedding_error: None,
|
||||||
|
generated_caption: None,
|
||||||
|
caption_model: None,
|
||||||
|
caption_updated_at: None,
|
||||||
|
caption_error: None,
|
||||||
|
ai_rating: None,
|
||||||
|
ai_tagger_model: None,
|
||||||
|
ai_tagged_at: None,
|
||||||
|
ai_tagger_error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::test_support::{test_conn, test_image};
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_folder_is_idempotent_per_path() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let first = insert_folder(&conn, "C:/media", "media").unwrap();
|
||||||
|
let second = insert_folder(&conn, "C:/media", "media again").unwrap();
|
||||||
|
assert_eq!(first, second);
|
||||||
|
|
||||||
|
insert_folder(&conn, "C:/other", "other").unwrap();
|
||||||
|
let folders = get_folders(&conn).unwrap();
|
||||||
|
assert_eq!(folders.len(), 2);
|
||||||
|
assert_eq!(folders[0].name, "media");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_image_updates_in_place_and_preserves_user_state() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let folder_id = insert_folder(&conn, "C:/media", "media").unwrap();
|
||||||
|
let mut img = test_image(folder_id, "C:/media/a.jpg");
|
||||||
|
let id = upsert_image(&conn, &img).unwrap();
|
||||||
|
|
||||||
|
// Simulate user state + AI state on the stored row.
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE images SET favorite = 1, rating = 4, ai_tagger_model = 'wd' WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
add_user_tag(&conn, id, "keeper").unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
|
||||||
|
VALUES (?1, 'cat', 'ai', 'wd', 0.9, datetime('now'))",
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
img.file_size = 2048;
|
||||||
|
let same_id = upsert_image(&conn, &img).unwrap();
|
||||||
|
assert_eq!(same_id, id);
|
||||||
|
|
||||||
|
let stored = get_image_by_id(&conn, id).unwrap();
|
||||||
|
assert_eq!(stored.file_size, 2048);
|
||||||
|
// favorite/rating survive re-index; AI tag state is invalidated.
|
||||||
|
assert!(stored.favorite);
|
||||||
|
assert_eq!(stored.rating, 4);
|
||||||
|
assert_eq!(stored.ai_tagger_model, None);
|
||||||
|
|
||||||
|
let tags = get_image_tags(&conn, id).unwrap();
|
||||||
|
assert_eq!(tags.len(), 1);
|
||||||
|
assert_eq!(tags[0].tag, "keeper");
|
||||||
|
assert_eq!(tags[0].source, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_images_applies_filters_and_agrees_with_count() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let folder_a = insert_folder(&conn, "C:/a", "a").unwrap();
|
||||||
|
let folder_b = insert_folder(&conn, "C:/b", "b").unwrap();
|
||||||
|
|
||||||
|
upsert_image(&conn, &test_image(folder_a, "C:/a/cat.jpg")).unwrap();
|
||||||
|
let dog_id = upsert_image(&conn, &test_image(folder_a, "C:/a/dog.jpg")).unwrap();
|
||||||
|
let mut video = test_image(folder_b, "C:/b/clip.mp4");
|
||||||
|
video.media_kind = "video".into();
|
||||||
|
video.embedding_status = "failed".into();
|
||||||
|
upsert_image(&conn, &video).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE images SET favorite = 1, rating = 5 WHERE id = ?1",
|
||||||
|
[dog_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let no_filter = get_images(
|
||||||
|
&conn, None, None, None, false, 0, false, false, None, "name_asc", 0, 100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(no_filter.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
count_images(&conn, None, None, None, false, 0, false, false, None).unwrap(),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
let by_folder = get_images(
|
||||||
|
&conn,
|
||||||
|
Some(folder_a),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
"name_asc",
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(by_folder.len(), 2);
|
||||||
|
|
||||||
|
let by_search = get_images(
|
||||||
|
&conn,
|
||||||
|
None,
|
||||||
|
Some("cat"),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
"name_asc",
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(by_search.len(), 1);
|
||||||
|
assert_eq!(by_search[0].filename, "cat.jpg");
|
||||||
|
|
||||||
|
let videos = get_images(
|
||||||
|
&conn,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("video"),
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
"name_asc",
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(videos.len(), 1);
|
||||||
|
|
||||||
|
let favorites = get_images(
|
||||||
|
&conn, None, None, None, true, 0, false, false, None, "name_asc", 0, 100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(favorites.len(), 1);
|
||||||
|
assert_eq!(favorites[0].filename, "dog.jpg");
|
||||||
|
|
||||||
|
let rated = get_images(
|
||||||
|
&conn, None, None, None, false, 3, false, false, None, "name_asc", 0, 100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rated.len(), 1);
|
||||||
|
|
||||||
|
let failed = get_images(
|
||||||
|
&conn, None, None, None, false, 0, true, false, None, "name_asc", 0, 100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(failed.len(), 1);
|
||||||
|
assert_eq!(failed[0].filename, "clip.mp4");
|
||||||
|
|
||||||
|
// Pagination: page size 2 then the remaining 1.
|
||||||
|
let page_one = get_images(
|
||||||
|
&conn, None, None, None, false, 0, false, false, None, "name_asc", 0, 2,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let page_two = get_images(
|
||||||
|
&conn, None, None, None, false, 0, false, false, None, "name_asc", 2, 2,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(page_one.len(), 2);
|
||||||
|
assert_eq!(page_two.len(), 1);
|
||||||
|
assert_eq!(page_one[0].filename, "cat.jpg");
|
||||||
|
assert_eq!(page_two[0].filename, "dog.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_tags_upgrade_ai_tags_and_survive_tag_maintenance() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
|
||||||
|
let id = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
|
||||||
|
VALUES (?1, 'cat', 'ai', 'wd', 0.9, datetime('now'))",
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let upgraded = add_user_tag(&conn, id, "cat").unwrap();
|
||||||
|
assert_eq!(upgraded.source, "user");
|
||||||
|
assert_eq!(upgraded.ai_model, None);
|
||||||
|
assert_eq!(get_image_tags(&conn, id).unwrap().len(), 1);
|
||||||
|
|
||||||
|
let tag = add_user_tag(&conn, id, "kitty").unwrap();
|
||||||
|
remove_tag(&conn, tag.id).unwrap();
|
||||||
|
assert_eq!(get_image_tags(&conn, id).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_tag_merges_into_existing_tag() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
|
||||||
|
let with_both = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
|
||||||
|
let with_old = upsert_image(&conn, &test_image(folder_id, "C:/a/b.jpg")).unwrap();
|
||||||
|
|
||||||
|
add_user_tag(&conn, with_both, "cat").unwrap();
|
||||||
|
add_user_tag(&conn, with_both, "kitty").unwrap();
|
||||||
|
add_user_tag(&conn, with_old, "kitty").unwrap();
|
||||||
|
|
||||||
|
rename_tag(&conn, "kitty", "cat").unwrap();
|
||||||
|
|
||||||
|
let both_tags = get_image_tags(&conn, with_both).unwrap();
|
||||||
|
assert_eq!(both_tags.len(), 1);
|
||||||
|
assert_eq!(both_tags[0].tag, "cat");
|
||||||
|
let old_tags = get_image_tags(&conn, with_old).unwrap();
|
||||||
|
assert_eq!(old_tags.len(), 1);
|
||||||
|
assert_eq!(old_tags[0].tag, "cat");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_tag_removes_it_everywhere_and_reports_count() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
|
||||||
|
let first = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
|
||||||
|
let second = upsert_image(&conn, &test_image(folder_id, "C:/a/b.jpg")).unwrap();
|
||||||
|
add_user_tag(&conn, first, "cat").unwrap();
|
||||||
|
add_user_tag(&conn, second, "cat").unwrap();
|
||||||
|
add_user_tag(&conn, second, "dog").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(delete_tag(&conn, "cat").unwrap(), 2);
|
||||||
|
assert_eq!(get_image_tags(&conn, first).unwrap().len(), 0);
|
||||||
|
assert_eq!(get_image_tags(&conn, second).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_crud_and_membership() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
|
||||||
|
let first = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
|
||||||
|
let second = upsert_image(&conn, &test_image(folder_id, "C:/a/b.jpg")).unwrap();
|
||||||
|
|
||||||
|
let album = create_album(&conn, "Holiday").unwrap();
|
||||||
|
assert_eq!(album.name, "Holiday");
|
||||||
|
assert_eq!(album.image_count, 0);
|
||||||
|
|
||||||
|
// Adding is idempotent.
|
||||||
|
assert_eq!(
|
||||||
|
add_images_to_album(&conn, album.id, &[first, second]).unwrap(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(add_images_to_album(&conn, album.id, &[first]).unwrap(), 0);
|
||||||
|
assert_eq!(count_album_images(&conn, album.id).unwrap(), 2);
|
||||||
|
|
||||||
|
remove_images_from_album(&conn, album.id, &[first]).unwrap();
|
||||||
|
assert_eq!(count_album_images(&conn, album.id).unwrap(), 1);
|
||||||
|
|
||||||
|
rename_album(&conn, album.id, "Trip").unwrap();
|
||||||
|
assert_eq!(get_album(&conn, album.id).unwrap().name, "Trip");
|
||||||
|
|
||||||
|
delete_album(&conn, album.id).unwrap();
|
||||||
|
assert!(list_albums(&conn).unwrap().is_empty());
|
||||||
|
// Membership rows cascade away with the album.
|
||||||
|
let orphans: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM album_images", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(orphans, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backfill_enqueues_only_unqueued_unready_images() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
|
||||||
|
let pending = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
|
||||||
|
let mut ready = test_image(folder_id, "C:/a/b.jpg");
|
||||||
|
ready.embedding_status = "ready".into();
|
||||||
|
upsert_image(&conn, &ready).unwrap();
|
||||||
|
let queued = upsert_image(&conn, &test_image(folder_id, "C:/a/c.jpg")).unwrap();
|
||||||
|
enqueue_embedding_job(&conn, queued).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(backfill_embedding_jobs(&conn).unwrap(), 1);
|
||||||
|
let has_job: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM embedding_jobs WHERE image_id = ?1",
|
||||||
|
[pending],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(has_job, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retry_failed_embeddings_skips_thumbnailless_videos() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
|
||||||
|
let mut failed_image = test_image(folder_id, "C:/a/a.jpg");
|
||||||
|
failed_image.embedding_status = "failed".into();
|
||||||
|
let failed_image_id = upsert_image(&conn, &failed_image).unwrap();
|
||||||
|
|
||||||
|
let mut failed_video = test_image(folder_id, "C:/a/clip.mp4");
|
||||||
|
failed_video.media_kind = "video".into();
|
||||||
|
failed_video.embedding_status = "failed".into();
|
||||||
|
failed_video.thumbnail_path = None;
|
||||||
|
upsert_image(&conn, &failed_video).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(retry_failed_embedding_jobs(&conn, folder_id).unwrap(), 1);
|
||||||
|
let requeued = get_image_by_id(&conn, failed_image_id).unwrap();
|
||||||
|
assert_eq!(requeued.embedding_status, "pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repair_embedding_consistency_requeues_ready_images_without_vectors() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
|
||||||
|
let mut ready = test_image(folder_id, "C:/a/a.jpg");
|
||||||
|
ready.embedding_status = "ready".into();
|
||||||
|
let ready_id = upsert_image(&conn, &ready).unwrap();
|
||||||
|
|
||||||
|
let (orphaned, requeued) = repair_embedding_consistency(&conn).unwrap();
|
||||||
|
assert_eq!(orphaned, 0);
|
||||||
|
assert_eq!(requeued, 1);
|
||||||
|
let repaired = get_image_by_id(&conn, ready_id).unwrap();
|
||||||
|
assert_eq!(repaired.embedding_status, "pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_images_by_tag_is_case_insensitive_and_paginates() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let folder_a = insert_folder(&conn, "C:/a", "a").unwrap();
|
||||||
|
let folder_b = insert_folder(&conn, "C:/b", "b").unwrap();
|
||||||
|
let first = upsert_image(&conn, &test_image(folder_a, "C:/a/a.jpg")).unwrap();
|
||||||
|
let second = upsert_image(&conn, &test_image(folder_a, "C:/a/b.jpg")).unwrap();
|
||||||
|
let third = upsert_image(&conn, &test_image(folder_b, "C:/b/c.jpg")).unwrap();
|
||||||
|
add_user_tag(&conn, first, "Cat").unwrap();
|
||||||
|
add_user_tag(&conn, second, "cat").unwrap();
|
||||||
|
add_user_tag(&conn, third, "cat").unwrap();
|
||||||
|
add_user_tag(&conn, third, "dog").unwrap();
|
||||||
|
|
||||||
|
// Query is trimmed and matched case-insensitively against stored tags.
|
||||||
|
let (all, total) =
|
||||||
|
search_images_by_tag(&conn, " CAT ", None, None, false, 0, None, 10, 0).unwrap();
|
||||||
|
assert_eq!(total, 3);
|
||||||
|
assert_eq!(all.len(), 3);
|
||||||
|
|
||||||
|
let (scoped, scoped_total) =
|
||||||
|
search_images_by_tag(&conn, "cat", Some(folder_a), None, false, 0, None, 10, 0)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(scoped_total, 2);
|
||||||
|
assert_eq!(scoped.len(), 2);
|
||||||
|
|
||||||
|
// Pagination: total stays the full count while the page shrinks.
|
||||||
|
let (page, page_total) =
|
||||||
|
search_images_by_tag(&conn, "cat", None, None, false, 0, None, 2, 2).unwrap();
|
||||||
|
assert_eq!(page_total, 3);
|
||||||
|
assert_eq!(page.len(), 1);
|
||||||
|
|
||||||
|
// Blank queries return nothing instead of matching everything.
|
||||||
|
let (empty, empty_total) =
|
||||||
|
search_images_by_tag(&conn, " ", None, None, false, 0, None, 10, 0).unwrap();
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
assert_eq!(empty_total, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_ai_tags_replaces_ai_state_without_downgrading_user_tags() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
|
||||||
|
let id = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
|
||||||
|
add_user_tag(&conn, id, "cat").unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
|
||||||
|
VALUES (?1, 'stale', 'ai', 'wd', 0.5, datetime('now'))",
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
enqueue_tagging_job(&conn, id).unwrap();
|
||||||
|
|
||||||
|
update_ai_tags(
|
||||||
|
&conn,
|
||||||
|
id,
|
||||||
|
&[("cat".into(), 0.9), ("outdoors".into(), 0.7)],
|
||||||
|
"general",
|
||||||
|
"wd",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tags = get_image_tags(&conn, id).unwrap();
|
||||||
|
assert_eq!(tags.len(), 2, "stale AI tag should be gone");
|
||||||
|
let cat = tags.iter().find(|tag| tag.tag == "cat").unwrap();
|
||||||
|
assert_eq!(cat.source, "user", "user tag must not be downgraded to ai");
|
||||||
|
let outdoors = tags.iter().find(|tag| tag.tag == "outdoors").unwrap();
|
||||||
|
assert_eq!(outdoors.source, "ai");
|
||||||
|
assert_eq!(outdoors.confidence, Some(0.7));
|
||||||
|
|
||||||
|
let record = get_image_by_id(&conn, id).unwrap();
|
||||||
|
assert_eq!(record.ai_rating.as_deref(), Some("general"));
|
||||||
|
assert_eq!(record.ai_tagger_model.as_deref(), Some("wd"));
|
||||||
|
assert_eq!(record.ai_tagger_error, None);
|
||||||
|
|
||||||
|
let pending_jobs: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM tagging_jobs WHERE image_id = ?1",
|
||||||
|
[id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pending_jobs, 0, "completed job should be dequeued");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_folder_cascades_images_tags_and_vectors() {
|
||||||
|
let conn = test_conn();
|
||||||
|
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
|
||||||
|
let id = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
|
||||||
|
add_user_tag(&conn, id, "cat").unwrap();
|
||||||
|
vector::upsert_embedding(&conn, id, &vec![0.5f32; vector::CLIP_VECTOR_DIM]).unwrap();
|
||||||
|
|
||||||
|
delete_folder(&conn, folder_id).unwrap();
|
||||||
|
|
||||||
|
assert!(get_folders(&conn).unwrap().is_empty());
|
||||||
|
let remaining_images: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM images", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(remaining_images, 0);
|
||||||
|
let remaining_tags: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM image_tags", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(remaining_tags, 0);
|
||||||
|
assert!(!vector::has_image_vector(&conn, id).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -562,3 +562,81 @@ fn pack_f32(values: &[f32]) -> Vec<u8> {
|
|||||||
}
|
}
|
||||||
out
|
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