"""Tests for OCR engine abstraction layer. Covers: base types, exception hierarchy, PaddleOcrEngine, CloudEngine, HybridEngine, and engine_factory. """ import io from unittest.mock import MagicMock, patch import pytest from PIL import Image from app.engines.base_engine import ( EngineError, EngineProcessingError, EngineUnavailableError, OcrConfig, OcrEngine, OcrEngineResult, WordBox, ) # --- Helpers --- def _create_test_image_bytes() -> bytes: """Create minimal PNG image bytes for engine testing.""" img = Image.new("RGB", (100, 50), (255, 255, 255)) buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue() def _make_result( text: str, confidence: float, engine_name: str ) -> OcrEngineResult: """Create a minimal OcrEngineResult for testing.""" return OcrEngineResult( text=text, confidence=confidence, word_boxes=[], engine_name=engine_name ) def _mock_paddle_result( dt_polys: list, rec_texts: list[str], rec_scores: list[float] ) -> MagicMock: """Create a mock PaddleOCR v3.x predict() result object. Wraps data under ``"res"`` key to match save_to_json format. """ result = MagicMock() result.json = { "res": { "dt_polys": dt_polys, "rec_texts": rec_texts, "rec_scores": rec_scores, } } return result # --------------------------------------------------------------------------- # Exception hierarchy # --------------------------------------------------------------------------- class TestExceptionHierarchy: """Engine errors form a proper hierarchy under EngineError.""" def test_unavailable_is_engine_error(self) -> None: assert issubclass(EngineUnavailableError, EngineError) def test_processing_is_engine_error(self) -> None: assert issubclass(EngineProcessingError, EngineError) def test_engine_error_is_exception(self) -> None: assert issubclass(EngineError, Exception) def test_catch_base_catches_subtypes(self) -> None: with pytest.raises(EngineError): raise EngineUnavailableError("not installed") with pytest.raises(EngineError): raise EngineProcessingError("OCR failed") # --------------------------------------------------------------------------- # Data types # --------------------------------------------------------------------------- class TestWordBox: def test_default_positions(self) -> None: wb = WordBox(text="VIN", confidence=0.95) assert wb.x == 0 assert wb.y == 0 assert wb.width == 0 assert wb.height == 0 def test_all_fields(self) -> None: wb = WordBox(text="ABC", confidence=0.88, x=10, y=20, width=100, height=30) assert wb.text == "ABC" assert wb.confidence == 0.88 assert wb.x == 10 assert wb.width == 100 class TestOcrConfig: def test_defaults(self) -> None: config = OcrConfig() assert config.char_whitelist is None assert config.single_line is False assert config.single_word is False assert config.use_angle_cls is True assert config.hints == {} def test_vin_whitelist_excludes_ioq(self) -> None: whitelist = "ABCDEFGHJKLMNPRSTUVWXYZ0123456789" config = OcrConfig(char_whitelist=whitelist) assert "I" not in config.char_whitelist assert "O" not in config.char_whitelist assert "Q" not in config.char_whitelist def test_hints_are_independent_across_instances(self) -> None: c1 = OcrConfig() c2 = OcrConfig() c1.hints["psm"] = 7 assert "psm" not in c2.hints class TestOcrEngineResult: def test_construction(self) -> None: result = OcrEngineResult( text="1HGBH41JXMN109186", confidence=0.94, word_boxes=[WordBox(text="1HGBH41JXMN109186", confidence=0.94)], engine_name="paddleocr", ) assert result.text == "1HGBH41JXMN109186" assert result.confidence == 0.94 assert len(result.word_boxes) == 1 assert result.engine_name == "paddleocr" def test_empty_result(self) -> None: result = OcrEngineResult( text="", confidence=0.0, word_boxes=[], engine_name="paddleocr" ) assert result.text == "" assert result.word_boxes == [] # --------------------------------------------------------------------------- # OcrEngine ABC # --------------------------------------------------------------------------- class TestOcrEngineABC: def test_cannot_instantiate_directly(self) -> None: with pytest.raises(TypeError): OcrEngine() # type: ignore[abstract] def test_concrete_subclass_works(self) -> None: class StubEngine(OcrEngine): @property def name(self) -> str: return "stub" def recognize( self, image_bytes: bytes, config: OcrConfig ) -> OcrEngineResult: return OcrEngineResult( text="ok", confidence=1.0, word_boxes=[], engine_name="stub" ) engine = StubEngine() assert engine.name == "stub" result = engine.recognize(b"", OcrConfig()) assert result.text == "ok" # --------------------------------------------------------------------------- # PaddleOcrEngine # --------------------------------------------------------------------------- class TestPaddleOcrEngine: def test_name(self) -> None: from app.engines.paddle_engine import PaddleOcrEngine engine = PaddleOcrEngine() assert engine.name == "paddleocr" def test_lazy_init_not_loaded_at_construction(self) -> None: from app.engines.paddle_engine import PaddleOcrEngine engine = PaddleOcrEngine() assert engine._ocr is None def test_recognize_empty_results(self) -> None: from app.engines.paddle_engine import PaddleOcrEngine engine = PaddleOcrEngine() mock_ocr = MagicMock() mock_ocr.predict.return_value = iter([ _mock_paddle_result(dt_polys=[], rec_texts=[], rec_scores=[]) ]) engine._ocr = mock_ocr result = engine.recognize(_create_test_image_bytes(), OcrConfig()) assert result.text == "" assert result.confidence == 0.0 assert result.word_boxes == [] assert result.engine_name == "paddleocr" def test_recognize_with_results(self) -> None: from app.engines.paddle_engine import PaddleOcrEngine engine = PaddleOcrEngine() mock_ocr = MagicMock() mock_ocr.predict.return_value = iter([ _mock_paddle_result( dt_polys=[ [[10, 20], [110, 20], [110, 50], [10, 50]], [[10, 60], [110, 60], [110, 90], [10, 90]], ], rec_texts=["HELLO", "WORLD"], rec_scores=[0.95, 0.88], ) ]) engine._ocr = mock_ocr result = engine.recognize(_create_test_image_bytes(), OcrConfig()) assert result.text == "HELLO WORLD" assert abs(result.confidence - 0.915) < 0.01 assert len(result.word_boxes) == 2 assert result.word_boxes[0].text == "HELLO" assert result.word_boxes[0].confidence == 0.95 assert result.word_boxes[1].text == "WORLD" assert result.engine_name == "paddleocr" def test_recognize_whitelist_filters_characters(self) -> None: from app.engines.paddle_engine import PaddleOcrEngine engine = PaddleOcrEngine() mock_ocr = MagicMock() mock_ocr.predict.return_value = iter([ _mock_paddle_result( dt_polys=[[[0, 0], [100, 0], [100, 30], [0, 30]]], rec_texts=["1HG-BH4!"], rec_scores=[0.9], ) ]) engine._ocr = mock_ocr config = OcrConfig(char_whitelist="ABCDEFGHJKLMNPRSTUVWXYZ0123456789") result = engine.recognize(_create_test_image_bytes(), config) assert "-" not in result.text assert "!" not in result.text assert result.word_boxes[0].text == "1HGBH4" def test_recognize_quadrilateral_to_bounding_box(self) -> None: from app.engines.paddle_engine import PaddleOcrEngine engine = PaddleOcrEngine() mock_ocr = MagicMock() # Slightly rotated quad: min x=8, min y=20, max x=110, max y=55 mock_ocr.predict.return_value = iter([ _mock_paddle_result( dt_polys=[[[10, 20], [110, 25], [108, 55], [8, 50]]], rec_texts=["TEXT"], rec_scores=[0.9], ) ]) engine._ocr = mock_ocr result = engine.recognize(_create_test_image_bytes(), OcrConfig()) wb = result.word_boxes[0] assert wb.x == 8 assert wb.y == 20 assert wb.width == 102 # 110 - 8 assert wb.height == 35 # 55 - 20 def test_recognize_skips_empty_after_whitelist(self) -> None: """Text consisting only of non-whitelisted characters is skipped.""" from app.engines.paddle_engine import PaddleOcrEngine engine = PaddleOcrEngine() mock_ocr = MagicMock() mock_ocr.predict.return_value = iter([ _mock_paddle_result( dt_polys=[[[0, 0], [50, 0], [50, 20], [0, 20]]], rec_texts=["---"], rec_scores=[0.9], ) ]) engine._ocr = mock_ocr config = OcrConfig(char_whitelist="ABC") result = engine.recognize(_create_test_image_bytes(), config) assert result.text == "" assert result.word_boxes == [] assert result.confidence == 0.0 def test_import_error_raises_unavailable(self) -> None: from app.engines.paddle_engine import PaddleOcrEngine engine = PaddleOcrEngine() engine._ocr = None with patch.dict("sys.modules", {"paddleocr": None}): with patch( "app.engines.paddle_engine.importlib.import_module", side_effect=ImportError("No module"), ): # Force re-import by removing cached paddleocr original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__ def mock_import(name, *args, **kwargs): if name == "paddleocr": raise ImportError("No module named 'paddleocr'") return original_import(name, *args, **kwargs) with patch("builtins.__import__", side_effect=mock_import): with pytest.raises(EngineUnavailableError, match="paddleocr"): engine._get_ocr() def test_processing_error_on_exception(self) -> None: from app.engines.paddle_engine import PaddleOcrEngine engine = PaddleOcrEngine() mock_ocr = MagicMock() mock_ocr.predict.side_effect = RuntimeError("OCR crashed") engine._ocr = mock_ocr with pytest.raises(EngineProcessingError, match="PaddleOCR recognition failed"): engine.recognize(_create_test_image_bytes(), OcrConfig()) # --------------------------------------------------------------------------- # CloudEngine # --------------------------------------------------------------------------- class TestCloudEngine: def test_name(self) -> None: from app.engines.cloud_engine import CloudEngine engine = CloudEngine(key_path="/fake/path.json") assert engine.name == "google_vision" def test_lazy_init_not_loaded_at_construction(self) -> None: from app.engines.cloud_engine import CloudEngine engine = CloudEngine(key_path="/fake/path.json") assert engine._client is None def test_missing_key_file_raises_unavailable(self) -> None: from app.engines.cloud_engine import CloudEngine engine = CloudEngine(key_path="/nonexistent/key.json") with pytest.raises(EngineUnavailableError, match="key not found"): engine._get_client() @patch("os.path.isfile", return_value=True) def test_missing_library_raises_unavailable(self, _mock_isfile: MagicMock) -> None: from app.engines.cloud_engine import CloudEngine engine = CloudEngine(key_path="/fake/key.json") def mock_import(name, *args, **kwargs): if "google.cloud" in name: raise ImportError("No module named 'google.cloud'") return __import__(name, *args, **kwargs) with patch("builtins.__import__", side_effect=mock_import): with pytest.raises(EngineUnavailableError, match="google-cloud-vision"): engine._get_client() def test_recognize_empty_annotations(self) -> None: from app.engines.cloud_engine import CloudEngine engine = CloudEngine(key_path="/fake/key.json") mock_client = MagicMock() mock_response = MagicMock() mock_response.error.message = "" mock_response.text_annotations = [] mock_client.text_detection.return_value = mock_response engine._client = mock_client # Mock the google.cloud.vision import inside recognize() mock_vision = MagicMock() with patch.dict("sys.modules", {"google.cloud.vision": mock_vision, "google.cloud": MagicMock(), "google": MagicMock()}): result = engine.recognize(b"fake_image", OcrConfig()) assert result.text == "" assert result.confidence == 0.0 assert result.engine_name == "google_vision" def test_recognize_api_error_raises_processing_error(self) -> None: from app.engines.cloud_engine import CloudEngine engine = CloudEngine(key_path="/fake/key.json") mock_client = MagicMock() mock_response = MagicMock() mock_response.error.message = "API quota exceeded" mock_client.text_detection.return_value = mock_response engine._client = mock_client mock_vision = MagicMock() with patch.dict("sys.modules", {"google.cloud.vision": mock_vision, "google.cloud": MagicMock(), "google": MagicMock()}): with pytest.raises(EngineProcessingError, match="API quota exceeded"): engine.recognize(b"fake_image", OcrConfig()) # --------------------------------------------------------------------------- # HybridEngine # --------------------------------------------------------------------------- class TestHybridEngine: def test_name_with_fallback(self) -> None: from app.engines.hybrid_engine import HybridEngine primary = MagicMock(spec=OcrEngine) primary.name = "paddleocr" fallback = MagicMock(spec=OcrEngine) fallback.name = "google_vision" engine = HybridEngine(primary=primary, fallback=fallback) assert engine.name == "hybrid(paddleocr+google_vision)" def test_name_without_fallback(self) -> None: from app.engines.hybrid_engine import HybridEngine primary = MagicMock(spec=OcrEngine) primary.name = "paddleocr" engine = HybridEngine(primary=primary) assert engine.name == "hybrid(paddleocr+none)" 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" primary.recognize.return_value = _make_result("VIN123", 0.95, "paddleocr") engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) result = engine.recognize(b"img", OcrConfig()) assert result.text == "VIN123" 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 primary = MagicMock(spec=OcrEngine) primary.name = "paddleocr" primary.recognize.return_value = _make_result("VIN123", 0.3, "paddleocr") engine = HybridEngine(primary=primary, fallback=None, threshold=0.6) result = engine.recognize(b"img", OcrConfig()) assert result.text == "VIN123" def test_fallback_lower_confidence_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.4, "paddleocr") fallback.recognize.return_value = _make_result("VIN456", 0.3, "google_vision") engine = HybridEngine(primary=primary, fallback=fallback, threshold=0.6) result = engine.recognize(b"img", OcrConfig()) assert result.text == "VIN123" def test_fallback_engine_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 = 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) 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: 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") # Simulate 6-second delay (exceeds 5s limit) 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" # timeout -> use primary 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 = "cloud" 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() # --------------------------------------------------------------------------- # Engine factory # --------------------------------------------------------------------------- class TestEngineFactory: def test_unknown_engine_raises(self) -> None: from app.engines.engine_factory import _create_single_engine with pytest.raises(EngineUnavailableError, match="Unknown engine"): _create_single_engine("nonexistent") @patch("app.engines.engine_factory.settings") @patch("app.engines.engine_factory._create_single_engine") def test_defaults_to_settings_primary( self, mock_create: MagicMock, mock_settings: MagicMock ) -> None: mock_settings.ocr_primary_engine = "paddleocr" mock_settings.ocr_fallback_engine = "none" mock_engine = MagicMock(spec=OcrEngine) mock_create.return_value = mock_engine from app.engines.engine_factory import create_engine result = create_engine() mock_create.assert_called_once_with("paddleocr") assert result == mock_engine @patch("app.engines.engine_factory.settings") @patch("app.engines.engine_factory._create_single_engine") def test_explicit_name_overrides_settings( self, mock_create: MagicMock, mock_settings: MagicMock ) -> None: mock_settings.ocr_fallback_engine = "none" mock_engine = MagicMock(spec=OcrEngine) mock_create.return_value = mock_engine from app.engines.engine_factory import create_engine create_engine("google_vision") mock_create.assert_called_once_with("google_vision") @patch("app.engines.engine_factory.settings") @patch("app.engines.engine_factory._create_single_engine") def test_creates_hybrid_when_fallback_configured( self, mock_create: MagicMock, mock_settings: MagicMock ) -> None: mock_settings.ocr_primary_engine = "paddleocr" mock_settings.ocr_fallback_engine = "google_vision" mock_settings.ocr_fallback_threshold = 0.7 mock_primary = MagicMock(spec=OcrEngine) mock_fallback = MagicMock(spec=OcrEngine) mock_create.side_effect = [mock_primary, mock_fallback] from app.engines.engine_factory import create_engine from app.engines.hybrid_engine import HybridEngine result = create_engine() assert isinstance(result, HybridEngine) @patch("app.engines.engine_factory.settings") @patch("app.engines.engine_factory._create_single_engine") def test_fallback_failure_returns_primary_only( self, mock_create: MagicMock, mock_settings: MagicMock ) -> None: mock_settings.ocr_primary_engine = "paddleocr" mock_settings.ocr_fallback_engine = "google_vision" mock_settings.ocr_fallback_threshold = 0.6 mock_primary = MagicMock(spec=OcrEngine) mock_create.side_effect = [mock_primary, EngineUnavailableError("no key")] from app.engines.engine_factory import create_engine result = create_engine() assert result == mock_primary