XmLLM / tests /integration /test_fixtures_e2e.py
Claude
Code quality: fix all ruff warnings, add CI/CD, improve test coverage
bbbfba8 unverified
Raw
History Blame Contribute Delete
11.2 kB
"""End-to-end tests with all fixture types (§28.5).
Each fixture represents a different document scenario and is run through
the full pipeline: raw → normalize → enrich → validate → ALTO + PAGE + viewer.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from lxml import etree
from src.app.domain.models import CanonicalDocument, RawProviderPayload
from src.app.domain.models.geometry import GeometryContext
from src.app.domain.models.status import GeometryStatus
from src.app.enrichers import EnricherPipeline
from src.app.enrichers.bbox_repair_light import BboxRepairLightEnricher
from src.app.enrichers.hyphenation_basic import HyphenationBasicEnricher
from src.app.enrichers.lang_propagation import LangPropagationEnricher
from src.app.enrichers.reading_order_simple import ReadingOrderSimpleEnricher
from src.app.enrichers.text_consistency import TextConsistencyEnricher
from src.app.normalization.pipeline import normalize
from src.app.policies.document_policy import DocumentPolicy
from src.app.policies.export_policy import check_alto_export, check_page_export
from src.app.serializers.alto_xml import ALTO_NS, serialize_alto
from src.app.serializers.page_xml import PAGE_NS, serialize_page_xml
from src.app.validators.export_eligibility_validator import compute_export_eligibility
from src.app.validators.structural_validator import validate_structure
from src.app.viewer.projection_builder import build_projection
if TYPE_CHECKING:
from pathlib import Path
GEO_CTX = GeometryContext(source_width=1000, source_height=800)
ENRICHER_PIPELINE = EnricherPipeline([
BboxRepairLightEnricher(),
LangPropagationEnricher(),
ReadingOrderSimpleEnricher(),
HyphenationBasicEnricher(),
TextConsistencyEnricher(),
])
def _run_paddle_pipeline(fixture_name: str, fixtures_dir: Path) -> tuple[
CanonicalDocument, bytes, bytes, dict
]:
"""Full pipeline for a PaddleOCR-format fixture."""
with open(fixtures_dir / fixture_name) as f:
payload = json.load(f)
raw = RawProviderPayload(
provider_id="paddleocr", adapter_id="v1", runtime_type="local",
payload=payload, image_width=1000, image_height=800,
)
doc = normalize(raw, "word_box_json", GEO_CTX, document_id=f"test_{fixture_name}")
doc = ENRICHER_PIPELINE.run(doc, DocumentPolicy())
validate_structure(doc, bbox_tolerance=5.0)
eligibility = compute_export_eligibility(doc)
alto_bytes = serialize_alto(doc)
page_bytes = serialize_page_xml(doc)
vp = build_projection(doc, export_status=eligibility)
return doc, alto_bytes, page_bytes, vp.model_dump(mode="json")
# -- Simple page (paddle_ocr_sample.json) ------------------------------------
class TestSimplePage:
def test_full_pipeline(self, fixtures_dir: Path) -> None:
doc, alto, page, viewer = _run_paddle_pipeline("paddle_ocr_sample.json", fixtures_dir)
assert len(doc.pages[0].text_regions) >= 1
assert b"Bonjour" in alto
assert b"Bonjour" in page
assert len(viewer["word_overlays"]) == 5
def test_alto_valid_structure(self, fixtures_dir: Path) -> None:
_, alto, _, _ = _run_paddle_pipeline("paddle_ocr_sample.json", fixtures_dir)
root = etree.fromstring(alto)
strings = root.findall(f".//{{{ALTO_NS}}}String")
assert all(s.get("HPOS").isdigit() for s in strings)
assert all(s.get("CONTENT") for s in strings)
def test_page_has_reading_order(self, fixtures_dir: Path) -> None:
_, _, page, _ = _run_paddle_pipeline("paddle_ocr_sample.json", fixtures_dir)
root = etree.fromstring(page)
refs = root.findall(f".//{{{PAGE_NS}}}RegionRefIndexed")
assert len(refs) >= 1
# -- Double column -----------------------------------------------------------
class TestDoubleColumn:
def test_four_items(self, fixtures_dir: Path) -> None:
doc, _, _, _ = _run_paddle_pipeline("double_column.json", fixtures_dir)
words = [w for r in doc.pages[0].text_regions for ln in r.lines for w in ln.words]
assert len(words) == 4
def test_reading_order_inferred(self, fixtures_dir: Path) -> None:
doc, _, _, _ = _run_paddle_pipeline("double_column.json", fixtures_dir)
assert doc.pages[0].reading_order # enricher should have set it
def test_dual_export(self, fixtures_dir: Path) -> None:
_, alto, page, _ = _run_paddle_pipeline("double_column.json", fixtures_dir)
alto_root = etree.fromstring(alto)
page_root = etree.fromstring(page)
assert len(alto_root.findall(f".//{{{ALTO_NS}}}String")) == 4
assert len(page_root.findall(f".//{{{PAGE_NS}}}Word")) == 4
# -- Noisy page --------------------------------------------------------------
class TestNoisyPage:
def test_handles_negative_coords(self, fixtures_dir: Path) -> None:
doc, _, _, _ = _run_paddle_pipeline("noisy_page.json", fixtures_dir)
# bbox_repair_light should clip negative coords
for r in doc.pages[0].text_regions:
for ln in r.lines:
for w in ln.words:
x, y, _, _ = w.geometry.bbox
assert x >= 0
assert y >= 0
def test_low_confidence_preserved(self, fixtures_dir: Path) -> None:
doc, _, _, _ = _run_paddle_pipeline("noisy_page.json", fixtures_dir)
words = [w for r in doc.pages[0].text_regions for ln in r.lines for w in ln.words]
confs = [w.confidence for w in words if w.confidence is not None]
assert any(c < 0.5 for c in confs)
def test_structural_validation(self, fixtures_dir: Path) -> None:
doc, _, _, _ = _run_paddle_pipeline("noisy_page.json", fixtures_dir)
report = validate_structure(doc, bbox_tolerance=5.0)
# After repair, structural issues should be minimized
assert report.error_count == 0
def test_viewer_has_all_words(self, fixtures_dir: Path) -> None:
_, _, _, viewer = _run_paddle_pipeline("noisy_page.json", fixtures_dir)
assert len(viewer["word_overlays"]) == 4
# -- Title + body ------------------------------------------------------------
class TestTitleBody:
def test_four_items(self, fixtures_dir: Path) -> None:
doc, _, _, _ = _run_paddle_pipeline("title_body.json", fixtures_dir)
words = [w for r in doc.pages[0].text_regions for ln in r.lines for w in ln.words]
assert len(words) == 4
def test_alto_all_strings(self, fixtures_dir: Path) -> None:
_, alto, _, _ = _run_paddle_pipeline("title_body.json", fixtures_dir)
root = etree.fromstring(alto)
strings = root.findall(f".//{{{ALTO_NS}}}String")
assert any("Titre" in s.get("CONTENT", "") for s in strings)
def test_page_all_words(self, fixtures_dir: Path) -> None:
_, _, page, _ = _run_paddle_pipeline("title_body.json", fixtures_dir)
root = etree.fromstring(page)
words = root.findall(f".//{{{PAGE_NS}}}Word")
assert len(words) == 4
# -- Hyphenation -------------------------------------------------------------
class TestHyphenationFixture:
def test_hyphenation_detected(self, fixtures_dir: Path) -> None:
doc, _, _, _ = _run_paddle_pipeline("hyphenation_sample.json", fixtures_dir)
words = [w for r in doc.pages[0].text_regions for ln in r.lines for w in ln.words]
hyph_words = [w for w in words if w.hyphenation is not None and w.hyphenation.is_hyphenated]
assert len(hyph_words) == 2
assert hyph_words[0].hyphenation.full_form == "patrimoine"
assert hyph_words[0].hyphenation.part == 1
assert hyph_words[1].hyphenation.part == 2
def test_alto_hyphenation(self, fixtures_dir: Path) -> None:
_, alto, _, _ = _run_paddle_pipeline("hyphenation_sample.json", fixtures_dir)
root = etree.fromstring(alto)
strings = root.findall(f".//{{{ALTO_NS}}}String")
hyp_strings = [s for s in strings if s.get("SUBS_TYPE")]
assert len(hyp_strings) == 2
assert hyp_strings[0].get("SUBS_TYPE") == "HypPart1"
assert hyp_strings[0].get("SUBS_CONTENT") == "patrimoine"
# -- Text only (no geometry) -------------------------------------------------
class TestTextOnlyFixture:
def test_produces_document(self, fixtures_dir: Path) -> None:
with open(fixtures_dir / "text_only_blocks.json") as f:
payload = json.load(f)
raw = RawProviderPayload(
provider_id="qwen", adapter_id="v1", runtime_type="api",
payload=payload, image_width=1000, image_height=800,
)
doc = normalize(raw, "text_only", GEO_CTX, document_id="text_test")
assert len(doc.pages[0].text_regions) == 3
def test_geometry_is_unknown(self, fixtures_dir: Path) -> None:
with open(fixtures_dir / "text_only_blocks.json") as f:
payload = json.load(f)
raw = RawProviderPayload(
provider_id="qwen", adapter_id="v1", runtime_type="api",
payload=payload, image_width=1000, image_height=800,
)
doc = normalize(raw, "text_only", GEO_CTX, document_id="text_test")
word = doc.pages[0].text_regions[0].lines[0].words[0]
assert word.geometry.status == GeometryStatus.UNKNOWN
def test_alto_refused(self, fixtures_dir: Path) -> None:
with open(fixtures_dir / "text_only_blocks.json") as f:
payload = json.load(f)
raw = RawProviderPayload(
provider_id="qwen", adapter_id="v1", runtime_type="api",
payload=payload, image_width=1000, image_height=800,
)
doc = normalize(raw, "text_only", GEO_CTX, document_id="text_test")
eligibility = compute_export_eligibility(doc)
decision = check_alto_export(eligibility)
assert decision.allowed is False
def test_page_export_possible(self, fixtures_dir: Path) -> None:
with open(fixtures_dir / "text_only_blocks.json") as f:
payload = json.load(f)
raw = RawProviderPayload(
provider_id="qwen", adapter_id="v1", runtime_type="api",
payload=payload, image_width=1000, image_height=800,
)
doc = normalize(raw, "text_only", GEO_CTX, document_id="text_test")
eligibility = compute_export_eligibility(doc)
# PAGE is more lenient — may still be exportable
page_decision = check_page_export(eligibility)
# Even if refused, it should give a clear reason
assert page_decision.reason
def test_viewer_renders_degraded(self, fixtures_dir: Path) -> None:
with open(fixtures_dir / "text_only_blocks.json") as f:
payload = json.load(f)
raw = RawProviderPayload(
provider_id="qwen", adapter_id="v1", runtime_type="api",
payload=payload, image_width=1000, image_height=800,
)
doc = normalize(raw, "text_only", GEO_CTX, document_id="text_test")
eligibility = compute_export_eligibility(doc)
vp = build_projection(doc, export_status=eligibility)
# Should still have overlays even with unknown geometry
assert len(vp.word_overlays) > 0