From 22ca529bc627ba026e7188c18dfc9b31e20abbd9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:18:00 +0400 Subject: [PATCH] =?UTF-8?q?test:=20golden=20v2=20=D0=B8=20Review=20Focus?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=D1=85=20=D0=98?= =?UTF-8?q?=D0=A1=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/tests/golden_update.py | 42 ++++-- api/tests/unit/normalizer/test_golden.py | 18 ++- .../unit/normalizer/test_real_answers.py | 125 ++++++++++++++++++ 3 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 api/tests/unit/normalizer/test_real_answers.py diff --git a/api/tests/golden_update.py b/api/tests/golden_update.py index 4fa60d4..633dc5f 100644 --- a/api/tests/golden_update.py +++ b/api/tests/golden_update.py @@ -6,10 +6,13 @@ import json import sys +import uuid from pathlib import Path from typing import Any from arm_statements import default_rules +from src.contract import ResultStatus, ServiceInfo, ServiceName +from src.core.worker import _outcome_metrics, build_result from src.gateway.hotfolder import AnswerFile from src.gateway.merge import merge_answers from src.normalizer import NormalizeOutcome, OcrNormalizer @@ -18,6 +21,9 @@ API_DIR = Path(__file__).resolve().parents[1] REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" GOLDEN_DIR = REAL_FIXTURES / "expected_normalize" JOB = "3f1c2b7a-0000-4000-8000-000000000001" +JOB_ID = uuid.UUID(JOB) +FILE_ID = uuid.UUID("3f1c2b7a-0000-4000-8000-0000000000f1") +SERVICE = ServiceInfo(name=ServiceName.SCAN_GATEWAY, version="golden") GOLDEN_VERSION = "0" * 40 # фиксированная версия копии: golden от раскладки копии не зависит @@ -42,14 +48,34 @@ def real_set(name: str) -> dict[str, Any]: return dict(merge_answers(JOB, files)) +def full_payload(outcome: NormalizeOutcome) -> dict[str, Any]: + """Полный файл результата (только успех) с фиктивными идентификаторами: им проверяется схема `result`.""" + result = build_result( + outcome, + job_id=JOB_ID, + file_id=FILE_ID, + service=SERVICE, + raw_s3_key="raw/golden.json", + metrics=_outcome_metrics(outcome), + ) + return dict(result.to_wire()) + + def outcome_to_json(outcome: NormalizeOutcome) -> dict[str, Any]: - return { - "status": outcome.status.value, - "warnings": [w.to_wire() for w in outcome.warnings], - "metrics": outcome.metrics.model_dump(mode="json", exclude_none=True) if outcome.metrics else None, - "error": outcome.error.model_dump(mode="json", exclude_none=True) if outcome.error else None, - "documents": [doc.model_dump(mode="json", exclude_none=True) for doc in outcome.documents], - } + """Эталон golden v2 (P5-14): `schema_version`, `status`, `metrics`, `documents`; для ошибки — ещё `error`. + + `job_id`, `file_id`, `service`, `raw_s3_key` не пишутся: `statements_version` меняется при каждой раскладке.""" + metrics = _outcome_metrics(outcome) + if outcome.status is ResultStatus.ERROR: + return { + "schema_version": "2.0", + "status": "error", + "error": outcome.error.model_dump(mode="json", exclude_none=True) if outcome.error else None, + "metrics": metrics.model_dump(mode="json", exclude_none=True) if metrics else None, + "documents": [], + } + wire = full_payload(outcome) + return {key: wire[key] for key in ("schema_version", "status", "metrics", "documents") if key in wire} def main() -> int: @@ -65,7 +91,7 @@ def main() -> int: ) print( f"{name}: {payload['status']}, документов {len(payload['documents'])}, " - f"предупреждений {len(payload['warnings'])}" + f"предупреждений {len((payload.get('metrics') or {}).get('warnings', []))}" ) return 0 diff --git a/api/tests/unit/normalizer/test_golden.py b/api/tests/unit/normalizer/test_golden.py index 033b4a9..7885f28 100644 --- a/api/tests/unit/normalizer/test_golden.py +++ b/api/tests/unit/normalizer/test_golden.py @@ -9,8 +9,18 @@ from pathlib import Path import pytest from arm_statements import default_rules +from src.contract import ResultStatus from src.normalizer import OcrNormalizer -from tests.golden_update import GOLDEN_DIR, GOLDEN_VERSION, REAL_FIXTURES, golden_cases, outcome_to_json, real_set +from tests.golden_update import ( + GOLDEN_DIR, + GOLDEN_VERSION, + REAL_FIXTURES, + full_payload, + golden_cases, + outcome_to_json, + real_set, +) +from tests.unit.test_contract import VALIDATORS API_DIR = Path(__file__).resolve().parents[3] RULES = default_rules() @@ -25,9 +35,11 @@ def _cases() -> list[str]: def test_matches_golden(name: str) -> None: if not REAL_FIXTURES.is_dir(): pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") - pytest.skip("golden v2 пересобирается в задаче 6 (эталоны в fixtures пока прежнего формата)") expected_path = GOLDEN_DIR / f"{name}.json" assert expected_path.is_file(), f"нет эталона {expected_path}: выполните make golden-update и проверьте diff" expected = json.loads(expected_path.read_text(encoding="utf-8")) - actual = outcome_to_json(OcrNormalizer(RULES, version=GOLDEN_VERSION).normalize(real_set(name))) + outcome = OcrNormalizer(RULES, version=GOLDEN_VERSION).normalize(real_set(name)) + if outcome.status is not ResultStatus.ERROR: + VALIDATORS["result"].validate(full_payload(outcome)) + actual = outcome_to_json(outcome) assert actual == expected, f"эталон {name} разошёлся: make golden-update и git diff в fixtures" diff --git a/api/tests/unit/normalizer/test_real_answers.py b/api/tests/unit/normalizer/test_real_answers.py new file mode 100644 index 0000000..2c02281 --- /dev/null +++ b/api/tests/unit/normalizer/test_real_answers.py @@ -0,0 +1,125 @@ +"""Review Focus плана 5: правила пакета arm_statements на реальных ответах ИС (`fixtures/ocr`). + +Утверждения не ослабляются и не подгоняются под файл (Р-31/P5-17): расхождение — находка для пакета.""" + +from decimal import Decimal +from typing import Any + +import pytest + +from arm_statements import default_rules +from src.contract import ErrorCode, ResultStatus +from src.contract.result import StatementDocument, StatementLine, StatementValue +from src.normalizer import NormalizeOutcome, OcrNormalizer +from tests.golden_update import JOB, REAL_FIXTURES, real_set + +pytestmark = [ + pytest.mark.fixtures, + pytest.mark.skipif(not REAL_FIXTURES.is_dir(), reason="fixtures недоступен: положите репозиторий fixtures рядом"), +] + +NORMALIZER = OcrNormalizer(default_rules(), version="0" * 40) + + +def run(name: str) -> NormalizeOutcome: + return NORMALIZER.normalize(real_set(name)) + + +def lines(doc: StatementDocument) -> dict[str, StatementLine]: + refs = [ln.ref for ln in doc.lines] + assert len(refs) == len(set(refs)) and [ln.row_index for ln in doc.lines] == list(range(len(doc.lines))) + return {ln.ref: ln for ln in doc.lines} + + +def value(line: StatementLine, column_index: int) -> StatementValue: + return next(v for v in line.values if v.column_index == column_index) + + +def _holders(out: NormalizeOutcome) -> list[Any]: + """Что удерживает `done`: значения и строки с needs_review, строки не ok/detail, предупреждения error.""" + found: list[Any] = [] + for doc in out.documents: + for ln in doc.lines: + if ln.line_status not in ("ok", "detail"): + found.append((doc.locator, ln.ref, "line_status", ln.line_status, ln.line_code_text)) + for v in ln.values: + if v.needs_review: + found.append((doc.locator, ln.ref, v.column_index, v.text, v.confidence)) + if ln.needs_review: + found.append((doc.locator, ln.ref, "line", ln.line_code_text, None)) + found.extend(("warning", w.code, w.severity, w.locator, w.message) for w in out.warnings if w.severity == "error") + return found + + +def test_aep_3_junk_codes_and_dirty_amounts() -> None: # Review Focus 1, 4 + out = run("aep_3") + assert out.status is ResultStatus.NEEDS_CHECK + bal, inc = out.documents + assert [(d.document_type, d.locator, d.status) for d in out.documents] == [ + ("BALANCE", f"file:{JOB}.json;table:0", "needs_check"), + ("INCOME", f"file:{JOB}.json;table:1", "needs_check"), + ] + assert [c.column_index for c in bal.columns] == [2, 4, 5] + b = lines(bal) + assert (b["row:0"].line_status, b["row:0"].line_code_text) == ("missing", "НИ") + assert (b["row:2"].line_status, b["row:2"].parent_ref) == ("detail", "row:1") + assert (b["row:3"].line_status, b["row:3"].line_code) == ("ok", "1140") + assert {b["row:12"].line_status, b["row:31"].line_status} == {"unreadable"} + assert (b["row:17"].line_status, b["row:17"].parent_ref) == ("detail", "row:16") + i = lines(inc) + revenue = value(i["row:0"], 2) + assert i["row:0"].line_status == "missing" + assert (revenue.amount, revenue.string_value, revenue.is_recognized) == (None, "211(1 |", False) + cost = value(i["row:1"], 2) + assert (i["row:1"].line_code, cost.amount, cost.is_recognized) == ("2120", Decimal("-7744536"), False) + assert value(i["row:8"], 2).amount is None + [failed] = [w for w in out.warnings if w.code == "JSON_FAILED"] + assert (failed.severity, failed.locator) == ("error", f"file:{JOB}_ofr.json") + assert failed.message == "INVALID_DOCUMENT: Не удалось определить тип документа." + + +def test_aep_6_garbage_code_and_repeats() -> None: # Review Focus 2 + bal, inc = run("aep_6").documents + b = lines(bal) + assert (b["row:6"].line_status, b["row:6"].line_code, b["row:6"].line_code_text) == ( + "unreadable", + None, + "44 30\nI |", + ) + assert "row:6#1" not in b and all(v.amount is None for v in b["row:6"].values) + assert [ln.line_code for ln in bal.lines].count("1220") == 2 + assert [ln.line_code for ln in bal.lines].count("1520") == 2 + i = lines(inc) + assert [ln.line_code for ln in inc.lines].count("2410") == 2 and i["row:4"].line_status == "detail" + assert bal.status == "needs_check" + + +@pytest.mark.parametrize( + ("name", "row", "amounts"), + [ + ("БО 1 кв 2025", 73, (Decimal("43274"), Decimal("198622"))), + ("Бухгалтерская отчетность 1 кв.25", 93, (Decimal("917775"), Decimal("2371333"))), + ], +) +def test_q1_2025_split_repeats_and_done(name: str, row: int, amounts: tuple[Decimal, Decimal]) -> None: # RF 3 + out = run(name) + bal, _ = out.documents + b = lines(bal) + first, second = b[f"row:{row}#1"], b[f"row:{row}#2"] + assert (first.line_code, second.line_code) == ("1500", "1700") + assert (value(first, 2).amount, value(second, 2).amount) == amounts + assert any(w.code == "ROW_SPLIT" and w.severity == "info" for w in bal.warnings) + codes = [ln.line_code for ln in bal.lines] + assert codes.count("1220") == 2 and codes.count("1520") == 2 + # Р-23: расшифровки не держат документ; расхождение — находка для пакета (P5-17), не правка шлюза + assert [d.status for d in out.documents] == ["done", "done"], _holders(out) + assert out.status is ResultStatus.DONE + + +@pytest.mark.parametrize("name", ["aep_5", "aep_8_form4"]) +def test_all_json_failed_is_error(name: str) -> None: # Review Focus 4, 5 + out = run(name) + assert (out.status, out.documents) == (ResultStatus.ERROR, []) + assert out.error is not None and out.error.code is ErrorCode.INVALID_DOCUMENT + assert out.error.message == "Не удалось определить тип документа." + assert [(w.code, w.locator) for w in out.warnings] == [("JSON_FAILED", f"file:{JOB}.json")]