kiwi/tests/services/test_label_capture.py
pyr0ball 17e62c451f feat: visual label capture for unenriched barcodes (kiwi#79)
When a barcode scan finds no product in FDC/OFF, paid-tier users now see a
"Capture label" offer instead of a dead-end "add manually" prompt.

Backend:
- Migration 036: captured_products local cache table (keyed by barcode,
  UPSERT on conflict so re-capture refreshes rather than errors)
- store.get_captured_product / save_captured_product (with JSON decode for
  ingredient_names and allergens)
- app/services/label_capture.py: wraps cf-core VisionRouter (caption API);
  graceful fallback to zero-confidence mock when stub/error; JSON fence
  stripping; confidence clamped to [0,1]; KIWI_LABEL_CAPTURE_MOCK=1 for tests
- New schemas: LabelCaptureResponse, LabelConfirmRequest, LabelConfirmResponse
- POST /inventory/scan/label-capture — image to extraction (paid+ gate, 403)
- POST /inventory/scan/label-confirm — save confirmed product + optional
  inventory add
- Both scan endpoints now: check captured_products cache before FDC/OFF;
  set needs_visual_capture=True for gap products on paid tier; BarcodeScanResult
  gains needs_visual_capture field
- visual_label_capture feature gate added to tiers.py (paid)

Tests: 42 new tests (service, store/migration, API endpoints) — 367 total passing

Frontend:
- InventoryList.vue: capturePhase state machine (offer => uploading => reviewing)
- Offer card appears after scan gap (calm UX: no urgency, Discard always visible)
- Review form: pre-populated from extraction; amber label highlights for
  unread fields (confidence < 0.7); comma-separated ingredients/allergens
- api.ts: LabelCaptureResult + LabelConfirmRequest types; captureLabelPhoto()
  and confirmLabelCapture() API methods
2026-04-24 17:57:25 -07:00

171 lines
7.4 KiB
Python

"""
Tests for app.services.label_capture.
All tests set KIWI_LABEL_CAPTURE_MOCK=1 so no vision model weights are needed.
"""
import json
import os
import pytest
@pytest.fixture(autouse=True)
def mock_vision(monkeypatch):
monkeypatch.setenv("KIWI_LABEL_CAPTURE_MOCK", "1")
yield
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
# ── TestExtractLabel ──────────────────────────────────────────────────────────
class TestExtractLabel:
def test_mock_returns_dict(self):
from app.services.label_capture import extract_label
result = extract_label(b"fake image bytes")
assert isinstance(result, dict)
def test_mock_returns_all_required_keys(self):
from app.services.label_capture import extract_label
result = extract_label(b"fake image bytes")
for key in ("product_name", "brand", "calories", "fat_g", "carbs_g",
"protein_g", "sodium_mg", "ingredient_names", "allergens",
"confidence"):
assert key in result, f"missing key: {key}"
def test_mock_ingredient_names_is_list(self):
from app.services.label_capture import extract_label
result = extract_label(b"fake")
assert isinstance(result["ingredient_names"], list)
def test_mock_allergens_is_list(self):
from app.services.label_capture import extract_label
result = extract_label(b"fake")
assert isinstance(result["allergens"], list)
def test_mock_confidence_zero(self):
from app.services.label_capture import extract_label
result = extract_label(b"fake")
assert result["confidence"] == 0.0
def _patch_vision(self, monkeypatch, caption_text: str):
"""Patch circuitforge_core.vision.caption to return a VisionResult with caption_text."""
from circuitforge_core.vision.backends.base import VisionResult
def _fake_caption(image_bytes, prompt=""):
return VisionResult(caption=caption_text)
import circuitforge_core.vision as vision_mod
monkeypatch.setattr(vision_mod, "caption", _fake_caption)
def test_exception_falls_back_to_mock(self, monkeypatch):
"""Any exception from the vision backend returns mock extraction."""
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
import circuitforge_core.vision as vision_mod
monkeypatch.setattr(vision_mod, "caption", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("GPU unavailable")))
import app.services.label_capture as svc_mod
result = svc_mod.extract_label(b"image")
assert result["confidence"] == 0.0
assert isinstance(result["ingredient_names"], list)
def test_live_path_parses_json_string(self, monkeypatch):
"""When the vision backend returns valid JSON, it is parsed correctly."""
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
payload = {
"product_name": "Test Crackers",
"brand": "Test Brand",
"serving_size_g": 30.0,
"calories": 120.0,
"fat_g": 4.0,
"saturated_fat_g": 0.5,
"carbs_g": 20.0,
"sugar_g": 2.0,
"fiber_g": 1.0,
"protein_g": 3.0,
"sodium_mg": 200.0,
"ingredient_names": ["wheat flour", "canola oil", "salt"],
"allergens": ["wheat"],
"confidence": 0.92,
}
self._patch_vision(monkeypatch, json.dumps(payload))
import app.services.label_capture as svc_mod
result = svc_mod.extract_label(b"image")
assert result["product_name"] == "Test Crackers"
assert result["calories"] == 120.0
assert result["ingredient_names"] == ["wheat flour", "canola oil", "salt"]
assert result["allergens"] == ["wheat"]
assert result["confidence"] == 0.92
def test_live_path_strips_markdown_fences(self, monkeypatch):
"""JSON wrapped in ```json ... ``` fences is still parsed."""
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
payload = {"product_name": "Fancy", "brand": None, "serving_size_g": None,
"calories": None, "fat_g": None, "saturated_fat_g": None,
"carbs_g": None, "sugar_g": None, "fiber_g": None,
"protein_g": None, "sodium_mg": None,
"ingredient_names": [], "allergens": [], "confidence": 0.5}
self._patch_vision(monkeypatch, f"```json\n{json.dumps(payload)}\n```")
import app.services.label_capture as svc_mod
result = svc_mod.extract_label(b"image")
assert result["product_name"] == "Fancy"
def test_live_path_bad_json_falls_back(self, monkeypatch):
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
self._patch_vision(monkeypatch, "this is not json")
import app.services.label_capture as svc_mod
result = svc_mod.extract_label(b"image")
assert result["confidence"] == 0.0
def test_confidence_clamped_above_one(self, monkeypatch):
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
payload = {"product_name": None, "brand": None, "serving_size_g": None,
"calories": None, "fat_g": None, "saturated_fat_g": None,
"carbs_g": None, "sugar_g": None, "fiber_g": None,
"protein_g": None, "sodium_mg": None,
"ingredient_names": [], "allergens": [], "confidence": 5.0}
self._patch_vision(monkeypatch, json.dumps(payload))
import app.services.label_capture as svc_mod
result = svc_mod.extract_label(b"image")
assert result["confidence"] == 1.0
def test_none_list_fields_normalised_to_empty(self, monkeypatch):
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
payload = {"product_name": None, "brand": None, "serving_size_g": None,
"calories": None, "fat_g": None, "saturated_fat_g": None,
"carbs_g": None, "sugar_g": None, "fiber_g": None,
"protein_g": None, "sodium_mg": None,
"ingredient_names": None, "allergens": None, "confidence": 0.8}
self._patch_vision(monkeypatch, json.dumps(payload))
import app.services.label_capture as svc_mod
result = svc_mod.extract_label(b"image")
assert result["ingredient_names"] == []
assert result["allergens"] == []
# ── TestNeedsReview ───────────────────────────────────────────────────────────
class TestNeedsReview:
def test_below_threshold_needs_review(self):
from app.services.label_capture import needs_review, REVIEW_THRESHOLD
assert needs_review({"confidence": REVIEW_THRESHOLD - 0.01})
def test_at_threshold_no_review(self):
from app.services.label_capture import needs_review, REVIEW_THRESHOLD
assert not needs_review({"confidence": REVIEW_THRESHOLD})
def test_above_threshold_no_review(self):
from app.services.label_capture import needs_review
assert not needs_review({"confidence": 0.95})
def test_missing_confidence_needs_review(self):
from app.services.label_capture import needs_review
assert needs_review({})