test(normalizer): golden на реальных ответах ИС, make golden-update (D-8)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 00:47:42 +04:00
parent 2a7ceca334
commit ebbeff58bd
3 changed files with 111 additions and 1 deletions

View File

@ -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/<name>.json; diff смотреть в fixtures
golden-update:
cd api && $(BIN)/python -m tests.golden_update

View File

@ -0,0 +1,73 @@
"""Golden нормализатора: сбор склейки реального ответа и запись эталонов (`make golden-update`).
Набор задания `<name>` — `<name>.json` + `<name>_*.json` из `../fixtures/ocr` (как у эмулятора); эталон —
`../fixtures/ocr/expected_normalize/<name>.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]:
"""Имена `<name>` без суффикса `_*`: `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())

View File

@ -0,0 +1,32 @@
"""Golden нормализатора на реальных ответах ИС (D-8).
`../fixtures/ocr/<name>.json` → `../fixtures/ocr/expected_normalize/<name>.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"