Схема recognition-result и примеры result-* скопированы байт в байт из docs/contracts (v1.1); OsvRow.extra добавлен в pydantic-модель; в тест примеров добавлен result-osv-needs-check.json. CONTRACT_VERSION не менялся: ядро — потребитель, принимает любой MINOR 1.x, запрос не менялся. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
650 lines
29 KiB
Python
650 lines
29 KiB
Python
"""Контракт обмена с сервисами распознавания v1.0 (spec recognition-job-exchange, design D-6).
|
||
|
||
Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/`; каждый пример проходит и
|
||
JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же вердикт на негативных кейсах.
|
||
"""
|
||
|
||
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,
|
||
ack_msg_id,
|
||
request_msg_id,
|
||
request_subject,
|
||
result_msg_id,
|
||
)
|
||
|
||
CONTRACTS_DIR = Path(__file__).resolve().parents[1] / "contracts"
|
||
EXAMPLES_DIR = CONTRACTS_DIR / "examples"
|
||
SCHEMA_FILES = {
|
||
"request": "recognition-request.schema.json",
|
||
"message": "recognition-result-message.schema.json",
|
||
"result": "recognition-result.schema.json",
|
||
}
|
||
MODELS: dict[str, type[ContractModel]] = {
|
||
"request": RecognitionRequest,
|
||
"message": RecognitionResultMessage,
|
||
"result": RecognitionResult,
|
||
}
|
||
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",
|
||
}
|
||
EXAMPLES_COUNT = 9
|
||
|
||
|
||
def _load(path: Path) -> Any:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
|
||
|
||
def _example(name: str) -> dict[str, Any]:
|
||
data = _load(EXAMPLES_DIR / name)
|
||
assert isinstance(data, dict)
|
||
return data
|
||
|
||
|
||
def _build_validators() -> 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()
|
||
|
||
|
||
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 рядом)"
|
||
)
|
||
docs_files = {p.relative_to(docs_dir).as_posix() for p in docs_dir.rglob("*.json")}
|
||
own_files = {p.relative_to(CONTRACTS_DIR).as_posix() for p in CONTRACTS_DIR.rglob("*.json")}
|
||
assert own_files == docs_files, (
|
||
f"набор файлов контракта разошёлся: только в api/contracts: {sorted(own_files - docs_files)}, "
|
||
f"только в docs/contracts: {sorted(docs_files - own_files)}"
|
||
)
|
||
for name in sorted(docs_files):
|
||
assert (CONTRACTS_DIR / name).read_bytes() == (docs_dir / name).read_bytes(), (
|
||
f"файл {name} в api/contracts отличается от docs/contracts: скопируйте заново (cp), не правьте руками"
|
||
)
|
||
|
||
|
||
def test_example_table_covers_all_example_files() -> None:
|
||
files = {p.name for p in EXAMPLES_DIR.glob("*.json")}
|
||
assert files == set(EXAMPLE_KIND)
|
||
assert len(files) == EXAMPLES_COUNT
|
||
|
||
|
||
# --- примеры: 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") == "recognition.tabular.request"
|
||
assert request_subject("image") == "recognition.image.request"
|
||
assert RESULT_SUBJECT == "recognition.result"
|
||
assert NATS_MSG_ID_HEADER == "Nats-Msg-Id"
|
||
|
||
|
||
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))
|
||
assert request_subject(request.pipeline) == f"recognition.{request.pipeline}.request"
|
||
|
||
|
||
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)
|