Ошибка ИС пробрасывается с кодом из ответа, неизвестный код/вид ошибки -> UNKNOWN_ERROR с исходным представлением, успех -> NOT_IMPLEMENTED, не объект -> INVALID_FORMAT. Пакет добавлен в strict-список mypy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
299 lines
13 KiB
Python
299 lines
13 KiB
Python
"""Нормализатор ответа ИС Сканирование (D-5, D-7, R-10, R-12, slice-0-skeleton).
|
||
|
||
Ошибки ИС — на копиях реальных `../fixtures/ocr/*.json` (маркер `fixtures`; обе реальные фикстуры — ответы с ошибкой
|
||
INVALID_DOCUMENT, O-1); успех (`NOT_IMPLEMENTED`) — на синтетическом ответе, собранном из `aep_5.json` (R-10).
|
||
"""
|
||
|
||
import copy
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from pydantic import ValidationError
|
||
|
||
from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument
|
||
from src.normalizer import NormalizeOutcome, Normalizer, NullNormalizer
|
||
|
||
API_DIR = Path(__file__).resolve().parents[2]
|
||
# `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как у test_emulator.py
|
||
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
|
||
|
||
NOT_IMPLEMENTED_MESSAGE = "нормализация ответа ИС Сканирование реализуется в Срезе 3"
|
||
NOT_OBJECT_MESSAGE = "ответ ИС не является JSON-объектом"
|
||
|
||
|
||
def _real(name: str) -> dict[str, Any]:
|
||
if not REAL_FIXTURES.is_dir():
|
||
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
|
||
loaded: dict[str, Any] = json.loads((REAL_FIXTURES / name).read_text(encoding="utf-8"))
|
||
return loaded
|
||
|
||
|
||
def _success_response() -> dict[str, Any]:
|
||
"""Синтетический успех ИС: форма `aep_5.json` без ошибки и с заполненными label и строками таблицы."""
|
||
return {
|
||
"file_uid": "aep_5",
|
||
"error": None,
|
||
"data_ocr": {
|
||
"label": {"form_name": "Бухгалтерский баланс", "confidence": 98.5},
|
||
"organization": {"full_name": "ООО «Русский бисквит»", "confidence": 97.0},
|
||
"inn": {"inn": "7701234567", "confidence": 99.0},
|
||
"units": {"measurement": "тыс. руб.", "confidence": 95.0},
|
||
"date": {"value": "30 сентября 2025 г.", "confidence": 96.0},
|
||
"table_structure": {
|
||
"rows": [
|
||
{
|
||
"row_index": 0,
|
||
"cells": [
|
||
{"column_index": 0, "value": "Наименование показателя", "confidence": 99.0},
|
||
{"column_index": 1, "value": "Код", "confidence": 99.0},
|
||
{"column_index": 2, "value": "На 30 сентября 2025 г.", "confidence": 98.0},
|
||
],
|
||
},
|
||
{
|
||
"row_index": 1,
|
||
"cells": [
|
||
{"column_index": 0, "value": "Основные средства", "confidence": 97.0},
|
||
{"column_index": 1, "value": "1150", "confidence": 99.0},
|
||
{"column_index": 2, "value": "1 234", "confidence": 90.0},
|
||
],
|
||
},
|
||
]
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def _assert_error(outcome: NormalizeOutcome, code: ErrorCode, message: str | None = None) -> None:
|
||
assert outcome.status is ResultStatus.ERROR
|
||
assert outcome.error is not None
|
||
assert outcome.error.code is code
|
||
if message is not None:
|
||
assert outcome.error.message == message
|
||
assert outcome.documents == []
|
||
assert outcome.metrics is None
|
||
|
||
|
||
@pytest.mark.fixtures
|
||
class TestRealFixtures:
|
||
@pytest.mark.parametrize("name", ["aep_5.json", "aep_8_form4.json"])
|
||
def test_is_error_passes_through(self, name: str) -> None:
|
||
raw = _real(name)
|
||
assert raw["error"] is not None # O-1: обе реальные фикстуры — ответы с ошибкой
|
||
outcome = NullNormalizer().normalize(raw)
|
||
_assert_error(outcome, ErrorCode.INVALID_DOCUMENT, raw["error"]["message"])
|
||
assert outcome.error is not None
|
||
assert outcome.error.message == "Не удалось определить тип документа."
|
||
|
||
|
||
class TestIsError:
|
||
def test_known_code_and_message_verbatim(self) -> None:
|
||
raw = {"file_uid": "x", "error": {"code": "FILE_READ_ERROR", "message": "Файл повреждён: стр. 3 «пусто»"}}
|
||
_assert_error(NullNormalizer().normalize(raw), ErrorCode.FILE_READ_ERROR, "Файл повреждён: стр. 3 «пусто»")
|
||
|
||
@pytest.mark.parametrize("code", [c.value for c in ErrorCode])
|
||
def test_every_contract_code_passes(self, code: str) -> None:
|
||
raw = {"error": {"code": code, "message": "сообщение ИС"}}
|
||
_assert_error(NullNormalizer().normalize(raw), ErrorCode(code), "сообщение ИС")
|
||
|
||
def test_unknown_code_becomes_unknown_error_keeping_original(self) -> None:
|
||
raw = {"error": {"code": "QUOTA_EXCEEDED", "message": "Превышена квота"}}
|
||
outcome = NullNormalizer().normalize(raw)
|
||
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
|
||
assert outcome.error is not None
|
||
assert "QUOTA_EXCEEDED" in outcome.error.message
|
||
assert "Превышена квота" in outcome.error.message
|
||
|
||
def test_lowercase_code_is_unknown(self) -> None:
|
||
outcome = NullNormalizer().normalize({"error": {"code": "invalid_document", "message": "m"}})
|
||
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
|
||
assert outcome.error is not None
|
||
assert "invalid_document" in outcome.error.message
|
||
|
||
@pytest.mark.parametrize("error", ["Что-то сломалось", ["a", "b"], 42, True, {}, 0, ""])
|
||
def test_error_not_an_object_or_empty(self, error: object) -> None:
|
||
outcome = NullNormalizer().normalize({"error": error})
|
||
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
|
||
assert outcome.error is not None
|
||
assert repr(error) in outcome.error.message
|
||
|
||
def test_error_string_message_kept(self) -> None:
|
||
outcome = NullNormalizer().normalize({"error": "Что-то сломалось"})
|
||
assert outcome.error is not None
|
||
assert "Что-то сломалось" in outcome.error.message
|
||
|
||
def test_missing_code(self) -> None:
|
||
outcome = NullNormalizer().normalize({"error": {"message": "Нет кода"}})
|
||
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
|
||
assert outcome.error is not None
|
||
assert "Нет кода" in outcome.error.message
|
||
|
||
@pytest.mark.parametrize("code", [None, 5, ["INVALID_DOCUMENT"], {"a": 1}])
|
||
def test_code_not_a_string(self, code: object) -> None:
|
||
outcome = NullNormalizer().normalize({"error": {"code": code, "message": "m-text"}})
|
||
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
|
||
assert outcome.error is not None
|
||
assert repr(code) in outcome.error.message
|
||
assert "m-text" in outcome.error.message
|
||
|
||
@pytest.mark.parametrize("error", [{"code": "INVALID_DOCUMENT"}, {"code": "INVALID_DOCUMENT", "message": None}])
|
||
def test_missing_message_gets_default(self, error: dict[str, Any]) -> None:
|
||
outcome = NullNormalizer().normalize({"error": error})
|
||
_assert_error(outcome, ErrorCode.INVALID_DOCUMENT)
|
||
assert outcome.error is not None
|
||
assert outcome.error.message
|
||
assert "INVALID_DOCUMENT" in outcome.error.message
|
||
|
||
def test_empty_message_gets_default(self) -> None:
|
||
outcome = NullNormalizer().normalize({"error": {"code": "UNREADABLE", "message": ""}})
|
||
_assert_error(outcome, ErrorCode.UNREADABLE)
|
||
assert outcome.error is not None
|
||
assert outcome.error.message
|
||
|
||
def test_message_not_a_string_is_not_lost(self) -> None:
|
||
outcome = NullNormalizer().normalize({"error": {"code": "UNREADABLE", "message": {"detail": "x"}}})
|
||
_assert_error(outcome, ErrorCode.UNREADABLE)
|
||
assert outcome.error is not None
|
||
assert "{'detail': 'x'}" in outcome.error.message
|
||
|
||
def test_unknown_code_and_no_message(self) -> None:
|
||
outcome = NullNormalizer().normalize({"error": {"code": "WEIRD"}})
|
||
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
|
||
assert outcome.error is not None
|
||
assert "WEIRD" in outcome.error.message
|
||
|
||
def test_error_wins_over_data(self) -> None:
|
||
raw = _success_response()
|
||
raw["error"] = {"code": "RECOGNITION_FAILED", "message": "сбой"}
|
||
_assert_error(NullNormalizer().normalize(raw), ErrorCode.RECOGNITION_FAILED, "сбой")
|
||
|
||
def test_input_is_not_mutated(self) -> None:
|
||
raw = {"error": {"code": "WEIRD", "message": "m"}}
|
||
before = copy.deepcopy(raw)
|
||
NullNormalizer().normalize(raw)
|
||
assert raw == before
|
||
|
||
|
||
class TestSuccessIsNotImplemented:
|
||
def test_error_null(self) -> None:
|
||
outcome = NullNormalizer().normalize(_success_response())
|
||
_assert_error(outcome, ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE)
|
||
|
||
def test_error_key_absent(self) -> None:
|
||
raw = _success_response()
|
||
del raw["error"]
|
||
_assert_error(NullNormalizer().normalize(raw), ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE)
|
||
|
||
def test_minimal_object(self) -> None:
|
||
_assert_error(NullNormalizer().normalize({}), ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE)
|
||
|
||
@pytest.mark.fixtures
|
||
def test_real_fixture_with_error_removed(self) -> None:
|
||
raw = _real("aep_5.json")
|
||
raw["error"] = None
|
||
_assert_error(NullNormalizer().normalize(raw), ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE)
|
||
|
||
|
||
class TestNotAnObject:
|
||
@pytest.mark.parametrize("raw", [[], [{"error": None}], "строка", "", None, 0, 42, 1.5, True])
|
||
def test_invalid_format(self, raw: object) -> None:
|
||
_assert_error(NullNormalizer().normalize(raw), ErrorCode.INVALID_FORMAT, NOT_OBJECT_MESSAGE)
|
||
|
||
|
||
class TestNormalizeOutcome:
|
||
def test_error_outcome(self) -> None:
|
||
outcome = NormalizeOutcome(status="error", error=MessageError(code=ErrorCode.UNREADABLE, message="m"))
|
||
assert outcome.status is ResultStatus.ERROR
|
||
assert outcome.documents == []
|
||
assert outcome.metrics is None
|
||
|
||
def test_error_requires_error(self) -> None:
|
||
with pytest.raises(ValidationError):
|
||
NormalizeOutcome(status="error")
|
||
|
||
@pytest.mark.parametrize("status", ["done", "needs_check"])
|
||
def test_success_forbids_error(self, status: str) -> None:
|
||
with pytest.raises(ValidationError):
|
||
NormalizeOutcome(
|
||
status=status,
|
||
documents=[_statement()],
|
||
error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
|
||
)
|
||
|
||
@pytest.mark.parametrize("status", ["done", "needs_check"])
|
||
def test_success_requires_documents(self, status: str) -> None:
|
||
with pytest.raises(ValidationError):
|
||
NormalizeOutcome(status=status)
|
||
|
||
@pytest.mark.parametrize("status", ["done", "needs_check"])
|
||
def test_success_outcome(self, status: str) -> None:
|
||
metrics = Metrics(pages=2, rows=10)
|
||
outcome = NormalizeOutcome(status=status, documents=[_statement()], metrics=metrics)
|
||
assert outcome.status is ResultStatus(status)
|
||
assert outcome.error is None
|
||
assert outcome.metrics == metrics
|
||
assert len(outcome.documents) == 1
|
||
|
||
def test_error_forbids_documents(self) -> None:
|
||
with pytest.raises(ValidationError):
|
||
NormalizeOutcome(
|
||
status="error",
|
||
documents=[_statement()],
|
||
error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
|
||
)
|
||
|
||
def test_error_may_carry_metrics(self) -> None:
|
||
outcome = NormalizeOutcome(
|
||
status="error",
|
||
error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
|
||
metrics=Metrics(pages=1),
|
||
)
|
||
assert outcome.metrics == Metrics(pages=1)
|
||
|
||
@pytest.mark.parametrize("status", ["processing", "ok", "", None])
|
||
def test_status_restricted(self, status: object) -> None:
|
||
with pytest.raises(ValidationError):
|
||
NormalizeOutcome(status=status, error=MessageError(code=ErrorCode.UNREADABLE, message="m"))
|
||
|
||
def test_frozen(self) -> None:
|
||
outcome = NormalizeOutcome(status="error", error=MessageError(code=ErrorCode.UNREADABLE, message="m"))
|
||
with pytest.raises(ValidationError):
|
||
outcome.status = ResultStatus.DONE
|
||
|
||
def test_unknown_field_forbidden(self) -> None:
|
||
with pytest.raises(ValidationError):
|
||
NormalizeOutcome(
|
||
status="error",
|
||
error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
|
||
extra_field=1,
|
||
)
|
||
|
||
|
||
class TestProtocol:
|
||
def test_null_normalizer_is_normalizer(self) -> None:
|
||
assert isinstance(NullNormalizer(), Normalizer)
|
||
|
||
def test_object_without_normalize_is_not(self) -> None:
|
||
assert not isinstance(object(), Normalizer)
|
||
|
||
def test_custom_implementation_is_accepted(self) -> None:
|
||
class Custom:
|
||
def normalize(self, raw: object) -> NormalizeOutcome:
|
||
return NormalizeOutcome(status="done", documents=[_statement()])
|
||
|
||
normalizer: Normalizer = Custom()
|
||
assert normalizer.normalize({}).status is ResultStatus.DONE
|
||
|
||
|
||
def _statement() -> StatementDocument:
|
||
return StatementDocument.model_validate(
|
||
{
|
||
"document_type": "BALANCE",
|
||
"locator": "pages:1-2",
|
||
"header": {},
|
||
"columns": [],
|
||
"lines": [],
|
||
}
|
||
)
|