"""Tests for the CanonicalDocument model — the heart of the system.""" from __future__ import annotations import json import pytest from pydantic import ValidationError from src.app.domain.models import ( AltoReadiness, Audit, BlockRole, CanonicalDocument, EvidenceType, Geometry, GeometryStatus, Hyphenation, InputType, NonTextKind, NonTextRegion, Page, PageXmlReadiness, Provenance, ReadinessLevel, Source, TextLine, TextRegion, Word, ) # -- Helpers (reusable fixture builders) ------------------------------------- def _prov(ref: str = "$.test", evidence: EvidenceType = EvidenceType.PROVIDER_NATIVE) -> Provenance: return Provenance( provider="test-provider", adapter="adapter.test.v1", source_ref=ref, evidence_type=evidence, ) def _geo( x: float = 100, y: float = 200, w: float = 300, h: float = 50, status: GeometryStatus = GeometryStatus.EXACT, ) -> Geometry: return Geometry(bbox=(x, y, w, h), status=status) def _word( id: str = "w1", text: str = "Bonjour", x: float = 100, y: float = 200, w: float = 90, h: float = 40, ) -> Word: return Word( id=id, text=text, geometry=_geo(x, y, w, h), lang="fra", confidence=0.96, provenance=_prov(f"$.words.{id}"), ) def _line(id: str = "tl1", words: list[Word] | None = None) -> TextLine: return TextLine( id=id, geometry=_geo(100, 200, 1100, 42), lang="fra", provenance=_prov(f"$.lines.{id}"), words=words or [_word()], ) def _region(id: str = "tb1", lines: list[TextLine] | None = None) -> TextRegion: return TextRegion( id=id, role=BlockRole.BODY, geometry=_geo(100, 200, 1200, 900), lang="fra", provenance=_prov(f"$.blocks.{id}"), lines=lines or [_line()], ) def _page( id: str = "p1", text_regions: list[TextRegion] | None = None, ) -> Page: return Page( id=id, page_index=0, width=2480, height=3508, alto_readiness=AltoReadiness(level=ReadinessLevel.FULL), page_readiness=PageXmlReadiness(level=ReadinessLevel.FULL), reading_order=["tb1"], text_regions=text_regions or [_region()], ) def _doc(pages: list[Page] | None = None) -> CanonicalDocument: return CanonicalDocument( document_id="doc_test_001", source=Source(input_type=InputType.IMAGE, filename="test.png"), pages=pages or [_page()], ) # -- Source ------------------------------------------------------------------ class TestSource: def test_minimal(self) -> None: s = Source(input_type=InputType.IMAGE) assert s.filename is None assert s.checksum is None def test_full(self) -> None: s = Source( input_type=InputType.IMAGE, filename="page.tif", mime_type="image/tiff", checksum="abc123", ) assert s.filename == "page.tif" # -- Hyphenation ------------------------------------------------------------- class TestHyphenation: def test_not_hyphenated(self) -> None: h = Hyphenation(is_hyphenated=False) assert h.part is None assert h.full_form is None def test_part_1(self) -> None: h = Hyphenation(is_hyphenated=True, part=1, full_form="patrimoine") assert h.part == 1 def test_part_2(self) -> None: h = Hyphenation(is_hyphenated=True, part=2, full_form="patrimoine") assert h.part == 2 def test_false_with_part_rejected(self) -> None: with pytest.raises(ValidationError, match="is_hyphenated is False"): Hyphenation(is_hyphenated=False, part=1, full_form="test") def test_true_without_part_rejected(self) -> None: with pytest.raises(ValidationError, match="part must be 1 or 2"): Hyphenation(is_hyphenated=True, part=None, full_form="test") def test_true_without_full_form_rejected(self) -> None: with pytest.raises(ValidationError, match="full_form is required"): Hyphenation(is_hyphenated=True, part=1, full_form=None) def test_invalid_part_rejected(self) -> None: with pytest.raises(ValidationError, match="part must be 1 or 2"): Hyphenation(is_hyphenated=True, part=3, full_form="test") # -- Word -------------------------------------------------------------------- class TestWord: def test_valid(self) -> None: w = _word() assert w.text == "Bonjour" assert w.confidence == 0.96 def test_empty_text_rejected(self) -> None: with pytest.raises(ValidationError): Word( id="w1", text="", geometry=_geo(), provenance=_prov(), ) def test_empty_id_rejected(self) -> None: with pytest.raises(ValidationError): Word( id="", text="hello", geometry=_geo(), provenance=_prov(), ) def test_confidence_out_of_range_rejected(self) -> None: with pytest.raises(ValidationError): Word( id="w1", text="hello", geometry=_geo(), confidence=1.5, provenance=_prov(), ) def test_invalid_lang_rejected(self) -> None: with pytest.raises(ValidationError): Word( id="w1", text="hello", geometry=_geo(), lang="french", # must be 3-letter ISO provenance=_prov(), ) def test_valid_lang(self) -> None: w = Word( id="w1", text="hello", geometry=_geo(), lang="eng", provenance=_prov(), ) assert w.lang == "eng" def test_null_confidence_ok(self) -> None: w = Word( id="w1", text="hello", geometry=_geo(), confidence=None, provenance=_prov(), ) assert w.confidence is None def test_with_hyphenation(self) -> None: w = Word( id="w1", text="patri-", geometry=_geo(), provenance=_prov(), hyphenation=Hyphenation(is_hyphenated=True, part=1, full_form="patrimoine"), ) assert w.hyphenation is not None assert w.hyphenation.full_form == "patrimoine" def test_with_metadata(self) -> None: w = Word( id="w1", text="hello", geometry=_geo(), provenance=_prov(), metadata={"custom_field": "value"}, ) assert w.metadata == {"custom_field": "value"} # -- TextLine ---------------------------------------------------------------- class TestTextLine: def test_valid(self) -> None: line = _line() assert line.text == "Bonjour" def test_empty_words_rejected(self) -> None: with pytest.raises(ValidationError): TextLine( id="tl1", geometry=_geo(), provenance=_prov(), words=[], ) def test_multiple_words(self) -> None: line = _line(words=[_word("w1", "Hello"), _word("w2", "world")]) assert line.text == "Hello world" # -- TextRegion -------------------------------------------------------------- class TestTextRegion: def test_valid(self) -> None: r = _region() assert r.role == BlockRole.BODY assert "Bonjour" in r.text def test_empty_lines_rejected(self) -> None: with pytest.raises(ValidationError): TextRegion( id="tb1", geometry=_geo(), provenance=_prov(), lines=[], ) def test_multiple_lines(self) -> None: r = _region( lines=[ _line("tl1", [_word("w1", "First")]), _line("tl2", [_word("w2", "Second")]), ] ) assert r.text == "First\nSecond" def test_null_role(self) -> None: r = TextRegion( id="tb1", role=None, geometry=_geo(), provenance=_prov(), lines=[_line()], ) assert r.role is None # -- NonTextRegion ----------------------------------------------------------- class TestNonTextRegion: def test_valid(self) -> None: ntr = NonTextRegion( id="ntr1", kind=NonTextKind.ILLUSTRATION, geometry=_geo(), provenance=_prov(), ) assert ntr.kind == NonTextKind.ILLUSTRATION def test_all_kinds(self) -> None: for kind in NonTextKind: ntr = NonTextRegion( id=f"ntr_{kind.value}", kind=kind, geometry=_geo(), provenance=_prov(), ) assert ntr.kind == kind # -- Page -------------------------------------------------------------------- class TestPage: def test_valid(self) -> None: p = _page() assert p.width == 2480 assert p.height == 3508 assert "Bonjour" in p.text def test_zero_width_rejected(self) -> None: with pytest.raises(ValidationError): Page( id="p1", page_index=0, width=0, height=100, ) def test_negative_index_rejected(self) -> None: with pytest.raises(ValidationError): Page( id="p1", page_index=-1, width=100, height=100, ) def test_empty_page_ok(self) -> None: """A page with no regions is valid (e.g. blank page).""" p = Page( id="p1", page_index=0, width=2480, height=3508, ) assert p.text_regions == [] assert p.text == "" def test_with_non_text_region(self) -> None: ntr = NonTextRegion( id="ntr1", kind=NonTextKind.SEPARATOR, geometry=_geo(), provenance=_prov(), ) p = Page( id="p1", page_index=0, width=2480, height=3508, non_text_regions=[ntr], ) assert len(p.non_text_regions) == 1 # -- CanonicalDocument ------------------------------------------------------- class TestCanonicalDocument: def test_valid_minimal(self) -> None: doc = _doc() assert doc.document_id == "doc_test_001" assert doc.schema_version == "1.0.0" assert len(doc.pages) == 1 def test_empty_pages_rejected(self) -> None: with pytest.raises(ValidationError): CanonicalDocument( document_id="doc1", source=Source(input_type=InputType.IMAGE), pages=[], ) def test_text_property(self) -> None: doc = _doc() assert "Bonjour" in doc.text def test_all_ids(self) -> None: doc = _doc() ids = doc.all_ids assert "p1" in ids assert "tb1" in ids assert "tl1" in ids assert "w1" in ids def test_all_text_region_ids(self) -> None: doc = _doc() assert doc.all_text_region_ids == {"tb1"} def test_invalid_schema_version_rejected(self) -> None: with pytest.raises(ValidationError): CanonicalDocument( schema_version="v1", document_id="doc1", source=Source(input_type=InputType.IMAGE), pages=[_page()], ) def test_empty_document_id_rejected(self) -> None: with pytest.raises(ValidationError): CanonicalDocument( document_id="", source=Source(input_type=InputType.IMAGE), pages=[_page()], ) def test_default_audit(self) -> None: doc = _doc() assert doc.audit.enrichers_applied == [] assert doc.audit.validators_run == [] def test_with_audit(self) -> None: doc = CanonicalDocument( document_id="doc1", source=Source(input_type=InputType.IMAGE), pages=[_page()], audit=Audit( provider_id="paddle", runtime_type="local", enrichers_applied=["polygon_to_bbox", "lang_propagation"], ), ) assert len(doc.audit.enrichers_applied) == 2 def test_default_export_eligibility(self) -> None: doc = _doc() assert doc.export_eligibility.alto_export == ReadinessLevel.NONE def test_with_metadata(self) -> None: doc = CanonicalDocument( document_id="doc1", source=Source(input_type=InputType.IMAGE), pages=[_page()], metadata={"project": "test", "version": 2}, ) assert doc.metadata["project"] == "test" def test_json_roundtrip(self) -> None: """Full serialization → deserialization cycle.""" doc = _doc() json_str = doc.model_dump_json() parsed = json.loads(json_str) doc2 = CanonicalDocument.model_validate(parsed) assert doc2.document_id == doc.document_id assert len(doc2.pages) == len(doc.pages) assert doc2.pages[0].text_regions[0].lines[0].words[0].text == "Bonjour" def test_json_schema_generation(self) -> None: """Verify we can generate a JSON Schema from the Pydantic model.""" schema = CanonicalDocument.model_json_schema() assert schema["title"] == "CanonicalDocument" assert "pages" in schema["properties"] # Verify nested definitions exist assert "$defs" in schema defs = schema["$defs"] assert "Word" in defs assert "TextLine" in defs assert "TextRegion" in defs assert "Page" in defs assert "Geometry" in defs assert "Provenance" in defs # -- Full example from spec -------------------------------------------------- class TestSpecExample: """Validate the example from the architecture document §14.""" def test_spec_example_validates(self) -> None: doc = CanonicalDocument( schema_version="1.0.0", document_id="doc_0001", source=Source( input_type=InputType.IMAGE, filename="page_001.tif", mime_type="image/tiff", checksum=None, ), pages=[ Page( id="p1", page_index=0, width=2480, height=3508, alto_readiness=AltoReadiness(level=ReadinessLevel.FULL), page_readiness=PageXmlReadiness(level=ReadinessLevel.FULL), reading_order=["tb1"], text_regions=[ TextRegion( id="tb1", role=BlockRole.BODY, geometry=Geometry( bbox=(100, 200, 1200, 900), polygon=None, status=GeometryStatus.EXACT, ), confidence=None, lang="fra", provenance=Provenance( provider="paddleocr-vl", adapter="adapter.paddle.v1", source_ref="$.pages[0].blocks[0]", evidence_type=EvidenceType.PROVIDER_NATIVE, derived_from=[], ), lines=[ TextLine( id="tl1", geometry=Geometry( bbox=(110, 220, 1180, 42), polygon=None, status=GeometryStatus.EXACT, ), confidence=None, lang="fra", provenance=Provenance( provider="paddleocr-vl", adapter="adapter.paddle.v1", source_ref="$.pages[0].blocks[0].lines[0]", evidence_type=EvidenceType.PROVIDER_NATIVE, derived_from=[], ), words=[ Word( id="w1", text="Bonjour", normalized_text=None, geometry=Geometry( bbox=(110, 220, 90, 40), polygon=None, status=GeometryStatus.EXACT, ), lang="fra", confidence=0.96, style_refs=[], hyphenation=None, provenance=Provenance( provider="paddleocr-vl", adapter="adapter.paddle.v1", source_ref="$.pages[0].blocks[0].lines[0].words[0]", evidence_type=EvidenceType.PROVIDER_NATIVE, derived_from=[], ), ), ], ), ], ), ], non_text_regions=[], ), ], ) assert doc.document_id == "doc_0001" assert doc.pages[0].text_regions[0].lines[0].words[0].text == "Bonjour" assert doc.pages[0].text_regions[0].lines[0].words[0].confidence == 0.96 def test_hyphenation_example(self) -> None: """Validate the hyphenation example from §8.""" w1 = Word( id="w18", text="patri-", geometry=_geo(500, 800, 95, 30), confidence=0.89, lang="fra", provenance=_prov("$.blocks[2].lines[5].words[7]"), hyphenation=Hyphenation(is_hyphenated=True, part=1, full_form="patrimoine"), ) w2 = Word( id="w19", text="moine", geometry=_geo(120, 840, 70, 30), confidence=0.90, lang="fra", provenance=_prov("$.blocks[2].lines[6].words[0]"), hyphenation=Hyphenation(is_hyphenated=True, part=2, full_form="patrimoine"), ) assert w1.hyphenation.full_form == w2.hyphenation.full_form == "patrimoine" assert w1.hyphenation.part == 1 assert w2.hyphenation.part == 2