diff --git a/ocr/tests/test_engine_abstraction.py b/ocr/tests/test_engine_abstraction.py index 9328aad..09eac4d 100644 --- a/ocr/tests/test_engine_abstraction.py +++ b/ocr/tests/test_engine_abstraction.py @@ -355,7 +355,7 @@ class TestCloudEngine: from app.engines.cloud_engine import CloudEngine engine = CloudEngine(key_path="/nonexistent/key.json") - with pytest.raises(EngineUnavailableError, match="key not found"): + with pytest.raises(EngineUnavailableError, match="credential config not found"): engine._get_client() @patch("os.path.isfile", return_value=True) @@ -414,6 +414,16 @@ class TestCloudEngine: class TestHybridEngine: + """Tests for HybridEngine with monthly Vision API cap.""" + + def _mock_redis(self, current_count: int = 0) -> MagicMock: + """Create a mock Redis instance with a configurable counter value.""" + mock_r = MagicMock() + mock_r.get.return_value = str(current_count) if current_count else None + mock_pipe = MagicMock() + mock_r.pipeline.return_value = mock_pipe + return mock_r + def test_name_with_fallback(self) -> None: from app.engines.hybrid_engine import HybridEngine @@ -432,13 +442,15 @@ class TestHybridEngine: engine = HybridEngine(primary=primary) assert engine.name == "hybrid(paddleocr+none)" + # --- Local-primary path (original confidence-based fallback) --- + def test_high_confidence_skips_fallback(self) -> None: from app.engines.hybrid_engine import HybridEngine primary = MagicMock(spec=OcrEngine) fallback = MagicMock(spec=OcrEngine) primary.name = "paddleocr" - fallback.name = "cloud" + fallback.name = "tesseract" primary.recognize.return_value = _make_result("VIN123", 0.95, "paddleocr") engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) @@ -447,22 +459,6 @@ class TestHybridEngine: assert result.engine_name == "paddleocr" fallback.recognize.assert_not_called() - def test_low_confidence_triggers_fallback(self) -> None: - from app.engines.hybrid_engine import HybridEngine - - primary = MagicMock(spec=OcrEngine) - fallback = MagicMock(spec=OcrEngine) - primary.name = "paddleocr" - fallback.name = "google_vision" - primary.recognize.return_value = _make_result("VIN123", 0.3, "paddleocr") - fallback.recognize.return_value = _make_result("VIN456", 0.92, "google_vision") - - engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) - result = engine.recognize(b"img", OcrConfig()) - assert result.text == "VIN456" - assert result.engine_name == "google_vision" - fallback.recognize.assert_called_once() - def test_low_confidence_no_fallback_returns_primary(self) -> None: from app.engines.hybrid_engine import HybridEngine @@ -474,6 +470,57 @@ class TestHybridEngine: result = engine.recognize(b"img", OcrConfig()) assert result.text == "VIN123" + def test_exact_threshold_skips_fallback(self) -> None: + """When confidence == threshold, no fallback needed (>= check).""" + from app.engines.hybrid_engine import HybridEngine + + primary = MagicMock(spec=OcrEngine) + fallback = MagicMock(spec=OcrEngine) + primary.name = "paddleocr" + fallback.name = "tesseract" + primary.recognize.return_value = _make_result("VIN", 0.6, "paddleocr") + + engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) + result = engine.recognize(b"img", OcrConfig()) + assert result.engine_name == "paddleocr" + fallback.recognize.assert_not_called() + + # --- Local-primary with cloud fallback (subject to monthly cap) --- + + def test_low_confidence_triggers_cloud_fallback(self) -> None: + from app.engines.hybrid_engine import HybridEngine + + primary = MagicMock(spec=OcrEngine) + fallback = MagicMock(spec=OcrEngine) + primary.name = "paddleocr" + fallback.name = "google_vision" + primary.recognize.return_value = _make_result("VIN123", 0.3, "paddleocr") + fallback.recognize.return_value = _make_result("VIN456", 0.92, "google_vision") + + engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) + engine._redis = self._mock_redis(current_count=0) + result = engine.recognize(b"img", OcrConfig()) + assert result.text == "VIN456" + assert result.engine_name == "google_vision" + + def test_cloud_fallback_skipped_when_limit_reached(self) -> None: + from app.engines.hybrid_engine import HybridEngine + + primary = MagicMock(spec=OcrEngine) + fallback = MagicMock(spec=OcrEngine) + primary.name = "paddleocr" + fallback.name = "google_vision" + primary.recognize.return_value = _make_result("VIN123", 0.3, "paddleocr") + + engine = HybridEngine( + primary=primary, fallback=fallback, threshold=0.6, monthly_limit=100 + ) + engine._redis = self._mock_redis(current_count=100) + result = engine.recognize(b"img", OcrConfig()) + assert result.text == "VIN123" + assert result.engine_name == "paddleocr" + fallback.recognize.assert_not_called() + def test_fallback_lower_confidence_returns_primary(self) -> None: from app.engines.hybrid_engine import HybridEngine @@ -485,10 +532,11 @@ class TestHybridEngine: fallback.recognize.return_value = _make_result("VIN456", 0.3, "google_vision") engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) + engine._redis = self._mock_redis(current_count=0) result = engine.recognize(b"img", OcrConfig()) assert result.text == "VIN123" - def test_fallback_engine_error_returns_primary(self) -> None: + def test_cloud_fallback_error_returns_primary(self) -> None: from app.engines.hybrid_engine import HybridEngine primary = MagicMock(spec=OcrEngine) @@ -499,25 +547,14 @@ class TestHybridEngine: fallback.recognize.side_effect = EngineUnavailableError("key missing") engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) - result = engine.recognize(b"img", OcrConfig()) - assert result.text == "VIN123" - - def test_fallback_unexpected_error_returns_primary(self) -> None: - from app.engines.hybrid_engine import HybridEngine - - primary = MagicMock(spec=OcrEngine) - fallback = MagicMock(spec=OcrEngine) - primary.name = "paddleocr" - fallback.name = "google_vision" - primary.recognize.return_value = _make_result("VIN123", 0.3, "paddleocr") - fallback.recognize.side_effect = RuntimeError("network error") - - engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) + engine._redis = self._mock_redis(current_count=0) result = engine.recognize(b"img", OcrConfig()) assert result.text == "VIN123" @patch("app.engines.hybrid_engine.time") - def test_fallback_timeout_returns_primary(self, mock_time: MagicMock) -> None: + def test_cloud_fallback_timeout_returns_primary( + self, mock_time: MagicMock + ) -> None: from app.engines.hybrid_engine import HybridEngine primary = MagicMock(spec=OcrEngine) @@ -525,28 +562,211 @@ class TestHybridEngine: primary.name = "paddleocr" fallback.name = "google_vision" primary.recognize.return_value = _make_result("VIN123", 0.3, "paddleocr") - fallback.recognize.return_value = _make_result("VIN456", 0.92, "google_vision") - # Simulate 6-second delay (exceeds 5s limit) + fallback.recognize.return_value = _make_result( + "VIN456", 0.92, "google_vision" + ) mock_time.monotonic.side_effect = [0.0, 6.0] engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) + engine._redis = self._mock_redis(current_count=0) result = engine.recognize(b"img", OcrConfig()) - assert result.text == "VIN123" # timeout -> use primary + assert result.text == "VIN123" - def test_exact_threshold_skips_fallback(self) -> None: - """When confidence == threshold, no fallback needed (>= check).""" + # --- Cloud-primary path --- + + def test_cloud_primary_returns_result_when_under_limit(self) -> None: + from app.engines.hybrid_engine import HybridEngine + + primary = MagicMock(spec=OcrEngine) + fallback = MagicMock(spec=OcrEngine) + primary.name = "google_vision" + fallback.name = "paddleocr" + primary.recognize.return_value = _make_result( + "VIN789", 0.95, "google_vision" + ) + + engine = HybridEngine( + primary=primary, fallback=fallback, threshold=0.6, monthly_limit=1000 + ) + engine._redis = self._mock_redis(current_count=500) + result = engine.recognize(b"img", OcrConfig()) + assert result.text == "VIN789" + assert result.engine_name == "google_vision" + fallback.recognize.assert_not_called() + + def test_cloud_primary_falls_back_when_limit_reached(self) -> None: + from app.engines.hybrid_engine import HybridEngine + + primary = MagicMock(spec=OcrEngine) + fallback = MagicMock(spec=OcrEngine) + primary.name = "google_vision" + fallback.name = "paddleocr" + fallback.recognize.return_value = _make_result( + "VIN_LOCAL", 0.75, "paddleocr" + ) + + engine = HybridEngine( + primary=primary, fallback=fallback, threshold=0.6, monthly_limit=1000 + ) + engine._redis = self._mock_redis(current_count=1000) + result = engine.recognize(b"img", OcrConfig()) + assert result.text == "VIN_LOCAL" + assert result.engine_name == "paddleocr" + primary.recognize.assert_not_called() + + def test_cloud_primary_no_fallback_raises_when_limit_reached(self) -> None: + from app.engines.hybrid_engine import HybridEngine + + primary = MagicMock(spec=OcrEngine) + primary.name = "google_vision" + + engine = HybridEngine( + primary=primary, fallback=None, threshold=0.6, monthly_limit=1000 + ) + engine._redis = self._mock_redis(current_count=1000) + with pytest.raises(EngineProcessingError, match="no fallback"): + engine.recognize(b"img", OcrConfig()) + + def test_cloud_primary_error_falls_back(self) -> None: + from app.engines.hybrid_engine import HybridEngine + + primary = MagicMock(spec=OcrEngine) + fallback = MagicMock(spec=OcrEngine) + primary.name = "google_vision" + fallback.name = "paddleocr" + primary.recognize.side_effect = EngineUnavailableError("API down") + fallback.recognize.return_value = _make_result( + "VIN_LOCAL", 0.75, "paddleocr" + ) + + engine = HybridEngine( + primary=primary, fallback=fallback, threshold=0.6, monthly_limit=1000 + ) + engine._redis = self._mock_redis(current_count=500) + result = engine.recognize(b"img", OcrConfig()) + assert result.text == "VIN_LOCAL" + assert result.engine_name == "paddleocr" + + # --- Redis counter behavior --- + + def test_counter_increments_on_successful_cloud_call(self) -> None: + from app.engines.hybrid_engine import HybridEngine + + primary = MagicMock(spec=OcrEngine) + fallback = MagicMock(spec=OcrEngine) + primary.name = "google_vision" + fallback.name = "paddleocr" + primary.recognize.return_value = _make_result( + "VIN789", 0.95, "google_vision" + ) + + mock_r = self._mock_redis(current_count=10) + engine = HybridEngine( + primary=primary, fallback=fallback, threshold=0.6, monthly_limit=1000 + ) + engine._redis = mock_r + engine.recognize(b"img", OcrConfig()) + + mock_r.pipeline.assert_called_once() + pipe = mock_r.pipeline.return_value + pipe.incr.assert_called_once() + pipe.expire.assert_called_once() + pipe.execute.assert_called_once() + + def test_counter_not_incremented_when_limit_reached(self) -> None: + from app.engines.hybrid_engine import HybridEngine + + primary = MagicMock(spec=OcrEngine) + fallback = MagicMock(spec=OcrEngine) + primary.name = "google_vision" + fallback.name = "paddleocr" + fallback.recognize.return_value = _make_result( + "VIN_LOCAL", 0.75, "paddleocr" + ) + + mock_r = self._mock_redis(current_count=1000) + engine = HybridEngine( + primary=primary, fallback=fallback, threshold=0.6, monthly_limit=1000 + ) + engine._redis = mock_r + engine.recognize(b"img", OcrConfig()) + + mock_r.pipeline.assert_not_called() + + def test_redis_failure_assumes_limit_not_reached(self) -> None: + from app.engines.hybrid_engine import HybridEngine + + primary = MagicMock(spec=OcrEngine) + fallback = MagicMock(spec=OcrEngine) + primary.name = "google_vision" + fallback.name = "paddleocr" + primary.recognize.return_value = _make_result( + "VIN789", 0.95, "google_vision" + ) + + mock_r = MagicMock() + mock_r.get.side_effect = Exception("Redis connection refused") + mock_pipe = MagicMock() + mock_r.pipeline.return_value = mock_pipe + engine = HybridEngine( + primary=primary, fallback=fallback, threshold=0.6, monthly_limit=1000 + ) + engine._redis = mock_r + result = engine.recognize(b"img", OcrConfig()) + assert result.text == "VIN789" + + # --- Non-cloud fallback path (no cap needed) --- + + def test_non_cloud_fallback_not_subject_to_cap(self) -> None: from app.engines.hybrid_engine import HybridEngine primary = MagicMock(spec=OcrEngine) fallback = MagicMock(spec=OcrEngine) primary.name = "paddleocr" - fallback.name = "cloud" - primary.recognize.return_value = _make_result("VIN", 0.6, "paddleocr") + fallback.name = "tesseract" + primary.recognize.return_value = _make_result("VIN123", 0.3, "paddleocr") + fallback.recognize.return_value = _make_result( + "VIN456", 0.92, "tesseract" + ) engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) result = engine.recognize(b"img", OcrConfig()) - assert result.engine_name == "paddleocr" - fallback.recognize.assert_not_called() + assert result.text == "VIN456" + assert result.engine_name == "tesseract" + + @patch("app.engines.hybrid_engine.time") + def test_non_cloud_fallback_timeout_returns_primary( + self, mock_time: MagicMock + ) -> None: + from app.engines.hybrid_engine import HybridEngine + + primary = MagicMock(spec=OcrEngine) + fallback = MagicMock(spec=OcrEngine) + primary.name = "paddleocr" + fallback.name = "tesseract" + primary.recognize.return_value = _make_result("VIN123", 0.3, "paddleocr") + fallback.recognize.return_value = _make_result( + "VIN456", 0.92, "tesseract" + ) + mock_time.monotonic.side_effect = [0.0, 6.0] + + engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) + result = engine.recognize(b"img", OcrConfig()) + assert result.text == "VIN123" + + def test_non_cloud_fallback_error_returns_primary(self) -> None: + from app.engines.hybrid_engine import HybridEngine + + primary = MagicMock(spec=OcrEngine) + fallback = MagicMock(spec=OcrEngine) + primary.name = "paddleocr" + fallback.name = "tesseract" + primary.recognize.return_value = _make_result("VIN123", 0.3, "paddleocr") + fallback.recognize.side_effect = RuntimeError("crash") + + engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) + result = engine.recognize(b"img", OcrConfig()) + assert result.text == "VIN123" # --------------------------------------------------------------------------- @@ -599,6 +819,7 @@ class TestEngineFactory: mock_settings.ocr_primary_engine = "paddleocr" mock_settings.ocr_fallback_engine = "google_vision" mock_settings.ocr_fallback_threshold = 0.7 + mock_settings.vision_monthly_limit = 1000 mock_primary = MagicMock(spec=OcrEngine) mock_fallback = MagicMock(spec=OcrEngine) mock_create.side_effect = [mock_primary, mock_fallback]