"""Контракт обмена с сервисами распознавания v1.1. Спека сервиса — `tabular-recognition-service`; change `recognition-contract` лежит в `../docs`. Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/`; каждый пример проходит и JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же вердикт на негативных кейсах. """ import copy import json import os 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 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, ) API_DIR = Path(__file__).resolve().parents[2] CONTRACTS_DIR = API_DIR / "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", "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]: 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`: каталог docs из `ARM_KA_DOCS`, иначе соседний `docs` рядом с репозиторием `tabular-parser`.""" docs = os.environ.get("ARM_KA_DOCS") docs_dir = Path(docs) if docs else API_DIR.parent.parent / "docs" return docs_dir / "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/osv-row-extra-strings", "result-osv-minimal.json", _set("documents.0.rows.0.extra", {"КПП": "1", "Н": None}), True, ), ("result/osv-row-extra-null", "result-osv-minimal.json", _set("documents.0.rows.0.extra", None), True), ("result/osv-row-extra-absent", "result-osv-minimal.json", _drop("documents.0.rows.1.extra"), True), ( "result/osv-row-extra-non-string-value", "result-osv-minimal.json", _set("documents.0.rows.0.extra", {"КПП": 1}), False, ), ("result/osv-row-extra-not-object", "result-osv-minimal.json", _set("documents.0.rows.0.extra", ["КПП"]), False), ("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.2" 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_rejects_unknown_pipeline() -> None: with pytest.raises(ValueError, match="pipeline"): request_subject("ocr") def test_request_subject_uses_environment_map() -> None: """Карта `subjects` окружения (NATSSettings.request_subjects()) подменяет имена контракта по умолчанию.""" 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" with pytest.raises(ValueError, match="неизвестный pipeline"): request_subject("ocr", subjects) 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)