From ebbeff58bd48dbd76c106ac078436fa42559219f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 00:47:42 +0400 Subject: [PATCH] =?UTF-8?q?test(normalizer):=20golden=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=82=D0=B0=D1=85=20=D0=98=D0=A1,=20make=20golden-?= =?UTF-8?q?update=20(D-8)?= 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_012vJkmBGKKqMoEMzvsXG3y2 --- Makefile | 7 ++- api/tests/golden_update.py | 73 ++++++++++++++++++++++++ api/tests/unit/normalizer/test_golden.py | 32 +++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 api/tests/golden_update.py create mode 100644 api/tests/unit/normalizer/test_golden.py diff --git a/Makefile b/Makefile index 0367f9e..4dc0fb2 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ COMPOSE_FILE ?= ../docs/dev/docker-compose.yml COMPOSE_PROJECT ?= arm-ka IMAGE ?= scan-gateway:dev -.PHONY: help venv lint test test-integration check docker-build check-full up emulator +.PHONY: help venv lint test test-integration check docker-build check-full up emulator golden-update help: @echo "venv - окружение api/.venv (Python 3.11) с зависимостями без raisa_*" @@ -19,6 +19,7 @@ help: @echo "check-full - check + test-integration + docker-build (по порядку, до первой ошибки)" @echo "up - поднять NATS/MinIO/PostgreSQL общим compose-проектом arm-ka и создать бакет (minio-init)" @echo "emulator - запустить эмулятор ИС Сканирование (python -m src.scan_emulator)" + @echo "golden-update - пересобрать эталоны нормализатора ../fixtures/ocr/expected_normalize/ (diff смотреть в fixtures)" # Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их venv: @@ -62,3 +63,7 @@ up: # Эмулятор ИС Сканирование (параметры — SCAN_HOTFOLDER_PATH, SCAN_FIXTURES_DIR, SCAN_EMULATOR_DELAY из api/.env) emulator: cd api && $(BIN)/python -m src.scan_emulator + +# Эталоны нормализатора на реальных ответах ИС: пишет ../fixtures/ocr/expected_normalize/.json; diff смотреть в fixtures +golden-update: + cd api && $(BIN)/python -m tests.golden_update diff --git a/api/tests/golden_update.py b/api/tests/golden_update.py new file mode 100644 index 0000000..a60c8f9 --- /dev/null +++ b/api/tests/golden_update.py @@ -0,0 +1,73 @@ +"""Golden нормализатора: сбор склейки реального ответа и запись эталонов (`make golden-update`). + +Набор задания `` — `.json` + `_*.json` из `../fixtures/ocr` (как у эмулятора); эталон — +`../fixtures/ocr/expected_normalize/.json`. Запуск: `cd api && .venv/bin/python -m tests.golden_update`. +""" + +import json +import sys +from pathlib import Path +from typing import Any + +from src.gateway.hotfolder import AnswerFile +from src.gateway.merge import merge_answers +from src.normalizer import NormalizeOutcome, OcrNormalizer +from src.normalizer.rules import load_rules + +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" + + +def golden_cases() -> list[str]: + """Имена `` без суффикса `_*`: `aep_3`, `aep_6`, … (по одному на набор).""" + names = sorted(p.name[: -len(".json")] for p in REAL_FIXTURES.glob("*.json") if p.is_file()) + stems = {name for name in names if not any(name.startswith(f"{other}_") for other in names if other != name)} + return sorted(stems) + + +def real_set(name: str) -> dict[str, Any]: + files = [] + for path in sorted(REAL_FIXTURES.glob(f"{name}*.json")): + base = path.name[: -len(".json")] + if base == name: + target = f"{JOB}.json" + elif base.startswith(f"{name}_"): + target = f"{JOB}_{base[len(name) + 1 :]}.json" + else: + continue + files.append(AnswerFile(target, path.read_bytes())) + return dict(merge_answers(JOB, files)) + + +def outcome_to_json(outcome: NormalizeOutcome) -> dict[str, Any]: + return { + "status": outcome.status.value, + "warnings": list(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], + } + + +def main() -> int: + if not REAL_FIXTURES.is_dir(): + print(f"нет каталога {REAL_FIXTURES}", file=sys.stderr) + return 2 + rules = load_rules(API_DIR / "rules") + GOLDEN_DIR.mkdir(exist_ok=True) + for name in golden_cases(): + payload = outcome_to_json(OcrNormalizer(rules).normalize(real_set(name))) + (GOLDEN_DIR / f"{name}.json").write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print( + f"{name}: {payload['status']}, документов {len(payload['documents'])}, " + f"предупреждений {len(payload['warnings'])}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/tests/unit/normalizer/test_golden.py b/api/tests/unit/normalizer/test_golden.py new file mode 100644 index 0000000..0c8357d --- /dev/null +++ b/api/tests/unit/normalizer/test_golden.py @@ -0,0 +1,32 @@ +"""Golden нормализатора на реальных ответах ИС (D-8). + +`../fixtures/ocr/.json` → `../fixtures/ocr/expected_normalize/.json`. +Обновление эталонов — `make golden-update` (не автоматически).""" + +import json +from pathlib import Path + +import pytest + +from src.normalizer import OcrNormalizer +from src.normalizer.rules import load_rules +from tests.golden_update import GOLDEN_DIR, REAL_FIXTURES, golden_cases, outcome_to_json, real_set + +API_DIR = Path(__file__).resolve().parents[3] +RULES = load_rules(API_DIR / "rules") +pytestmark = pytest.mark.fixtures + + +def _cases() -> list[str]: + return golden_cases() if REAL_FIXTURES.is_dir() else [] + + +@pytest.mark.parametrize("name", _cases() or ["<нет fixtures>"]) +def test_matches_golden(name: str) -> None: + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + 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).normalize(real_set(name))) + assert actual == expected, f"эталон {name} разошёлся: make golden-update и git diff в fixtures"