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