arm-scan-gateway/api/tests/unit/test_normalizer.py
Andrey Epifancev f448992e16 Срез 0 [5.1]: нормализатор ответа ИС (src/normalizer): Normalizer, NormalizeOutcome, NullNormalizer
Ошибка ИС пробрасывается с кодом из ответа, неизвестный код/вид ошибки -> UNKNOWN_ERROR с исходным
представлением, успех -> NOT_IMPLEMENTED, не объект -> INVALID_FORMAT. Пакет добавлен в strict-список mypy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:18:17 +04:00

299 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Нормализатор ответа ИС Сканирование (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": [],
}
)