"""Контракт v2.0 (RSHB/arm-ka-docs#116): модели результата и конверта v2, запрос остаётся v1.""" import datetime import json from decimal import Decimal from pathlib import Path from typing import Any import pytest from pydantic import ValidationError from src.contract import CONTRACT_VERSION, RecognitionRequest, RecognitionResult from src.contract.common import REQUEST_SCHEMA_VERSION_PATTERN, SCHEMA_VERSION_PATTERN from src.contract.result import ( FailedRelation, OsvHeader, StatementChecks, StatementDocument, StatementLine, ) EXAMPLES_DIR = Path(__file__).resolve().parents[2] / "contracts" / "examples" RESULT_EXAMPLES = ( "result-osv-minimal", "result-osv-needs-check", "result-statement-excel", "result-statement-minimal", "result-error", ) def _load(name: str) -> dict[str, Any]: data = json.loads((EXAMPLES_DIR / f"{name}.json").read_text(encoding="utf-8")) assert isinstance(data, dict) return data def test_docs_examples_validate_with_models() -> None: for name in RESULT_EXAMPLES: payload = _load(name) assert RecognitionResult.model_validate(payload).to_wire() == payload def test_request_stays_v1_and_result_is_v2() -> None: assert CONTRACT_VERSION == "2.0" assert SCHEMA_VERSION_PATTERN == r"^2\.\d+$" assert REQUEST_SCHEMA_VERSION_PATTERN == r"^1\.\d+$" request = RecognitionRequest.model_validate(_load("request-tabular")) assert request.schema_version == "1.0" with pytest.raises(ValidationError): RecognitionRequest.model_validate({**_load("request-tabular"), "schema_version": "2.0"}) def test_statement_without_statements_version_is_rejected() -> None: payload = _load("result-statement-excel") del payload["service"]["statements_version"] with pytest.raises(ValidationError, match="statements_version"): RecognitionResult.model_validate(payload) def test_osv_only_result_does_not_need_statements_version() -> None: payload = _load("result-osv-minimal") assert "statements_version" not in payload["service"] RecognitionResult.model_validate(payload) def test_unreadable_line_with_code_is_rejected() -> None: """Спека «Строка без кода с кодом в поле»: `line_code` не `null` ⇔ `line_status = ok`.""" line = _load("result-statement-minimal")["documents"][0]["lines"][2] assert line["line_status"] == "unreadable" and line["line_code"] is None line["line_code"] = "1240" with pytest.raises(ValidationError, match="line_code"): StatementLine.model_validate(line) ok_line = {**line, "line_status": "ok", "line_code": None} with pytest.raises(ValidationError, match="line_code"): StatementLine.model_validate(ok_line) def test_detail_line_requires_parent_ref() -> None: line = _load("result-statement-excel")["documents"][1]["lines"][1] assert line["line_status"] == "detail" del line["parent_ref"] with pytest.raises(ValidationError, match="parent_ref"): StatementLine.model_validate(line) def test_checks_required_for_balance_with_lines_and_forbidden_without() -> None: document = _load("result-statement-minimal")["documents"][0] without_checks = {k: v for k, v in document.items() if k != "checks"} with pytest.raises(ValidationError, match="checks"): StatementDocument.model_validate(without_checks) empty = {**document, "lines": [], "checks": {"relations_failed": [], "soft_relations_failed": []}} with pytest.raises(ValidationError, match="checks"): StatementDocument.model_validate(empty) disclosure = {**document, "document_type": "DISCLOSURE", "form_variant": None, "lines": []} disclosure.pop("checks") StatementDocument.model_validate(disclosure) def test_form_variant_is_required_and_other_type_is_gone() -> None: document = _load("result-statement-minimal")["documents"][0] with pytest.raises(ValidationError): StatementDocument.model_validate({k: v for k, v in document.items() if k != "form_variant"}) with pytest.raises(ValidationError): StatementDocument.model_validate({**document, "document_type": "OTHER"}) def test_osv_document_requires_status_and_warnings() -> None: payload = _load("result-osv-minimal") for key in ("status", "warnings"): broken = json.loads(json.dumps(payload)) del broken["documents"][0][key] with pytest.raises(ValidationError): RecognitionResult.model_validate(broken) def test_failed_relation_has_codes_and_nullable_amounts() -> None: relation = FailedRelation.model_validate( { "column_index": 3, "relation": "2200 = 2100 - 2210 - 2220", "codes": ["2200", "2100", "2210", "2220"], "expected": None, "actual": "400.00", } ) assert relation.expected is None and relation.actual == Decimal("400.00") with pytest.raises(ValidationError): FailedRelation.model_validate( {"column_index": 3, "relation": "x", "codes": [], "expected": None, "actual": None} ) with pytest.raises(ValidationError): FailedRelation.model_validate( {"column_index": 3, "relation": "x", "codes": ["12"], "expected": None, "actual": None} ) def test_statement_checks_have_no_duplicate_line_codes() -> None: checks = StatementChecks.model_validate({"relations_failed": [], "soft_relations_failed": []}) assert "duplicate_line_codes" not in StatementChecks.model_fields assert checks.to_wire() == {"relations_failed": [], "soft_relations_failed": []} def test_warning_code_is_free_text_by_pattern_and_severity_is_closed() -> None: payload = _load("result-statement-minimal") payload["documents"][0]["warnings"][0]["code"] = "NEW_CODE_2_1" RecognitionResult.model_validate(payload) payload["documents"][0]["warnings"][0]["code"] = "lowercase" with pytest.raises(ValidationError): RecognitionResult.model_validate(payload) payload["documents"][0]["warnings"][0].update(code="OK_CODE", severity="warning") with pytest.raises(ValidationError): RecognitionResult.model_validate(payload) def test_metrics_warnings_are_structures() -> None: payload = _load("result-osv-minimal") payload["metrics"] = {"warnings": ["строка"]} with pytest.raises(ValidationError): RecognitionResult.model_validate(payload) payload["metrics"]["warnings"] = [ {"code": "SHEET_OTHER", "severity": "info", "message": "лист", "locator": "sheet:Прочее"} ] assert RecognitionResult.model_validate(payload).to_wire()["metrics"]["warnings"][0]["locator"] == "sheet:Прочее" def test_osv_header_period_bounds_are_optional_dates() -> None: header = OsvHeader( organization_name="ООО", account_code_raw="60", period_text="1 квартал 2025 г.", ) assert header.period_start is None and header.is_from_year_start is None filled = OsvHeader.model_validate( { "organization_name": "ООО", "account_code_raw": "60", "period_text": "x", "period_start": "2024-10-01", "period_end": "2025-09-30", "is_from_year_start": False, } ) assert filled.period_start == datetime.date(2024, 10, 1) assert filled.period_end == datetime.date(2025, 9, 30)