"""Unit tests for the backend interface, factory, and offline stub. Covers the acceptance criteria for build-plan task 2.4 (+ the stub): - the factory returns the configured backend (resolved from settings or an explicit override), - an unknown backend name raises a clear, recoverable error, - the stub returns schema-valid ``Document`` data with no network. Also exercises the stub's determinism, its deep-copy isolation between calls, protocol conformance, and that the default stub document passes validation cleanly (the happy path the core smoke test relies on). """ from __future__ import annotations import pytest from doc_agent.backends.base import ( BackendResult, DocumentPayload, ExtractionBackend, available_backends, create_backend, ) from doc_agent.backends.stub import ( DEFAULT_STUB_DOCUMENT, StubBackend, ) from doc_agent.config import ConfigError, Settings, load_config from doc_agent.schema.models import Document from doc_agent.validation.rules import validate def _gemini_settings() -> Settings: """A valid Settings instance configured for the Gemini backend.""" return load_config(extraction_backend="gemini", gemini_api_key="test-key") def _ollama_settings() -> Settings: """A valid Settings instance configured for the (text-only) Ollama backend.""" return load_config(extraction_backend="ollama", image_strategy="ocr_then_text") def _payload() -> DocumentPayload: """A minimal text payload for driving ``extract`` (ignored by the stub).""" return DocumentPayload(modality="image", text="any text the stub ignores") # --- factory: returns the configured backend ---------------------------------- def test_factory_returns_backend_for_explicit_name() -> None: """An explicit name override resolves to the registered backend (AC).""" backend = create_backend(_gemini_settings(), name="stub") assert isinstance(backend, StubBackend) assert backend.name == "stub" def test_factory_override_takes_precedence_over_config() -> None: """The explicit ``name`` wins over ``settings.extraction_backend``.""" # Settings say "gemini", but the override forces the stub -- proving the # factory honors the override (and that selection is the one resolution # point the rest of the system depends on). backend = create_backend(_ollama_settings(), name="stub") assert isinstance(backend, StubBackend) def test_factory_reads_backend_name_from_settings() -> None: """With no override the factory resolves the name from settings. Gemini is now registered (T2), so _gemini_settings() succeeds. Ollama is still unregistered, so _ollama_settings() still raises a ConfigError naming the configured backend. """ from unittest.mock import patch from doc_agent.backends.gemini import GeminiBackend with patch("google.genai.Client"): backend = create_backend(_gemini_settings()) assert isinstance(backend, GeminiBackend) with pytest.raises(ConfigError, match="ollama"): create_backend(_ollama_settings()) def test_factory_returns_protocol_conforming_object() -> None: """The built backend satisfies the ``ExtractionBackend`` protocol.""" backend = create_backend(_gemini_settings(), name="stub") assert isinstance(backend, ExtractionBackend) # --- factory: unknown backend -> clear error ---------------------------------- def test_unknown_backend_raises_config_error() -> None: """An unrecognized backend name raises a recoverable ConfigError (AC).""" with pytest.raises(ConfigError) as excinfo: create_backend(_gemini_settings(), name="does-not-exist") message = str(excinfo.value) assert "does-not-exist" in message # The message is actionable: it lists what *is* available. assert "stub" in message def test_available_backends_lists_registered_backends() -> None: """The registry exposes gemini and stub (sorted); ollama is not yet registered.""" names = available_backends() assert "stub" in names assert "gemini" in names assert "ollama" not in names assert list(names) == sorted(names) # --- stub: returns schema-valid data ------------------------------------------ def test_stub_returns_schema_valid_document_data() -> None: """The stub's data validates against the ``Document`` schema (AC).""" result = StubBackend().extract(_payload(), Document) assert isinstance(result, BackendResult) document = Document.model_validate(result.data) assert document.total == pytest.approx(11.77) assert document.invoice_number == "STUB-0001" assert len(document.line_items) == 2 def test_stub_default_document_passes_validation_cleanly() -> None: """The default stub document trips no hard or soft rule (the happy path).""" document = Document.model_validate(StubBackend().extract(_payload(), Document).data) report = validate(document) assert not report.hard_failed assert not report.soft_failures def test_stub_reports_field_confidence() -> None: """The stub exposes a per-field confidence signal in [0, 1].""" result = StubBackend().extract(_payload(), Document) assert result.field_confidence is not None assert "total" in result.field_confidence assert all(0.0 <= v <= 1.0 for v in result.field_confidence.values()) # --- stub: determinism and isolation ------------------------------------------ def test_stub_is_deterministic_across_calls() -> None: """Two calls (with different payloads) return identical data.""" backend = StubBackend() first = backend.extract(DocumentPayload(modality="native_pdf", text="A"), Document) second = backend.extract(DocumentPayload(modality="image", text="B"), Document) assert first.data == second.data def test_stub_extract_returns_an_independent_copy() -> None: """Mutating a returned result cannot corrupt later calls or the template.""" backend = StubBackend() result = backend.extract(_payload(), Document) result.data["total"] = 999.0 result.data["line_items"].clear() fresh = backend.extract(_payload(), Document) assert fresh.data["total"] == pytest.approx(11.77) assert len(fresh.data["line_items"]) == 2 # The module-level template is likewise untouched. assert DEFAULT_STUB_DOCUMENT["total"] == pytest.approx(11.77) # --- stub: injected data ------------------------------------------------------ def test_stub_returns_injected_data() -> None: """A custom ``data`` mapping is returned verbatim, enabling targeted tests.""" custom = {"doc_type": "invoice", "total": "42.00", "vendor_name": "Custom Co"} backend = StubBackend(data=custom, field_confidence={"total": 0.5}) result = backend.extract(_payload(), Document) assert result.data == custom assert result.field_confidence == {"total": 0.5}