arm-core/api/tests/test_contract.py
Andrey Epifancev 17141c0a11 feat(contract): копии контракта обоих MAJOR, модели v2.0, приём конвертов 1.x/2.0, предупреждения файла структурами
Копии api/contracts/ побайтно равны docs/contracts/ (тест идентичности плана 0): схемы v2.0 основные, .v1 рядом,
examples/ — v2.0, examples/v1/ — v1.0. Модели result_v2.py и RecognitionResultMessageV2, versions.py выбирает модель
по MAJOR. Приём конвертов принимает оба MAJOR; metrics.warnings в recognition_job пишется структурами (строки 1.x
оборачиваются в LEGACY_WARNING). JobBrief.warnings отдаёт структуры, мусор пропускается, БД не переписывается.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 19:26:50 +04:00

970 lines
42 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.

"""Контракт обмена с сервисами распознавания, MAJOR 1 и 2 (spec recognition-job-exchange, design D-6).
Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/` по правилу MAJOR (S-53);
каждый пример проходит и JSON Schema, и pydantic-модель (с round-trip): `examples/*.json` — схемы и модели v2.0,
`examples/v1/*.json` — схемы `.v1` и модели v1; модели и схемы выносят один и тот же вердикт на негативных кейсах.
"""
import copy
import json
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
import pytest
from jsonschema import Draft202012Validator, FormatChecker
from pydantic import ValidationError
from referencing import Registry, Resource
from scripts.dbml_parity import default_dbml_path
from src.contract import (
CONTRACT_VERSION,
NATS_MSG_ID_HEADER,
RESULT_SUBJECT,
ContractModel,
MessageStatus,
RecognitionRequest,
RecognitionResult,
RecognitionResultMessage,
RecognitionResultMessageV2,
RecognitionResultV2,
ack_msg_id,
request_msg_id,
request_subject,
result_msg_id,
)
from src.contract.versions import major, parse_message, parse_result
from tests.contract_identity import identity_problems
CONTRACTS_DIR = Path(__file__).resolve().parents[1] / "contracts"
EXAMPLES_DIR = CONTRACTS_DIR / "examples"
V1_EXAMPLES_DIR = EXAMPLES_DIR / "v1"
# Таблица согласованности ниже — про MAJOR 1: схемы `.v1` и модели v1; MAJOR 2 — отдельные тесты в конце файла.
SCHEMA_FILES = {
"request": "recognition-request.schema.json",
"message": "recognition-result-message.v1.schema.json",
"result": "recognition-result.v1.schema.json",
}
SCHEMA_FILES_V2 = {
"message": "recognition-result-message.schema.json",
"result": "recognition-result.schema.json",
}
MODELS: dict[str, type[ContractModel]] = {
"request": RecognitionRequest,
"message": RecognitionResultMessage,
"result": RecognitionResult,
}
MODELS_V2: dict[str, type[ContractModel]] = {
"message": RecognitionResultMessageV2,
"result": RecognitionResultV2,
}
EXAMPLE_KIND = {
"request-image.json": "request",
"request-tabular.json": "request",
"result-message-done.json": "message",
"result-message-error.json": "message",
"result-message-processing.json": "message",
"result-error.json": "result",
"result-osv-minimal.json": "result",
"result-osv-needs-check.json": "result",
"result-statement-minimal.json": "result",
"result-statement-excel.json": "result",
}
EXAMPLES_COUNT = 10
def _load(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def _example(name: str) -> dict[str, Any]:
"""Пример MAJOR 1: запросы лежат в `examples/`, результаты и конверты v1 — в `examples/v1/`."""
data = _load((EXAMPLES_DIR if name.startswith("request-") else V1_EXAMPLES_DIR) / name)
assert isinstance(data, dict)
return data
def _example_v2(name: str) -> dict[str, Any]:
data = _load(EXAMPLES_DIR / name)
assert isinstance(data, dict)
return data
def _build_validators(schema_files: dict[str, str] = SCHEMA_FILES) -> dict[str, Draft202012Validator]:
"""Валидаторы по схемам; `$ref` между схемами разрешаются локальным реестром (без сети)."""
schemas = {kind: _load(CONTRACTS_DIR / file_name) for kind, file_name in schema_files.items()}
registry: Registry = Registry()
for schema in schemas.values():
registry = registry.with_resource(schema["$id"], Resource.from_contents(schema))
return {
kind: Draft202012Validator(schema, registry=registry, format_checker=FormatChecker())
for kind, schema in schemas.items()
}
VALIDATORS = _build_validators()
VALIDATORS_V2 = _build_validators(SCHEMA_FILES_V2)
def _schema_ok(kind: str, payload: dict[str, Any]) -> bool:
return VALIDATORS[kind].is_valid(payload)
def _model_ok(kind: str, payload: dict[str, Any]) -> bool:
try:
MODELS[kind].model_validate(payload)
except ValidationError:
return False
return True
# --- идентичность вендоренных копий -------------------------------------------------------------------------------
def _docs_contracts_dir() -> Path:
"""`docs/contracts` рядом с `arm-core` или из `ARM_KA_DOCS` (тот же поиск, что у DBML)."""
return default_dbml_path().parents[1] / "contracts"
def test_vendored_contracts_are_identical_to_docs() -> None:
docs_dir = _docs_contracts_dir()
if not docs_dir.is_dir():
pytest.skip(
"docs недоступен: тест идентичности контракта пропущен (задайте ARM_KA_DOCS или положите docs рядом)"
)
problems = identity_problems(CONTRACTS_DIR, docs_dir)
assert problems == [], "\n".join(problems)
def test_example_table_covers_all_example_files() -> None:
v1_files = {p.name for p in V1_EXAMPLES_DIR.glob("*.json")}
requests = {p.name for p in EXAMPLES_DIR.glob("request-*.json")}
assert v1_files | requests == set(EXAMPLE_KIND)
assert len(v1_files | requests) == EXAMPLES_COUNT
# v2.0: те же имена в `examples/`
assert {p.name for p in EXAMPLES_DIR.glob("*.json")} == set(EXAMPLE_KIND)
# --- примеры: JSON Schema, модель, round-trip ---------------------------------------------------------------------
@pytest.mark.parametrize("name", sorted(EXAMPLE_KIND))
def test_example_passes_json_schema(name: str) -> None:
VALIDATORS[EXAMPLE_KIND[name]].validate(_example(name))
@pytest.mark.parametrize("name", sorted(EXAMPLE_KIND))
def test_example_passes_model_and_round_trips(name: str) -> None:
kind = EXAMPLE_KIND[name]
original = _example(name)
model = MODELS[kind].model_validate(original)
dumped = model.model_dump(mode="json", exclude_unset=True)
# Ничего не потеряно и не изменено (в том числе суммы-строки: «1100», «-12.30», «0.00»)
assert dumped == original
VALIDATORS[kind].validate(dumped)
# to_wire() — то, что уйдёт в NATS/S3: тоже проходит схему и совпадает с исходным примером
wire = model.to_wire()
assert wire == original
VALIDATORS[kind].validate(wire)
# повторный разбор даёт равную модель
assert MODELS[kind].model_validate(wire) == model
def test_result_amounts_are_decimal_in_model_and_strings_on_the_wire() -> None:
result = RecognitionResult.model_validate(_example("result-statement-minimal.json"))
values = result.documents[0].lines[1].values # type: ignore[union-attr]
assert values[0].amount == Decimal("5678.00")
assert values[1].amount == Decimal("-12.30")
assert isinstance(values[1].amount, Decimal)
wire = result.to_wire()
assert wire["documents"][0]["lines"][1]["values"][1]["amount"] == "-12.30"
assert wire["documents"][0]["lines"][0]["values"][1]["amount"] == "1100" # без «.00» — как пришло
def test_osv_amount_keeps_kopecks() -> None:
payload = _example("result-osv-minimal.json")
payload["documents"][0]["rows"][0]["closing_credit"] = "106311116.46"
result = RecognitionResult.model_validate(payload)
assert result.documents[0].rows[0].closing_credit == Decimal("106311116.46") # type: ignore[union-attr]
wire = result.to_wire()
assert wire["documents"][0]["rows"][0]["closing_credit"] == "106311116.46"
VALIDATORS["result"].validate(wire)
def test_models_expose_json_schema() -> None:
for model in MODELS.values():
assert model.model_json_schema()["type"] == "object"
# --- согласованность моделей и схем -------------------------------------------------------------------------------
def _set(path: str, value: Any) -> Callable[[dict[str, Any]], None]:
"""Мутация: положить `value` по пути `a.b.0.c` (числа — индексы списков)."""
def apply(payload: dict[str, Any]) -> None:
*parents, last = path.split(".")
node: Any = payload
for part in parents:
node = node[int(part)] if part.isdigit() else node[part]
if isinstance(node, list):
node[int(last)] = value
else:
node[last] = value
return apply
def _drop(path: str) -> Callable[[dict[str, Any]], None]:
def apply(payload: dict[str, Any]) -> None:
*parents, last = path.split(".")
node: Any = payload
for part in parents:
node = node[int(part)] if part.isdigit() else node[part]
del node[last]
return apply
# (идентификатор, пример-основа, мутация, ожидаемый вердикт «валидно»)
CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [
# --- запрос ---
("request/base", "request-tabular.json", lambda p: None, True),
("request/no-job_id", "request-tabular.json", _drop("job_id"), False),
("request/no-attempt", "request-tabular.json", _drop("attempt"), False),
("request/attempt-0", "request-tabular.json", _set("attempt", 0), False),
("request/attempt-2", "request-tabular.json", _set("attempt", 2), True),
("request/attempt-string", "request-tabular.json", _set("attempt", "1"), False),
("request/attempt-float", "request-tabular.json", _set("attempt", 1.5), False),
("request/attempt-1.0", "request-tabular.json", _set("attempt", 1.0), True),
("request/attempt-0.0", "request-tabular.json", _set("attempt", 0.0), False),
("request/attempt-nan", "request-tabular.json", _set("attempt", float("nan")), False),
("request/attempt-bool", "request-tabular.json", _set("attempt", True), False),
("request/pipeline-unknown", "request-tabular.json", _set("pipeline", "ocr"), False),
("request/file_type-unknown", "request-tabular.json", _set("file_type", "DOCX"), False),
("request/schema_version-2.0", "request-tabular.json", _set("schema_version", "2.0"), False),
("request/schema_version-1.7", "request-tabular.json", _set("schema_version", "1.7"), True),
("request/schema_version-1", "request-tabular.json", _set("schema_version", "1"), False),
("request/schema_version-1.0.1", "request-tabular.json", _set("schema_version", "1.0.1"), False),
("request/job_id-not-uuid", "request-tabular.json", _set("job_id", "not-a-uuid"), False),
(
"request/job_id-uuid-without-dashes",
"request-tabular.json",
_set("job_id", "3f2a9c1e7b4d4e0a9d512c8e6a1b0f01"),
False,
),
("request/file_id-number", "request-tabular.json", _set("file_id", 12345), False),
("request/original_name-empty", "request-tabular.json", _set("original_name", ""), False),
("request/s3-no-key", "request-tabular.json", _drop("s3.key"), False),
("request/s3-empty-bucket", "request-tabular.json", _set("s3.bucket", ""), False),
("request/s3-null", "request-tabular.json", _set("s3", None), False),
("request/extra-field", "request-tabular.json", _set("trace_id", "abc"), True),
("request/extra-field-in-s3", "request-tabular.json", _set("s3.version_id", "v1"), True),
# --- конверт ---
("message/processing", "result-message-processing.json", lambda p: None, True),
("message/done", "result-message-done.json", lambda p: None, True),
("message/status-unknown", "result-message-done.json", _set("status", "queued"), False),
("message/no-service", "result-message-processing.json", _drop("service"), False),
("message/service-unknown", "result-message-processing.json", _set("service.name", "other-service"), False),
("message/schema_version-2.0", "result-message-processing.json", _set("schema_version", "2.0"), False),
("message/schema_version-1.7", "result-message-processing.json", _set("schema_version", "1.7"), True),
("message/done-no-result_s3", "result-message-done.json", _drop("result_s3"), False),
("message/done-no-result_schema_version", "result-message-done.json", _drop("result_schema_version"), False),
("message/needs_check-ok", "result-message-done.json", _set("status", "needs_check"), True),
(
"message/needs_check-no-result_s3",
"result-message-done.json",
lambda p: (p.pop("result_s3"), p.update(status="needs_check")),
False,
),
("message/result_s3-no-key", "result-message-done.json", _drop("result_s3.key"), False),
("message/result_s3-null", "result-message-done.json", _set("result_s3", None), False),
("message/result_schema_version-2.0", "result-message-done.json", _set("result_schema_version", "2.0"), True),
("message/result_schema_version-bad", "result-message-done.json", _set("result_schema_version", "1"), False),
("message/error-ok", "result-message-error.json", lambda p: None, True),
("message/error-no-error", "result-message-error.json", _drop("error"), False),
("message/error-null", "result-message-error.json", _set("error", None), False),
("message/error-unknown-code", "result-message-error.json", _set("error.code", "BOOM"), False),
("message/error-no-message", "result-message-error.json", _drop("error.message"), False),
("message/processing-no-result_s3", "result-message-processing.json", lambda p: None, True),
(
"message/done-with-error-block",
"result-message-done.json",
_set("error", {"code": "TIMEOUT", "message": "x"}),
True,
),
("message/raw_s3_key-null", "result-message-done.json", _set("raw_s3_key", None), True),
("message/metrics-warnings-not-strings", "result-message-done.json", _set("metrics.warnings", [1]), False),
("message/metrics-duration-float", "result-message-done.json", _set("metrics.duration_ms", 1.5), False),
("message/metrics-duration-1.0", "result-message-done.json", _set("metrics.duration_ms", 18400.0), True),
("message/metrics-pages-bool", "result-message-done.json", _set("metrics.pages", True), False),
("message/metrics-extra-field", "result-message-done.json", _set("metrics.cpu", 3), True),
("message/metrics-rows", "result-message-done.json", _set("metrics.rows", 10), True),
("message/extra-field", "result-message-done.json", _set("trace_id", "abc"), True),
# --- файл результата ---
("result/osv-base", "result-osv-minimal.json", lambda p: None, True),
("result/statement-base", "result-statement-minimal.json", lambda p: None, True),
("result/error-base", "result-error.json", lambda p: None, True),
(
"result/amount-float",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.amount", 1234.5),
False,
),
("result/amount-int", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", 1234), False),
("result/amount-null", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", None), True),
(
"result/amount-3-decimals",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.amount", "1.234"),
False,
),
("result/amount-comma", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", "1,5"), False),
(
"result/amount-negative",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.amount", "-0.5"),
True,
),
(
"result/amount-exponent",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.amount", "1e3"),
False,
),
("result/amount-empty", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", ""), False),
("result/osv-amount-float", "result-osv-minimal.json", _set("documents.0.rows.0.opening_credit", 1000.0), False),
("result/osv-amount-missing", "result-osv-minimal.json", _drop("documents.0.rows.0.opening_credit"), True),
("result/done-empty-documents", "result-osv-minimal.json", _set("documents", []), False),
(
"result/needs_check-empty-documents",
"result-osv-minimal.json",
lambda p: p.update(status="needs_check", documents=[]),
False,
),
("result/error-empty-documents", "result-error.json", lambda p: None, True),
("result/no-documents", "result-error.json", _drop("documents"), False),
("result/status-processing", "result-osv-minimal.json", _set("status", "processing"), False),
("result/error-null", "result-error.json", _set("error", None), True),
("result/error-empty-object", "result-error.json", _set("error", {}), True),
("result/error-bad-code", "result-error.json", _set("error.code", "BOOM"), False),
("result/schema_version-2.0", "result-osv-minimal.json", _set("schema_version", "2.0"), False),
("result/schema_version-1.3", "result-osv-minimal.json", _set("schema_version", "1.3"), True),
("result/document_type-unknown", "result-osv-minimal.json", _set("documents.0.document_type", "MEMO"), False),
("result/document-no-document_type", "result-osv-minimal.json", _drop("documents.0.document_type"), False),
("result/osv-no-rows", "result-osv-minimal.json", _drop("documents.0.rows"), False),
("result/osv-no-group_schema", "result-osv-minimal.json", _drop("documents.0.group_schema"), False),
("result/osv-no-header-period_text", "result-osv-minimal.json", _drop("documents.0.header.period_text"), False),
("result/osv-header_variant-3", "result-osv-minimal.json", _set("documents.0.header.header_variant", 3), False),
("result/osv-period_quarter-Q5", "result-osv-minimal.json", _set("documents.0.header.period_quarter", "Q5"), False),
(
"result/osv-period_quarter-null",
"result-osv-minimal.json",
_set("documents.0.header.period_quarter", None),
True,
),
(
"result/osv-header-null-for-non-nullable",
"result-osv-minimal.json",
_set("documents.0.header.header_variant", None),
False,
),
(
"result/osv-row-group_type-unknown",
"result-osv-minimal.json",
_set("documents.0.rows.0.group_type", "FOLDER"),
False,
),
(
"result/osv-row-group_type-TOTAL",
"result-osv-minimal.json",
_set("documents.0.rows.0.group_type", "TOTAL"),
True,
),
("result/osv-row-no-row_number", "result-osv-minimal.json", _drop("documents.0.rows.0.row_number"), False),
("result/osv-level-1.0", "result-osv-minimal.json", _set("documents.0.rows.0.level", 1.0), True),
("result/osv-level-1.5", "result-osv-minimal.json", _set("documents.0.rows.0.level", 1.5), False),
("result/osv-level-bool", "result-osv-minimal.json", _set("documents.0.rows.0.level", True), False),
("result/osv-level-string", "result-osv-minimal.json", _set("documents.0.rows.0.level", "1"), False),
("result/osv-row_number-2.0", "result-osv-minimal.json", _set("documents.0.rows.0.row_number", 2.0), True),
(
"result/osv-parent_row_number-1.0",
"result-osv-minimal.json",
_set("documents.0.rows.1.parent_row_number", 1.0),
True,
),
(
"result/osv-parent_row_number-1.5",
"result-osv-minimal.json",
_set("documents.0.rows.1.parent_row_number", 1.5),
False,
),
("result/osv-period_year-2025.0", "result-osv-minimal.json", _set("documents.0.header.period_year", 2025.0), True),
(
"result/osv-period_year-nan",
"result-osv-minimal.json",
_set("documents.0.header.period_year", float("nan")),
False,
),
("result/osv-header_variant-2.0", "result-osv-minimal.json", _set("documents.0.header.header_variant", 2.0), True),
(
"result/osv-header_variant-true",
"result-osv-minimal.json",
_set("documents.0.header.header_variant", True),
False,
),
("result/osv-header_variant-1.5", "result-osv-minimal.json", _set("documents.0.header.header_variant", 1.5), False),
(
"result/osv-violations-1.0",
"result-osv-minimal.json",
_set("documents.0.checks.parent_sum_violations", [3.0]),
True,
),
(
"result/osv-violations-1.5",
"result-osv-minimal.json",
_set("documents.0.checks.parent_sum_violations", [3.5]),
False,
),
(
"result/statement-column_index-1.0",
"result-statement-minimal.json",
_set("documents.0.columns.0.column_index", 1.0),
True,
),
(
"result/statement-column_index-1.5",
"result-statement-minimal.json",
_set("documents.0.columns.0.column_index", 1.5),
False,
),
(
"result/statement-value-column_index-0.0",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.column_index", 0.0),
True,
),
(
"result/statement-value-column_index-bool",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.column_index", False),
False,
),
("result/osv-row-level-float", "result-osv-minimal.json", _set("documents.0.rows.0.level", 0.5), False),
("result/osv-columns-non-string", "result-osv-minimal.json", _set("documents.0.columns.opening_debit", 3), False),
("result/osv-columns-extra-key", "result-osv-minimal.json", _set("documents.0.columns.inn", "B"), True),
(
"result/osv-group_schema-bad-type",
"result-osv-minimal.json",
_set("documents.0.group_schema.0.type", "FOLDER"),
False,
),
(
"result/osv-checks-violations-not-ints",
"result-osv-minimal.json",
_set("documents.0.checks.parent_sum_violations", ["a"]),
False,
),
("result/osv-extra-field", "result-osv-minimal.json", _set("documents.0.debug", {"a": 1}), True),
("result/statement-no-lines", "result-statement-minimal.json", _drop("documents.0.lines"), False),
("result/statement-no-columns", "result-statement-minimal.json", _drop("documents.0.columns"), False),
(
"result/statement-type-INCOME",
"result-statement-minimal.json",
_set("documents.0.document_type", "INCOME"),
True,
),
(
"result/statement-type-OSV-with-statement-body",
"result-statement-minimal.json",
_set("documents.0.document_type", "OSV"),
False,
),
(
"result/statement-confidence-101",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.confidence", 101),
False,
),
(
"result/statement-confidence-negative",
"result-statement-minimal.json",
_set("documents.0.lines.0.line_code_confidence", -1),
False,
),
(
"result/statement-confidence-null",
"result-statement-minimal.json",
_set("documents.0.lines.0.line_code_confidence", None),
True,
),
(
"result/statement-confidence-bool",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.confidence", True),
False,
),
(
"result/statement-confidence-100.0",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.confidence", 100.0),
True,
),
(
"result/statement-report_date-bad",
"result-statement-minimal.json",
_set("documents.0.header.report_date", "2025-13-45"),
False,
),
(
"result/statement-report_date-null",
"result-statement-minimal.json",
_set("documents.0.header.report_date", None),
True,
),
(
"result/statement-value-no-column_index",
"result-statement-minimal.json",
_drop("documents.0.lines.0.values.0.column_index"),
False,
),
(
"result/statement-is_recognized-string",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.is_recognized", "yes"),
False,
),
(
"result/statement-string_value-null",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.string_value", None),
True,
),
(
"result/statement-period_date-bad",
"result-statement-minimal.json",
_set("documents.0.columns.0.period_date", "30.09.2025"),
False,
),
("result/statement-extra-field", "result-statement-minimal.json", _set("documents.0.header.fax", "1"), True),
]
@pytest.mark.parametrize(("case_id", "example", "mutate", "expected_valid"), CASES, ids=[c[0] for c in CASES])
def test_model_and_schema_agree(
case_id: str, example: str, mutate: Callable[[dict[str, Any]], None], expected_valid: bool
) -> None:
kind = EXAMPLE_KIND[example]
payload = copy.deepcopy(_example(example))
mutate(payload)
schema_verdict = _schema_ok(kind, payload)
model_verdict = _model_ok(kind, payload)
assert schema_verdict == expected_valid, (
f"{case_id}: JSON Schema вынесла вердикт {schema_verdict}, ожидали {expected_valid}"
)
assert model_verdict == schema_verdict, (
f"{case_id}: модель {'приняла' if model_verdict else 'отвергла'}, схема "
f"{'приняла' if schema_verdict else 'отвергла'} — расхождение модели и схемы"
)
def test_consistency_table_covers_every_kind() -> None:
assert {EXAMPLE_KIND[c[1]] for c in CASES} == set(MODELS)
# --- то, что модель проверяет строже валидатора схемы без дополнительных библиотек ---------------------------------
def test_reply_deadline_must_be_aware_and_is_normalized_to_utc() -> None:
naive = _example("request-tabular.json")
naive["reply_deadline"] = "2026-10-03T12:30:00"
with pytest.raises(ValidationError):
RecognitionRequest.model_validate(naive)
shifted = _example("request-tabular.json")
shifted["reply_deadline"] = "2026-10-03T15:30:00+03:00"
request = RecognitionRequest.model_validate(shifted)
assert request.reply_deadline == datetime(2026, 10, 3, 12, 30, tzinfo=UTC)
assert request.reply_deadline.utcoffset() == UTC.utcoffset(None)
assert request.to_wire()["reply_deadline"] == "2026-10-03T12:30:00Z"
def test_uuid_fields_are_uuid_objects() -> None:
request = RecognitionRequest.model_validate(_example("request-tabular.json"))
assert request.job_id == uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01")
assert isinstance(request.file_id, uuid.UUID)
def test_message_status_enum_values() -> None:
assert [s.value for s in MessageStatus] == ["processing", "done", "needs_check", "error"]
message = RecognitionResultMessage.model_validate(_example("result-message-done.json"))
assert message.status is MessageStatus.DONE
def test_contract_version_constant() -> None:
assert CONTRACT_VERSION == "1.0"
def test_python_float_amount_is_rejected_with_clear_error() -> None:
payload = _example("result-statement-minimal.json")
payload["documents"][0]["lines"][0]["values"][0]["amount"] = 1234.5
with pytest.raises(ValidationError, match="строк"):
RecognitionResult.model_validate(payload)
def test_decimal_object_input_is_accepted_and_dumped_as_string() -> None:
"""Для кода, собирающего результат из `Decimal` (тесты, будущие производители): объект `Decimal` принимается."""
payload = _example("result-statement-minimal.json")
payload["documents"][0]["lines"][0]["values"][0]["amount"] = Decimal("1234.50")
result = RecognitionResult.model_validate(payload)
assert result.to_wire()["documents"][0]["lines"][0]["values"][0]["amount"] == "1234.50"
# --- subject'ы, заголовок и идентификаторы сообщений ---------------------------------------------------------------
def test_request_msg_id() -> None:
job_id = uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01")
assert request_msg_id(job_id, 1) == "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:1"
assert request_msg_id(str(job_id), 3) == "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:3"
def test_ack_and_result_msg_ids() -> None:
job_id = uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01")
assert ack_msg_id(job_id, 1) == "ack:3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:1"
assert result_msg_id(job_id, 2) == "result:3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:2"
def test_request_subject_by_pipeline() -> None:
assert request_subject("tabular") == "files.tabular_uploaded"
assert request_subject("image") == "files.img_uploaded"
assert RESULT_SUBJECT == "files.tabular_parsed"
assert NATS_MSG_ID_HEADER == "Nats-Msg-Id"
def test_request_subject_uses_environment_map() -> None:
subjects = {"tabular": "test.files.tabular_uploaded", "image": "test.files.img_uploaded"}
assert request_subject("tabular", subjects) == "test.files.tabular_uploaded"
assert request_subject("image", subjects) == "test.files.img_uploaded"
def test_request_subject_rejects_unknown_pipeline() -> None:
with pytest.raises(ValueError, match="pipeline"):
request_subject("ocr")
def test_request_subject_matches_example_pipeline() -> None:
for name in ("request-tabular.json", "request-image.json"):
request = RecognitionRequest.model_validate(_example(name))
expected = {"tabular": "files.tabular_uploaded", "image": "files.img_uploaded"}[request.pipeline]
assert request_subject(request.pipeline) == expected
def test_integer_valued_float_is_normalized_to_int() -> None:
"""`1.0` (pandas) принимается как целое и уходит дальше целым числом: `1`."""
payload = _example("result-osv-minimal.json")
payload["documents"][0]["rows"][0]["level"] = 1.0
payload["documents"][0]["header"]["header_variant"] = 2.0
result = RecognitionResult.model_validate(payload)
wire = result.to_wire()
assert wire["documents"][0]["rows"][0]["level"] == 1
assert type(wire["documents"][0]["rows"][0]["level"]) is int
assert type(wire["documents"][0]["header"]["header_variant"]) is int
VALIDATORS["result"].validate(wire)
# --- MAJOR 2: примеры `examples/*.json` против схем и моделей v2.0 ----------------------------------------------------
V2_EXAMPLES = sorted(name for name, kind in EXAMPLE_KIND.items() if kind != "request")
@pytest.mark.parametrize("name", V2_EXAMPLES)
def test_example_v2_passes_json_schema(name: str) -> None:
VALIDATORS_V2[EXAMPLE_KIND[name]].validate(_example_v2(name))
@pytest.mark.parametrize("name", V2_EXAMPLES)
def test_example_v2_passes_model_and_round_trips(name: str) -> None:
kind = EXAMPLE_KIND[name]
original = _example_v2(name)
model = MODELS_V2[kind].model_validate(original)
assert model.model_dump(mode="json", exclude_unset=True) == original
wire = model.to_wire()
assert wire == original
VALIDATORS_V2[kind].validate(wire)
assert MODELS_V2[kind].model_validate(wire) == model
def test_v2_models_expose_json_schema() -> None:
for model in MODELS_V2.values():
assert model.model_json_schema()["type"] == "object"
def test_v2_statement_amounts_are_decimal_and_warnings_are_structures() -> None:
result = RecognitionResultV2.model_validate(_example_v2("result-statement-excel.json"))
balance = result.documents[0]
assert balance.lines[0].values[0].amount == Decimal("586260.26") # type: ignore[union-attr]
assert balance.warnings[0].code == "FRACTIONAL_IN_THOUSANDS"
assert balance.warnings[0].severity == "info"
assert result.documents[1].lines[1].line_status == "detail" # type: ignore[union-attr]
# (идентификатор, пример-основа, мутация, ожидаемый вердикт) — модель v2 и схема v2 согласованы
CASES_V2: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [
("v2/message/base", "result-message-done.json", lambda p: None, True),
("v2/message/schema_version-1.0", "result-message-done.json", _set("schema_version", "1.0"), False),
("v2/message/schema_version-2.3", "result-message-done.json", _set("schema_version", "2.3"), True),
(
"v2/message/warning-ok",
"result-message-done.json",
_set(
"metrics.warnings", [{"code": "JSON_FAILED", "severity": "error", "message": "m", "locator": "file:a.json"}]
),
True,
),
("v2/message/warning-string", "result-message-done.json", _set("metrics.warnings", ["строка"]), False),
(
"v2/message/warning-bad-severity",
"result-message-done.json",
_set("metrics.warnings", [{"code": "X", "severity": "warn", "message": "m"}]),
False,
),
(
"v2/message/warning-bad-code",
"result-message-done.json",
_set("metrics.warnings", [{"code": "x-1", "severity": "info", "message": "m"}]),
False,
),
(
"v2/message/warning-no-message",
"result-message-done.json",
_set("metrics.warnings", [{"code": "X", "severity": "info"}]),
False,
),
("v2/result/statement-base", "result-statement-excel.json", lambda p: None, True),
("v2/result/schema_version-1.2", "result-statement-excel.json", _set("schema_version", "1.2"), False),
("v2/result/no-statements_version", "result-statement-excel.json", _drop("service.statements_version"), False),
(
"v2/result/osv-no-statements_version-ok",
"result-osv-minimal.json",
lambda p: p["service"].pop("statements_version", None),
True,
),
("v2/result/type-OTHER", "result-statement-excel.json", _set("documents.0.document_type", "OTHER"), False),
("v2/result/no-form_variant", "result-statement-excel.json", _drop("documents.0.form_variant"), False),
("v2/result/form_variant-bad", "result-statement-excel.json", _set("documents.0.form_variant", "x"), False),
("v2/result/no-status", "result-statement-excel.json", _drop("documents.0.status"), False),
("v2/result/status-error", "result-statement-excel.json", _set("documents.0.status", "error"), False),
("v2/result/no-warnings", "result-statement-excel.json", _drop("documents.0.warnings"), False),
("v2/result/no-checks", "result-statement-excel.json", _drop("documents.0.checks"), False),
(
"v2/result/checks-on-empty-lines",
"result-statement-excel.json",
_set("documents.2.checks", {"relations_failed": [], "soft_relations_failed": []}),
False,
),
("v2/result/line-no-ref", "result-statement-excel.json", _drop("documents.0.lines.0.ref"), False),
("v2/result/line-empty-ref", "result-statement-excel.json", _set("documents.0.lines.0.ref", ""), False),
(
"v2/result/line-row_index-negative",
"result-statement-excel.json",
_set("documents.0.lines.0.row_index", -1),
False,
),
("v2/result/line-status-bad", "result-statement-excel.json", _set("documents.0.lines.0.line_status", "x"), False),
("v2/result/line-code-1-digit", "result-statement-excel.json", _set("documents.0.lines.0.line_code", "1"), False),
("v2/result/ok-without-code", "result-statement-excel.json", _set("documents.0.lines.0.line_code", None), False),
(
"v2/result/unreadable-with-code",
"result-statement-excel.json",
_set("documents.0.lines.0.line_status", "unreadable"),
False,
),
(
"v2/result/unreadable-null-code",
"result-statement-excel.json",
lambda p: p["documents"][0]["lines"][0].update(line_status="unreadable", line_code=None),
True,
),
(
"v2/result/detail-without-parent_ref",
"result-statement-excel.json",
_drop("documents.1.lines.1.parent_ref"),
False,
),
(
"v2/result/line-needs_review-missing",
"result-statement-excel.json",
_drop("documents.0.lines.0.needs_review"),
False,
),
(
"v2/result/value-text-missing",
"result-statement-excel.json",
_drop("documents.0.lines.0.values.0.text"),
False,
),
(
"v2/result/value-needs_review-missing",
"result-statement-excel.json",
_drop("documents.0.lines.0.values.0.needs_review"),
False,
),
(
"v2/result/value-amount-float",
"result-statement-excel.json",
_set("documents.0.lines.0.values.0.amount", 1.5),
False,
),
(
"v2/result/column-no-period_kind",
"result-statement-excel.json",
_drop("documents.0.columns.0.period_kind"),
False,
),
(
"v2/result/column-period_kind-bad",
"result-statement-excel.json",
_set("documents.0.columns.0.period_kind", "abs"),
False,
),
(
"v2/result/column-index-negative",
"result-statement-excel.json",
_set("documents.0.columns.0.column_index", -1),
False,
),
("v2/result/header-inn-bad", "result-statement-excel.json", _set("documents.0.header.inn", "123"), False),
("v2/result/header-inn-null", "result-statement-excel.json", _set("documents.0.header.inn", None), True),
(
"v2/result/header-needs_review_fields-bad",
"result-statement-excel.json",
_set("documents.0.header.needs_review_fields", ["zzz"]),
False,
),
(
"v2/result/header-needs_review_fields-dup",
"result-statement-excel.json",
_set("documents.0.header.needs_review_fields", ["inn", "inn"]),
False,
),
(
"v2/result/header-needs_review_fields-ok",
"result-statement-excel.json",
_set("documents.0.header.needs_review_fields", ["report_date"]),
True,
),
(
"v2/result/relation-no-codes",
"result-statement-excel.json",
_drop("documents.0.checks.soft_relations_failed.0.codes"),
False,
),
(
"v2/result/relation-empty-codes",
"result-statement-excel.json",
_set("documents.0.checks.soft_relations_failed.0.codes", []),
False,
),
(
"v2/result/relation-bad-code",
"result-statement-excel.json",
_set("documents.0.checks.soft_relations_failed.0.codes", ["12"]),
False,
),
(
"v2/result/checks-no-relations_failed",
"result-statement-excel.json",
_drop("documents.0.checks.relations_failed"),
False,
),
(
"v2/result/doc-warning-field-bad",
"result-statement-excel.json",
_set("documents.1.warnings.0.field", "zzz"),
False,
),
(
"v2/result/doc-warning-line_refs-ok",
"result-statement-excel.json",
_set("documents.0.warnings.0.line_refs", ["row:1"]),
True,
),
("v2/result/osv-base", "result-osv-needs-check.json", lambda p: None, True),
("v2/result/osv-no-status", "result-osv-needs-check.json", _drop("documents.0.status"), False),
("v2/result/osv-no-warnings", "result-osv-needs-check.json", _drop("documents.0.warnings"), False),
("v2/result/osv-status-bad", "result-osv-needs-check.json", _set("documents.0.status", "error"), False),
("v2/result/error-base", "result-error.json", lambda p: None, True),
("v2/result/done-empty-documents", "result-osv-minimal.json", _set("documents", []), False),
(
"v2/result/metrics-warning-string",
"result-osv-needs-check.json",
_set("metrics.warnings", ["строка"]),
False,
),
]
@pytest.mark.parametrize(("case_id", "example", "mutate", "expected_valid"), CASES_V2, ids=[c[0] for c in CASES_V2])
def test_model_and_schema_agree_v2(
case_id: str, example: str, mutate: Callable[[dict[str, Any]], None], expected_valid: bool
) -> None:
kind = EXAMPLE_KIND[example]
payload = copy.deepcopy(_example_v2(example))
mutate(payload)
schema_verdict = VALIDATORS_V2[kind].is_valid(payload)
try:
MODELS_V2[kind].model_validate(payload)
model_verdict = True
except ValidationError:
model_verdict = False
assert schema_verdict == expected_valid, f"{case_id}: схема вынесла {schema_verdict}, ожидали {expected_valid}"
assert model_verdict == schema_verdict, f"{case_id}: модель {model_verdict}, схема {schema_verdict}"
# --- версии: разбор по MAJOR ---------------------------------------------------------------------------------------
@pytest.mark.parametrize(("version", "expected"), [("1.0", 1), ("1.7", 1), ("2.0", 2), ("2.13", 2), ("10.1", 10)])
def test_major(version: str, expected: int) -> None:
assert major(version) == expected
@pytest.mark.parametrize("bad", ["", "1", "x.y", "1.0.1", "v2.0", " 2.0"])
def test_major_rejects_malformed_version(bad: str) -> None:
with pytest.raises(ValueError, match="schema_version"):
major(bad)
def test_parse_message_by_major() -> None:
v1 = parse_message(_example("result-message-done.json"))
assert type(v1) is RecognitionResultMessage
v2 = parse_message(_example_v2("result-message-done.json"))
assert type(v2) is RecognitionResultMessageV2
payload = _example_v2("result-message-done.json")
payload["schema_version"] = "3.0"
with pytest.raises(ValidationError):
parse_message(payload)
payload["schema_version"] = "banana"
with pytest.raises(ValidationError):
parse_message(payload)
no_version = _example_v2("result-message-done.json")
del no_version["schema_version"]
with pytest.raises(ValidationError):
parse_message(no_version)
def test_parse_result_by_major() -> None:
v1 = parse_result((V1_EXAMPLES_DIR / "result-statement-minimal.json").read_bytes())
assert type(v1) is RecognitionResult
v2 = parse_result((EXAMPLES_DIR / "result-statement-excel.json").read_bytes())
assert type(v2) is RecognitionResultV2
payload = _example_v2("result-statement-excel.json")
payload["schema_version"] = "3.0"
with pytest.raises(ValidationError):
parse_result(json.dumps(payload).encode("utf-8"))
with pytest.raises(ValidationError):
parse_result(b"[1, 2]")