Копии 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
970 lines
42 KiB
Python
970 lines
42 KiB
Python
"""Контракт обмена с сервисами распознавания, 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]")
|