document-extract-agent / tests /test_backends.py
kennethzychew's picture
feat(gemini): Gemini backend + vision-direct image acquire (T2)
7a6af4c
Raw
History Blame Contribute Delete
6.86 kB
"""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}