| """ |
| Tests for FaceIndex — the reverse face search index. |
| |
| Tests run without needing ONNX models or the full app — they use |
| synthetic embeddings to verify storage/retrieval logic. |
| """ |
| from __future__ import annotations |
|
|
| import sys |
| import os |
| import numpy as np |
| import pytest |
|
|
| |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) |
|
|
| from storage.face_index import FaceIndex |
|
|
|
|
| @pytest.fixture |
| def index(): |
| """Fresh in-memory FaceIndex for each test.""" |
| return FaceIndex(":memory:") |
|
|
|
|
| def _random_embedding(seed: int) -> np.ndarray: |
| """Generate a deterministic L2-normalized random embedding.""" |
| rng = np.random.RandomState(seed) |
| emb = rng.randn(512).astype(np.float32) |
| return emb / np.linalg.norm(emb) |
|
|
|
|
| def _similar_embedding(base: np.ndarray, noise_scale: float = 0.1, seed: int = 0) -> np.ndarray: |
| """Generate an embedding similar to `base` with small noise.""" |
| rng = np.random.RandomState(seed) |
| emb = base + noise_scale * rng.randn(512) |
| return (emb / np.linalg.norm(emb)).astype(np.float32) |
|
|
|
|
| |
| |
| |
|
|
| def test_index_initializes(index): |
| assert index.count() == 0 |
| assert index.EMBEDDING_DIM == 512 |
|
|
|
|
| def test_index_stats_empty(index): |
| stats = index.stats() |
| assert stats["total_faces"] == 0 |
| assert stats["named_faces"] == 0 |
| assert stats["embedding_dim"] == 512 |
|
|
|
|
| |
| |
| |
|
|
| def test_enroll_returns_face_id(index): |
| emb = _random_embedding(42) |
| face_id = index.enroll(emb, name="Alice") |
| assert isinstance(face_id, str) |
| assert len(face_id) > 0 |
|
|
|
|
| def test_enroll_increments_count(index): |
| emb1 = _random_embedding(1) |
| emb2 = _random_embedding(2) |
| index.enroll(emb1, name="Alice") |
| assert index.count() == 1 |
| index.enroll(emb2, name="Bob") |
| assert index.count() == 2 |
|
|
|
|
| def test_enroll_with_metadata(index): |
| emb = _random_embedding(42) |
| face_id = index.enroll( |
| emb, name="Alice", |
| source_url="https://example.com/alice.jpg", |
| metadata={"age": 30, "location": "US"}, |
| ) |
| face = index.get(face_id) |
| assert face is not None |
| assert face["name"] == "Alice" |
| assert face["source_url"] == "https://example.com/alice.jpg" |
| assert face["metadata"]["age"] == 30 |
| assert face["metadata"]["location"] == "US" |
|
|
|
|
| def test_enroll_rejects_wrong_shape(index): |
| bad_emb = np.zeros(256, dtype=np.float32) |
| with pytest.raises(ValueError, match="shape"): |
| index.enroll(bad_emb) |
|
|
|
|
| def test_enroll_l2_normalizes(index): |
| """Even if the input isn't L2-normalized, the index should handle it.""" |
| emb = np.ones(512, dtype=np.float32) * 5 |
| face_id = index.enroll(emb, name="Test") |
| assert face_id |
|
|
|
|
| |
| |
| |
|
|
| def test_search_returns_best_match_first(index): |
| """The most similar face should be returned first.""" |
| emb_alice = _random_embedding(1) |
| emb_bob = _random_embedding(2) |
| index.enroll(emb_alice, name="Alice") |
| index.enroll(emb_bob, name="Bob") |
|
|
| |
| query = _similar_embedding(emb_alice, noise_scale=0.1) |
| results = index.search(query, top_k=5, threshold=0.0) |
|
|
| assert len(results) >= 1 |
| assert results[0]["name"] == "Alice" |
| assert results[0]["similarity"] > results[1]["similarity"] if len(results) > 1 else True |
|
|
|
|
| def test_search_returns_empty_when_index_empty(index): |
| query = _random_embedding(42) |
| results = index.search(query, top_k=5, threshold=0.0) |
| assert results == [] |
|
|
|
|
| def test_search_threshold_filters_results(index): |
| """Threshold should filter out low-similarity matches.""" |
| emb_alice = _random_embedding(1) |
| emb_bob = _random_embedding(2) |
| index.enroll(emb_alice, name="Alice") |
| index.enroll(emb_bob, name="Bob") |
|
|
| |
| query = _similar_embedding(emb_alice, noise_scale=0.05) |
|
|
| |
| results = index.search(query, top_k=5, threshold=0.5) |
| assert all(r["similarity"] >= 0.5 for r in results) |
| assert any(r["name"] == "Alice" for r in results) |
| assert not any(r["name"] == "Bob" for r in results) |
|
|
|
|
| def test_search_top_k_limits_results(index): |
| for i in range(10): |
| emb = _random_embedding(i) |
| index.enroll(emb, name=f"Person_{i}") |
|
|
| query = _random_embedding(0) |
| results = index.search(query, top_k=3, threshold=0.0) |
| assert len(results) <= 3 |
|
|
|
|
| def test_search_similarity_values_match_numpy(index): |
| """Cosine similarity returned by sqlite-vec should match numpy's dot product.""" |
| emb1 = _random_embedding(1) |
| emb2 = _random_embedding(2) |
| index.enroll(emb1, name="Alice") |
| index.enroll(emb2, name="Bob") |
|
|
| query = _similar_embedding(emb1, noise_scale=0.1) |
|
|
| |
| expected_sim_alice = float(np.dot(query, emb1)) |
|
|
| results = index.search(query, top_k=5, threshold=0.0) |
| alice_result = next(r for r in results if r["name"] == "Alice") |
| assert abs(alice_result["similarity"] - expected_sim_alice) < 0.01 |
|
|
|
|
| |
| |
| |
|
|
| def test_get_returns_face(index): |
| emb = _random_embedding(42) |
| face_id = index.enroll(emb, name="Alice", source_url="https://example.com") |
| face = index.get(face_id) |
| assert face is not None |
| assert face["name"] == "Alice" |
| assert face["source_url"] == "https://example.com" |
|
|
|
|
| def test_get_returns_none_for_missing(index): |
| assert index.get("nonexistent-id") is None |
|
|
|
|
| def test_list_returns_all(index): |
| for i in range(5): |
| index.enroll(_random_embedding(i), name=f"Person_{i}") |
| faces = index.list(limit=10) |
| assert len(faces) == 5 |
|
|
|
|
| def test_list_filter_by_name(index): |
| index.enroll(_random_embedding(1), name="Alice") |
| index.enroll(_random_embedding(2), name="Bob") |
| index.enroll(_random_embedding(3), name="Alice Cooper") |
|
|
| faces = index.list(name="Alice") |
| assert len(faces) == 2 |
|
|
|
|
| def test_delete_removes_face(index): |
| face_id = index.enroll(_random_embedding(42), name="Alice") |
| assert index.count() == 1 |
| deleted = index.delete(face_id) |
| assert deleted is True |
| assert index.count() == 0 |
| assert index.get(face_id) is None |
|
|
|
|
| def test_delete_returns_false_for_missing(index): |
| assert index.delete("nonexistent-id") is False |
|
|
|
|
| def test_clear_removes_all(index): |
| for i in range(5): |
| index.enroll(_random_embedding(i), name=f"Person_{i}") |
| assert index.count() == 5 |
| n = index.clear() |
| assert n == 5 |
| assert index.count() == 0 |
|
|
|
|
| |
| |
| |
|
|
| def test_stats_after_enrollment(index): |
| index.enroll(_random_embedding(1), name="Alice") |
| index.enroll(_random_embedding(2)) |
|
|
| stats = index.stats() |
| assert stats["total_faces"] == 2 |
| assert stats["named_faces"] == 1 |
| assert stats["anonymous_faces"] == 1 |
| assert stats["last_enrollment"] is not None |
| assert stats["recent_enrollments_24h"] == 2 |
|
|