import pytest from backend.app import create_app from backend.app.models import ImageRecord, TagKind from backend.app.models.tag import image_tag from backend.app.services.ml.suggestions import SuggestionService from backend.app.services.tag_service import TagService pytestmark = pytest.mark.integration def _img(sha: str, predictions: dict) -> ImageRecord: return ImageRecord( path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg", width=1, height=1, origin="imported_filesystem", integrity_status="unknown", tagger_predictions=predictions, ) @pytest.mark.asyncio async def test_consensus_includes_tag_over_threshold(db): tags = TagService(db) t = await tags.find_or_create("sword", TagKind.general) a = _img("a" * 64, {"sword": {"category": "general", "confidence": 0.97}}) b = _img("b" * 64, {"sword": {"category": "general", "confidence": 0.95}}) db.add_all([a, b]) await db.flush() res = await SuggestionService(db).for_selection([a.id, b.id], threshold=0.8) gen = res["general"] assert any(s["canonical_tag_id"] == t.id for s in gen) s = next(s for s in gen if s["canonical_tag_id"] == t.id) assert s["coverage"] == 1.0 assert 0.95 <= s["confidence"] <= 0.97 @pytest.mark.asyncio async def test_consensus_counts_already_applied_for_coverage(db): tags = TagService(db) t = await tags.find_or_create("sky", TagKind.general) a = _img("c" * 64, {"sky": {"category": "general", "confidence": 0.96}}) b = _img("d" * 64, {}) # no prediction db.add_all([a, b]) await db.flush() # b already has the tag applied -> counts toward coverage, not confidence await db.execute( image_tag.insert().values( image_record_id=b.id, tag_id=t.id, source="manual" ) ) res = await SuggestionService(db).for_selection([a.id, b.id], threshold=0.8) s = next(s for s in res["general"] if s["canonical_tag_id"] == t.id) assert s["coverage"] == 1.0 # 1 suggested + 1 applied / 2 assert s["confidence"] == pytest.approx(0.96, abs=1e-4) @pytest.mark.asyncio async def test_consensus_excludes_below_threshold(db): tags = TagService(db) await tags.find_or_create("rare", TagKind.general) a = _img("e" * 64, {"rare": {"category": "general", "confidence": 0.96}}) b = _img("f" * 64, {}) db.add_all([a, b]) await db.flush() res = await SuggestionService(db).for_selection([a.id, b.id], threshold=0.8) assert all( s["name"] != "rare" for s in res.get("general", []) ) # coverage 0.5 < 0.8 @pytest.mark.asyncio async def test_consensus_skips_creates_new_tag(db): a = _img("g" * 64, {"neverseen": {"category": "general", "confidence": 0.99}}) b = _img("h" * 64, {"neverseen": {"category": "general", "confidence": 0.99}}) db.add_all([a, b]) await db.flush() res = await SuggestionService(db).for_selection([a.id, b.id], threshold=0.8) # 'neverseen' has no Tag row -> creates_new_tag -> excluded from consensus assert all(s["name"] != "neverseen" for s in res.get("general", [])) @pytest.mark.asyncio async def test_consensus_threshold_clamped_and_empty_for_no_ids(db): res = await SuggestionService(db).for_selection([], threshold=5.0) assert res == {} @pytest.mark.asyncio async def test_bulk_suggestions_route(db): tags = TagService(db) await tags.find_or_create("sword", TagKind.general) a = _img("i" * 64, {"sword": {"category": "general", "confidence": 0.97}}) db.add(a) await db.commit() app = create_app() async with app.test_client() as c: resp = await c.post( "/api/suggestions/bulk", json={"image_ids": [a.id]} ) assert resp.status_code == 200 body = await resp.get_json() assert body["evaluated"] == 1 assert body["threshold"] == 0.8 assert "suggestions" in body @pytest.mark.asyncio async def test_bulk_suggestions_requires_ids(db): app = create_app() async with app.test_client() as c: resp = await c.post("/api/suggestions/bulk", json={}) assert resp.status_code == 400