Files
2026-07-13 12:59:42 +08:00

300 lines
12 KiB
Python

"""Tests for PARTIAL_SUCCESS handling in hybrid server responses.
Validates that when Docling encounters errors during PDF preprocessing
(e.g., Invalid code point), the hybrid server correctly reports:
- partial_success status instead of success
- list of failed page numbers
- error messages from Docling
"""
from opendataloader_pdf.hybrid_server import (
_extract_failed_pages_from_errors,
build_conversion_response,
)
class TestBuildConversionResponse:
"""Tests for the build_conversion_response function."""
def test_success_status(self):
"""Fully successful conversion should return status=success."""
response = build_conversion_response(
status_value="success",
json_content={"pages": {"1": {}, "2": {}, "3": {}}},
processing_time=1.5,
errors=[],
requested_pages=None,
)
assert response["status"] == "success"
assert response["failed_pages"] == []
assert response["processing_time"] == 1.5
def test_partial_success_status(self):
"""PARTIAL_SUCCESS should return status=partial_success with failed pages."""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {"1": {}, "2": {}, "4": {}, "5": {}}},
processing_time=2.0,
errors=["Unknown page: pipeline terminated early"],
requested_pages=(1, 5),
)
assert response["status"] == "partial_success"
assert response["failed_pages"] == [3]
assert response["errors"] == ["Unknown page: pipeline terminated early"]
def test_partial_success_multiple_failed_pages(self):
"""Multiple failed pages should all be reported."""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {"1": {}, "3": {}, "5": {}}},
processing_time=3.0,
errors=[
"Unknown page: pipeline terminated early",
"Unknown page: pipeline terminated early",
],
requested_pages=(1, 5),
)
assert response["status"] == "partial_success"
assert sorted(response["failed_pages"]) == [2, 4]
def test_partial_success_no_page_range_with_total_pages(self):
"""When total_pages is provided, boundary page failures are detected."""
# 5-page document, page 1 (first) and page 5 (last) failed
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {"2": {}, "3": {}, "4": {}}},
processing_time=2.0,
errors=["error1", "error2"],
requested_pages=None,
total_pages=5,
)
assert response["status"] == "partial_success"
assert response["failed_pages"] == [1, 5]
def test_partial_success_no_page_range_fallback(self):
"""When no page range or total_pages, interior gaps are still detected."""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {"1": {}, "2": {}, "4": {}, "5": {}}},
processing_time=2.0,
errors=["Unknown page: pipeline terminated early"],
requested_pages=None,
)
assert response["status"] == "partial_success"
assert response["failed_pages"] == [3]
def test_success_no_errors_field(self):
"""Successful conversion should have empty errors list."""
response = build_conversion_response(
status_value="success",
json_content={"pages": {"1": {}, "2": {}}},
processing_time=1.0,
errors=[],
requested_pages=None,
)
assert response["errors"] == []
def test_document_field_present(self):
"""Response should contain document.json_content."""
json_content = {"pages": {"1": {}}, "body": {"text": "hello"}}
response = build_conversion_response(
status_value="success",
json_content=json_content,
processing_time=1.0,
errors=[],
requested_pages=None,
)
assert response["document"]["json_content"] == json_content
def test_partial_success_first_page_failed_with_page_range(self):
"""First page failure should be detected when page range is specified."""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {"2": {}, "3": {}}},
processing_time=1.0,
errors=["error"],
requested_pages=(1, 3),
)
assert response["failed_pages"] == [1]
def test_partial_success_last_page_failed_with_page_range(self):
"""Last page failure should be detected when page range is specified."""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {"1": {}, "2": {}}},
processing_time=1.0,
errors=["error"],
requested_pages=(1, 3),
)
assert response["failed_pages"] == [3]
def test_partial_success_all_pages_failed(self):
"""All pages failing should report every page in failed_pages."""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {}},
processing_time=2.0,
errors=["error1", "error2", "error3"],
requested_pages=(1, 3),
)
assert response["status"] == "partial_success"
assert response["failed_pages"] == [1, 2, 3]
def test_partial_success_all_pages_failed_with_total_pages(self):
"""All pages failing with total_pages should report every page."""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {}},
processing_time=2.0,
errors=["error1", "error2"],
requested_pages=None,
total_pages=3,
)
assert response["status"] == "partial_success"
assert response["failed_pages"] == [1, 2, 3]
def test_failure_status_no_failed_pages_detection(self):
"""Failure status should not trigger failed page detection."""
response = build_conversion_response(
status_value="failure",
json_content={"pages": {"1": {}}},
processing_time=1.0,
errors=["PDF conversion failed"],
requested_pages=(1, 3),
)
assert response["status"] == "failure"
assert response["failed_pages"] == []
def test_partial_success_missing_pages_key(self):
"""json_content without 'pages' key should mark all requested pages as failed."""
response = build_conversion_response(
status_value="partial_success",
json_content={"body": {"text": "hello"}},
processing_time=1.0,
errors=["error"],
requested_pages=(1, 3),
)
assert response["status"] == "partial_success"
assert response["failed_pages"] == [1, 2, 3]
class TestExtractFailedPagesFromErrors:
"""Tests for error message-based failed page detection."""
def test_std_bad_alloc_errors(self):
"""Page numbers should be extracted from 'Page N: std::bad_alloc' messages."""
errors = [
"Page 26: std::bad_alloc",
"Page 27: std::bad_alloc",
"Page 28: std::bad_alloc",
]
assert _extract_failed_pages_from_errors(errors) == [26, 27, 28]
def test_mixed_error_formats(self):
"""Only 'Page N:' prefixed messages should be matched."""
errors = [
"Page 5: Invalid code point",
"Unknown page: pipeline terminated early",
"Page 10: std::bad_alloc",
]
assert _extract_failed_pages_from_errors(errors) == [5, 10]
def test_no_page_errors(self):
"""Non-page errors should return empty list."""
errors = [
"Unknown page: pipeline terminated early",
"General error occurred",
]
assert _extract_failed_pages_from_errors(errors) == []
def test_bare_page_no_colon(self):
"""Bare 'Page N' (no colon) should be matched when error_msg is empty."""
assert _extract_failed_pages_from_errors(["Page 26"]) == [26]
class TestBuildConversionResponseErrorParsing:
"""Tests for failed page detection via error message parsing.
When docling includes failed pages as empty entries in the pages dict,
gap detection fails. Error message parsing handles this case.
"""
def test_failed_pages_with_empty_entries_in_pages_dict(self):
"""Failed pages present as empty entries should still be detected via errors."""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {"1": {}, "2": {}, "3": {}, "4": {}, "5": {}}},
processing_time=2.0,
errors=["Page 4: std::bad_alloc", "Page 5: std::bad_alloc"],
requested_pages=(1, 5),
)
assert response["failed_pages"] == [4, 5]
def test_boundary_pages_detected_via_errors(self):
"""Boundary page failures should be detected even without page range."""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {"1": {}, "2": {}, "3": {}, "4": {}, "5": {}}},
processing_time=2.0,
errors=["Page 4: std::bad_alloc", "Page 5: std::bad_alloc"],
requested_pages=None,
total_pages=None,
)
assert response["failed_pages"] == [4, 5]
def test_both_strategies_combined(self):
"""Union of error-parsed and gap-detected pages should be reported.
Page 2 is missing from dict (gap), pages 4-5 have error messages
but are present as empty entries. All three must appear.
"""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {"1": {}, "3": {}, "4": {}, "5": {}}},
processing_time=2.0,
errors=["Page 4: std::bad_alloc", "Page 5: std::bad_alloc"],
requested_pages=(1, 5),
)
assert response["failed_pages"] == [2, 4, 5]
def test_overlap_between_gap_and_error_is_deduplicated(self):
"""Same page from gap and error parsing should appear once."""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {"1": {}, "3": {}}},
processing_time=1.0,
errors=["Page 2: std::bad_alloc"],
requested_pages=(1, 3),
)
assert response["failed_pages"] == [2]
def test_duplicate_page_in_errors(self):
"""Duplicate page numbers in error messages should be deduplicated."""
errors = [
"Page 3: std::bad_alloc",
"Page 3: Invalid code point",
]
assert _extract_failed_pages_from_errors(errors) == [3]
def test_no_page_pattern_errors_falls_back_to_gap(self):
"""When errors lack 'Page N:' pattern, gap detection should still work."""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {"1": {}, "3": {}}},
processing_time=1.0,
errors=["Unknown page: pipeline terminated early"],
requested_pages=(1, 3),
)
assert response["failed_pages"] == [2]
def test_empty_errors_with_partial_success(self):
"""Partial success with no error messages should still detect gaps."""
response = build_conversion_response(
status_value="partial_success",
json_content={"pages": {"1": {}, "3": {}}},
processing_time=1.0,
errors=[],
requested_pages=(1, 3),
)
assert response["failed_pages"] == [2]