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
171 lines
7.4 KiB
Python
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({})
|