Refactor tests: replace sys.modules hacks with real DI + in-memory DB
- Add db=None, embedder=None params to ai_provider_wrapper, text_pipeline, compute_similarities - New conftest.py: FakeEmbedder, mem_db (in-memory DuckDB), fake_embedder fixtures - Rewrite test_ai_provider_wrapper (4 tests), test_rerun_embeddings_retry (2 tests), test_similarity_compute_filter (1 test) with real implementations - Fix rerun_embeddings tests hanging on _get_all_windows by patching it alongside _clear_embeddings - All 53 tests pass (2 skipped), 0 sys.modules hacks in refactored files
This commit is contained in:
@@ -1,5 +1,66 @@
|
||||
import tempfile
|
||||
import pytest
|
||||
import os
|
||||
from config import config
|
||||
|
||||
# Ensure importing database at test-collection time doesn't try to open the real
|
||||
# application DB. Point the app config to a temporary DB file under the
|
||||
# system tempdir so the module-level MotionDatabase() in database.py can
|
||||
# initialize without conflicting with a running instance.
|
||||
_tmp_dir = tempfile.mkdtemp(prefix="tests_db_")
|
||||
config.DATABASE_PATH = os.path.join(_tmp_dir, "motions.db")
|
||||
|
||||
|
||||
class FakeEmbedder:
|
||||
"""Real callable that returns deterministic embeddings. No network calls.
|
||||
|
||||
Raises RuntimeError for any call where `fail_indices` are triggered.
|
||||
fail_indices is the set of positions (0-based) within the texts batch passed
|
||||
to a single __call__ invocation.
|
||||
"""
|
||||
|
||||
def __init__(self, fail_indices=None, vector_size=8):
|
||||
self.fail_indices = set(fail_indices or [])
|
||||
self.vector_size = vector_size
|
||||
self.call_count = 0
|
||||
self.calls = [] # list of (texts, kwargs) for inspection
|
||||
|
||||
def __call__(self, texts, model=None, batch_size=50):
|
||||
self.call_count += 1
|
||||
self.calls.append((list(texts), {"model": model, "batch_size": batch_size}))
|
||||
results = []
|
||||
for i, text in enumerate(texts):
|
||||
if i in self.fail_indices:
|
||||
raise RuntimeError(
|
||||
f"Simulated embedding failure for index {i}: {text!r}"
|
||||
)
|
||||
results.append([0.1 * (i + 1)] * self.vector_size)
|
||||
return results
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mem_db(tmp_path):
|
||||
"""In-memory MotionDatabase with full schema. No filesystem side effects.
|
||||
|
||||
MotionDatabase(':memory:') may raise when os.path.dirname(':memory:') is
|
||||
empty. Try in-memory first, fall back to a tmp file if that fails.
|
||||
"""
|
||||
from database import (
|
||||
MotionDatabase,
|
||||
) # lazy import — database module not imported at module level
|
||||
|
||||
try:
|
||||
db = MotionDatabase(":memory:")
|
||||
except Exception:
|
||||
db = MotionDatabase(str(tmp_path / "test.db"))
|
||||
yield db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_embedder():
|
||||
"""FakeEmbedder with no failures by default."""
|
||||
return FakeEmbedder()
|
||||
|
||||
|
||||
# Load test fixtures from the utils package so pytest can discover them.
|
||||
pytest_plugins = ["tests.utils.migration_fixtures"]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Tests for pipeline.ai_provider_wrapper — no monkeypatching, no mocks."""
|
||||
|
||||
import pipeline.ai_provider_wrapper as w
|
||||
from tests.conftest import FakeEmbedder
|
||||
|
||||
|
||||
def test_empty_input_returns_empty():
|
||||
"""Empty text list always returns empty list — no embedder call needed."""
|
||||
result = w.get_embeddings_with_retry([])
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_successful_embeddings(mem_db):
|
||||
"""Real embedder returns vectors aligned with input texts."""
|
||||
embedder = FakeEmbedder()
|
||||
result = w.get_embeddings_with_retry(
|
||||
["motion one", "motion two"],
|
||||
motion_ids=[1, 2],
|
||||
embedder=embedder,
|
||||
db=mem_db,
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert result[0] is not None
|
||||
assert result[1] is not None
|
||||
assert embedder.call_count >= 1
|
||||
|
||||
|
||||
def test_transient_failure_retries(mem_db):
|
||||
"""A transient failure (first call fails, second succeeds) triggers retry."""
|
||||
|
||||
class TransientEmbedder:
|
||||
def __init__(self):
|
||||
self.call_count = 0
|
||||
|
||||
def __call__(self, texts, model=None, batch_size=50):
|
||||
self.call_count += 1
|
||||
if self.call_count == 1:
|
||||
raise RuntimeError("Transient network error")
|
||||
return [[0.5] * 8 for _ in texts]
|
||||
|
||||
embedder = TransientEmbedder()
|
||||
result = w.get_embeddings_with_retry(
|
||||
["motion text"],
|
||||
motion_ids=[42],
|
||||
embedder=embedder,
|
||||
db=mem_db,
|
||||
retries=3,
|
||||
)
|
||||
# After retry, should succeed
|
||||
assert result[0] is not None
|
||||
assert embedder.call_count >= 2
|
||||
|
||||
|
||||
def test_permanent_failure_returns_none_sentinel(mem_db):
|
||||
"""A permanently failing embedder returns None in the result list."""
|
||||
always_fails = FakeEmbedder(fail_indices={0})
|
||||
|
||||
result = w.get_embeddings_with_retry(
|
||||
["failing motion"],
|
||||
motion_ids=[99],
|
||||
embedder=always_fails,
|
||||
db=mem_db,
|
||||
retries=2,
|
||||
)
|
||||
# Result entry is None for the failed item
|
||||
assert result == [None]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tests for scripts.rerun_embeddings retry orchestration.
|
||||
|
||||
No sys.modules tricks needed — duckdb is available in .venv.
|
||||
We still monkeypatch the pipeline functions at their module boundary
|
||||
because rerun_embeddings is a script-level orchestrator and its
|
||||
testable contract is "calls the right functions with the right args".
|
||||
"""
|
||||
|
||||
import scripts.rerun_embeddings as rerun
|
||||
import pipeline.text_pipeline as tp
|
||||
|
||||
|
||||
def test_rerun_retries_missing(monkeypatch):
|
||||
"""When ensure_text_embeddings returns failed_ids, retry helper is called."""
|
||||
monkeypatch.setattr(rerun, "_clear_embeddings", lambda db_path: 0)
|
||||
monkeypatch.setattr(rerun, "_get_all_windows", lambda db_path: [])
|
||||
|
||||
def first_call(db_path=None, model=None, batch_size=50, **kwargs):
|
||||
return (1, 0, 0, 1, [101, 102])
|
||||
|
||||
called = {"retried": False, "ids": None}
|
||||
|
||||
def retry_call(db_path=None, ids=None, model=None, batch_size=10, **kwargs):
|
||||
called["retried"] = True
|
||||
called["ids"] = ids
|
||||
return (1, 0, 0, 0, [])
|
||||
|
||||
monkeypatch.setattr(tp, "ensure_text_embeddings", first_call)
|
||||
monkeypatch.setattr(tp, "ensure_text_embeddings_for_ids", retry_call)
|
||||
|
||||
summary = rerun.rerun_embeddings(
|
||||
"data/motions.db", model="test-model", retry_missing=True
|
||||
)
|
||||
|
||||
assert called["retried"] is True
|
||||
assert set(called["ids"]) == {101, 102}
|
||||
|
||||
|
||||
def test_rerun_no_retry_when_no_failures(monkeypatch):
|
||||
"""When ensure_text_embeddings returns no failed_ids, retry is NOT called."""
|
||||
monkeypatch.setattr(rerun, "_clear_embeddings", lambda db_path: 0)
|
||||
monkeypatch.setattr(rerun, "_get_all_windows", lambda db_path: [])
|
||||
|
||||
def no_failures(db_path=None, model=None, batch_size=50, **kwargs):
|
||||
return (5, 0, 0, 0, [])
|
||||
|
||||
retry_called = {"v": False}
|
||||
|
||||
def retry_should_not_be_called(**kwargs):
|
||||
retry_called["v"] = True
|
||||
return (0, 0, 0, 0, [])
|
||||
|
||||
monkeypatch.setattr(tp, "ensure_text_embeddings", no_failures)
|
||||
monkeypatch.setattr(
|
||||
tp, "ensure_text_embeddings_for_ids", retry_should_not_be_called
|
||||
)
|
||||
|
||||
rerun.rerun_embeddings("data/motions.db", model="test-model", retry_missing=True)
|
||||
|
||||
assert retry_called["v"] is False
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Tests for similarity filter in compute_similarities — real DB, real code, no mocks."""
|
||||
|
||||
import json
|
||||
import duckdb
|
||||
from database import MotionDatabase
|
||||
import similarity.compute as sc
|
||||
|
||||
|
||||
def test_filter_skips_identical_short_title_pairs(tmp_path):
|
||||
"""Pairs with identical short titles and perfect cosine similarity are filtered out."""
|
||||
db_path = str(tmp_path / "test.db")
|
||||
|
||||
# 1. Initialize schema
|
||||
db = MotionDatabase(db_path)
|
||||
|
||||
# 2. Insert 2 motions with identical short titles
|
||||
motion1 = {
|
||||
"title": "Aangenomen.",
|
||||
"description": "desc1",
|
||||
"date": "2020-01-01",
|
||||
"policy_area": "",
|
||||
"voting_results": {},
|
||||
"winning_margin": 0.5,
|
||||
"url": "u1",
|
||||
}
|
||||
motion2 = {
|
||||
"title": "Aangenomen.",
|
||||
"description": "desc2",
|
||||
"date": "2020-01-02",
|
||||
"policy_area": "",
|
||||
"voting_results": {},
|
||||
"winning_margin": 0.6,
|
||||
"url": "u2",
|
||||
}
|
||||
|
||||
assert db.insert_motion(motion1) is True
|
||||
assert db.insert_motion(motion2) is True
|
||||
|
||||
# fetch ids
|
||||
conn = duckdb.connect(db_path)
|
||||
id1 = conn.execute(
|
||||
"SELECT id FROM motions WHERE url = ?", (motion1["url"],)
|
||||
).fetchone()[0]
|
||||
id2 = conn.execute(
|
||||
"SELECT id FROM motions WHERE url = ?", (motion2["url"],)
|
||||
).fetchone()[0]
|
||||
|
||||
assert id1 is not None and id2 is not None and id1 != id2
|
||||
|
||||
# 3. Insert identical unit vectors into fused_embeddings using store_fused_embedding
|
||||
vec = [1.0] + [0.0] * 7 # 8-dim unit vector
|
||||
|
||||
# use a window id (schema requires NOT NULL); compute_similarities will read all fused embeddings when window_id=None
|
||||
window_id = "w"
|
||||
assert db.store_fused_embedding(id1, window_id, vec, svd_dims=0, text_dims=0) > 0
|
||||
assert db.store_fused_embedding(id2, window_id, vec, svd_dims=0, text_dims=0) > 0
|
||||
conn.close()
|
||||
|
||||
# 4. Run compute_similarities
|
||||
inserted = sc.compute_similarities(
|
||||
vector_type="fused",
|
||||
window_id=None,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
# 5. The pair (id1, id2) has perfect similarity and identical short titles
|
||||
# The filter should remove it → 0 rows inserted into similarity_cache
|
||||
assert inserted == 0, f"Expected 0 pairs after filter, got {inserted}"
|
||||
Reference in New Issue
Block a user