"""MonkeyOCR 文档结果包解析测试(#34 设计 §5/§5.1)。 fixtures 全部**合成**: 保留 2026-10-21 实测的结构骨架(para_blocks 的块类型、 table/image 的嵌套 `blocks`、span 字段),一切识别文本替换为 `SYNTH-*` 占位—— 第三方论文内容许可不明、医疗数据不入库(零业务假设 + P5),真实 ZIP 不提交。 契约表逐行一条,参数化列明各分支。解析函数是 bytes → 结果的纯函数,失败一律 `ResultInvalidError`,**不得漏出原生异常**(漏出会让 `OcrClient._call` 既不写终态遥测 行也不归还半开探针)。 """ import io import json import struct import zipfile import pytest from polygateway.errors import ResultInvalidError from polygateway.transports import _monkey_document from polygateway.transports._monkey_document import parse_document_package from polygateway.types import OcrDocumentElement from tests.unit._document_fixtures import ( _BBOX, _body, _encrypted_package, _image_block, _package, _page, _span, _table_block, _text_block, ) def _parse(pages=None, **kwargs): return parse_document_package(_package(pages, **kwargs)) def _invalid(pages=None, **kwargs): with pytest.raises(ResultInvalidError): _parse(pages, **kwargs) class TestHappyPath: def test_every_block_type_yields_text_per_contract(self): pages = [ _page( [ _text_block("SYNTH-TITLE", type_="title"), _text_block("SYNTH-BODY"), _text_block("x^{2}", type_="interline_equation"), _table_block("SYNTH
"), _image_block(), ] ) ] elements, page_sizes, markdown = _parse(pages) assert [(e.type, e.text) for e in elements] == [ ("title", "SYNTH-TITLE"), ("text", "SYNTH-BODY"), ("interline_equation", "x^{2}"), ("table", "SYNTH
"), ("image", ""), ] assert all(isinstance(e, OcrDocumentElement) for e in elements) assert all(e.bbox == _BBOX and e.page_index == 0 for e in elements) assert page_sizes == [(612.0, 792.0)] assert markdown == "# synth\n" def test_reading_order_and_page_index_across_pages(self): pages = [ _page([_text_block("A")], page_idx=0), _page([_text_block("B"), _text_block("C")], page_idx=1, page_size=(595.0, 842.0)), ] elements, page_sizes, _ = _parse(pages) assert [(e.page_index, e.text) for e in elements] == [(0, "A"), (1, "B"), (1, "C")] assert page_sizes == [(612.0, 792.0), (595.0, 842.0)] def test_content_list_and_images_are_ignored(self): """内容只来自 middle: content_list 把 `$` 转义成 `\\$`(展示格式),取它会污染 LaTeX。""" garbage = json.dumps([{"type": "text", "text": "GARBAGE \\$"}]).encode() elements, _, _ = parse_document_package( _package( [_page([_text_block("SYNTH $x$")])], extra=[("doc_content_list.json", garbage), ("images/x.jpg", b"\xff\xd8")], ) ) assert [e.text for e in elements] == ["SYNTH $x$"] def test_discarded_blocks_are_not_in_the_result(self): pages = [_page([_text_block("KEEP")], discarded_blocks=[_text_block("DROPPED")])] elements, _, _ = _parse(pages) assert [e.text for e in elements] == ["KEEP"] def test_need_drop_pages_are_neither_read_nor_rejected(self): pages = [_page([_text_block("KEEP")], need_drop=True, drop_reason=["synthetic"])] elements, _, _ = _parse(pages) assert [e.text for e in elements] == ["KEEP"] class TestTextAssembly: def test_multiple_lines_and_spans_are_joined(self): """样本全是 1 行 1 span;多行/多 span 路径未经实测,只由构造 fixture 钉住拼接规则。""" block = { "type": "text", "bbox": list(_BBOX), "lines": [ {"spans": [_span("A"), _span("B")]}, {"spans": [_span("C")]}, ], } elements, _, _ = _parse([_page([block])]) assert elements[0].text == "AB\nC" def test_inline_equation_spans_are_wrapped_in_dollars(self): """上游惯例;样本中从未出现该 span 类型,未经实测。""" block = { "type": "text", "bbox": list(_BBOX), "lines": [{"spans": [_span("see "), _span("a+b", "inline_equation"), _span(".")]}], } elements, _, _ = _parse([_page([block])]) assert elements[0].text == "see $a+b$." def test_empty_lines_list_is_empty_text(self): block = {"type": "text", "bbox": list(_BBOX), "lines": []} elements, _, _ = _parse([_page([block])]) assert elements[0].text == "" @pytest.mark.parametrize( "mutate", [ pytest.param(lambda b: b.update(lines="not-a-list"), id="lines-not-list"), pytest.param(lambda b: b.update(lines=["not-a-dict"]), id="line-not-dict"), pytest.param(lambda b: b["lines"][0].update(spans="x"), id="spans-not-list"), pytest.param(lambda b: b["lines"][0].update(spans=["x"]), id="span-not-dict"), pytest.param(lambda b: b["lines"][0]["spans"][0].pop("content"), id="content-missing"), pytest.param(lambda b: b["lines"][0]["spans"][0].update(content=7), id="content-int"), pytest.param( lambda b: b["lines"][0]["spans"][0].update(content=None), id="content-null" ), ], ) def test_malformed_text_structure_is_rejected_not_silently_empty(self, mutate): block = _text_block() mutate(block) _invalid([_page([block])]) class TestTableAndImage: def test_table_falls_back_to_latex_when_no_html(self): body = _body("table_body", [{"type": "table", "latex": "\\begin{tabular}SYNTH"}]) block = {"type": "table", "bbox": list(_BBOX), "blocks": [body]} elements, _, _ = _parse([_page([block])]) assert elements[0].text == "\\begin{tabular}SYNTH" @pytest.mark.parametrize( "block", [ pytest.param( { "type": "table", "bbox": list(_BBOX), "blocks": [_body("table_body", [{"type": "table"}])], }, id="span-has-neither-html-nor-latex", ), pytest.param( {"type": "table", "bbox": list(_BBOX), "blocks": []}, id="no-body-sub-block" ), pytest.param({"type": "table", "bbox": list(_BBOX)}, id="no-blocks-key"), ], ) def test_table_without_content_is_rejected_not_silently_empty(self, block): """表格无内容与"合法空串"无法在结果里区分,故响亮失败。""" _invalid([_page([block])]) def test_image_text_is_empty_even_with_path_only_span(self): elements, _, _ = _parse([_page([_image_block()])]) assert (elements[0].type, elements[0].text) == ("image", "") def test_non_body_sub_blocks_become_independent_elements_after_their_parent(self): """默认部署不产生 caption/footnote 子块(未经实测);出现时不得静默丢弃。""" caption = _body("table_caption", [_span("SYNTH-CAPTION")], bbox=(1.0, 2.0, 3.0, 4.0)) footnote = _body("table_footnote", [_span("SYNTH-NOTE")], bbox=(5.0, 6.0, 7.0, 8.0)) block = _table_block(extra_subs=[caption]) block["blocks"].append(footnote) elements, _, _ = _parse([_page([block])]) assert [(e.type, e.text) for e in elements] == [ ("table", "
SYNTH-CELL
"), ("table_caption", "SYNTH-CAPTION"), ("table_footnote", "SYNTH-NOTE"), ] assert elements[1].bbox == (1.0, 2.0, 3.0, 4.0) assert all(e.page_index == 0 for e in elements) def test_empty_non_body_sub_block_is_not_emitted(self): caption = _body("table_caption", [_span("")]) elements, _, _ = _parse([_page([_table_block(extra_subs=[caption])])]) assert [e.type for e in elements] == ["table"] def test_unknown_type_without_lines_or_blocks_keeps_type_and_empty_text(self): block = {"type": "synthetic_widget", "bbox": list(_BBOX)} elements, _, _ = _parse([_page([block])]) assert (elements[0].type, elements[0].text) == ("synthetic_widget", "") def test_unknown_container_keeps_its_body_text(self): """未知容器类型的 `*_body` 子块文字并入父元素,不被静默丢弃。""" block = { "type": "synthetic_code", "bbox": list(_BBOX), "blocks": [_body("synthetic_code_body", [_span("SYNTH-CODE")])], } elements, _, _ = _parse([_page([block])]) assert (elements[0].type, elements[0].text) == ("synthetic_code", "SYNTH-CODE") class TestPageStructure: @pytest.mark.parametrize( "payload", [ pytest.param({}, id="pdf_info-missing"), pytest.param({"pdf_info": "x"}, id="pdf_info-not-list"), pytest.param({"pdf_info": []}, id="zero-pages"), pytest.param([], id="payload-not-dict"), ], ) def test_document_level_shape(self, payload): """零页被拒: 成功解析的文档至少 1 页(与 layout 路径"elements 空合法"不同)。""" _invalid(middle=json.dumps(payload).encode()) def test_page_not_a_dict(self): _invalid(["not-a-dict"]) @pytest.mark.parametrize( "page_size", [ pytest.param(None, id="missing"), pytest.param([612.0], id="one-element"), pytest.param([612.0, 792.0, 1.0], id="three-elements"), pytest.param([0, 792.0], id="zero-width"), pytest.param([612.0, -1], id="negative-height"), pytest.param([True, 792.0], id="bool"), pytest.param(["612", 792.0], id="string"), pytest.param([10**400, 792.0], id="huge-int"), ], ) def test_page_size_invalid(self, page_size): page = _page([]) if page_size is None: del page["page_size"] else: page["page_size"] = page_size _invalid([page]) @pytest.mark.parametrize( "page_idx", [ pytest.param("missing", id="missing"), pytest.param("0", id="string"), pytest.param(0.0, id="float"), pytest.param(True, id="bool"), pytest.param(-1, id="negative"), pytest.param(3, id="out-of-position"), ], ) def test_page_idx_must_equal_position(self, page_idx): """layout 路径对非法 `page_idx` 回退枚举下标,会让 page_sizes 按 page_index 的索引契约静默错位。""" page = _page([]) if page_idx == "missing": del page["page_idx"] else: page["page_idx"] = page_idx _invalid([page]) def test_second_page_must_not_claim_the_first_index(self): _invalid([_page([], page_idx=0), _page([], page_idx=0)]) def test_para_blocks_missing_is_a_legal_empty_page(self): elements, page_sizes, _ = _parse([_page()]) assert elements == [] and page_sizes == [(612.0, 792.0)] def test_para_blocks_not_a_list_is_rejected(self): _invalid([_page("x")]) class TestBlockStructure: @pytest.mark.parametrize( "block", [ pytest.param("not-a-dict", id="block-not-dict"), pytest.param({"bbox": list(_BBOX)}, id="type-missing"), pytest.param({"type": 7, "bbox": list(_BBOX)}, id="type-not-str"), pytest.param({"type": "", "bbox": list(_BBOX)}, id="type-empty"), pytest.param({"type": " ", "bbox": list(_BBOX)}, id="type-whitespace"), ], ) def test_block_shape(self, block): _invalid([_page([block])]) @pytest.mark.parametrize( "bbox", [ pytest.param(None, id="missing"), pytest.param("1,2,3,4", id="string"), pytest.param([1, 2, 3], id="length-3"), pytest.param([1, 2, 3, 4, 5], id="length-5"), pytest.param([1, 2, "3", 4], id="string-element"), pytest.param([1, 2, True, 4], id="bool-element"), pytest.param([1, 2, float("nan"), 4], id="nan"), pytest.param([1, 2, float("inf"), 4], id="inf"), pytest.param([10, 2, 5, 4], id="x-reversed"), pytest.param([1, 20, 5, 4], id="y-reversed"), pytest.param([0, 0, 10**400, 1], id="huge-int"), ], ) def test_bbox_invalid(self, bbox): block = _text_block() if bbox is None: del block["bbox"] else: block["bbox"] = bbox # NaN/Infinity 不是合法 JSON,但 Python 的 json 模块默认接受并往返——真实服务可能发出 _invalid([_page([block])]) @pytest.mark.parametrize("bbox", [(5, 5, 5, 20), (5, 5, 50, 5), (5, 5, 5, 5)]) def test_zero_width_or_height_boxes_are_legal(self, bbox): """整数化退化校验专为 CHS shim 的 int() 裁剪;文档消费者不裁剪,一个退化框不该让整份失败。""" elements, _, _ = _parse([_page([_text_block(bbox=bbox)])]) assert elements[0].bbox == tuple(float(v) for v in bbox) def test_out_of_page_boxes_are_returned_as_is(self): """越界与否是图内语义,归调用方(ARCHITECTURE §5.1 边界);库不裁剪。""" elements, _, _ = _parse([_page([_text_block(bbox=(0, 0, 9999, 9999))])]) assert elements[0].bbox == (0.0, 0.0, 9999.0, 9999.0) class TestMembers: def test_missing_middle_json(self): buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr("doc.md", "x") with pytest.raises(ResultInvalidError): parse_document_package(buf.getvalue()) def test_missing_paired_markdown(self): _invalid(md=None) def test_markdown_is_paired_by_name_not_first_match(self): elements, _, markdown = parse_document_package( _package( [_page([_text_block("A")])], md="PAIRED", extra=[("aaa_other.md", "DISTRACTOR")], ) ) assert markdown == "PAIRED" def test_unpaired_markdown_alone_is_not_accepted(self): _invalid(md=None, extra=[("other.md", "DISTRACTOR")]) def test_first_middle_json_wins_and_pairs_with_its_own_markdown(self): pages_a = json.dumps({"pdf_info": [_page([_text_block("A")])]}).encode() pages_b = json.dumps({"pdf_info": [_page([_text_block("B")])]}).encode() buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr("a_middle.json", pages_a) zf.writestr("b_middle.json", pages_b) zf.writestr("a.md", "MD-A") zf.writestr("b.md", "MD-B") elements, _, markdown = parse_document_package(buf.getvalue()) assert [e.text for e in elements] == ["A"] and markdown == "MD-A" def test_markdown_bom_is_stripped(self): _, _, markdown = _parse(md=b"\xef\xbb\xbf# synth") assert markdown == "# synth" def test_empty_markdown_is_legal(self): _, _, markdown = _parse(md="") assert markdown == "" def test_non_utf8_markdown_is_rejected(self): _invalid(md=b"\xff\xfe\x00bad") @pytest.mark.parametrize( "middle", [ pytest.param(b"not json at all", id="not-json"), pytest.param(b"", id="empty"), pytest.param(b"\xff\xfe", id="not-utf8"), ], ) def test_middle_json_unparseable(self, middle): _invalid(middle=middle) def test_not_a_zip(self): with pytest.raises(ResultInvalidError): parse_document_package(b"definitely not a zip") def _patch_first_member_compression(zip_bytes: bytes, method: int) -> bytes: """把第一个成员的压缩方法号改成 `method`(本地头偏移 8,中央目录偏移 10)。""" data = bytearray(zip_bytes) local = data.index(b"PK\x03\x04") central = data.index(b"PK\x01\x02") struct.pack_into("