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:
parent
2a7ceca334
commit
ebbeff58bd
7
Makefile
7
Makefile
@ -7,7 +7,7 @@ COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
|
|||||||
COMPOSE_PROJECT ?= arm-ka
|
COMPOSE_PROJECT ?= arm-ka
|
||||||
IMAGE ?= scan-gateway:dev
|
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:
|
help:
|
||||||
@echo "venv - окружение api/.venv (Python 3.11) с зависимостями без raisa_*"
|
@echo "venv - окружение api/.venv (Python 3.11) с зависимостями без raisa_*"
|
||||||
@ -19,6 +19,7 @@ help:
|
|||||||
@echo "check-full - check + test-integration + docker-build (по порядку, до первой ошибки)"
|
@echo "check-full - check + test-integration + docker-build (по порядку, до первой ошибки)"
|
||||||
@echo "up - поднять NATS/MinIO/PostgreSQL общим compose-проектом arm-ka и создать бакет (minio-init)"
|
@echo "up - поднять NATS/MinIO/PostgreSQL общим compose-проектом arm-ka и создать бакет (minio-init)"
|
||||||
@echo "emulator - запустить эмулятор ИС Сканирование (python -m src.scan_emulator)"
|
@echo "emulator - запустить эмулятор ИС Сканирование (python -m src.scan_emulator)"
|
||||||
|
@echo "golden-update - пересобрать эталоны нормализатора ../fixtures/ocr/expected_normalize/ (diff смотреть в fixtures)"
|
||||||
|
|
||||||
# Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их
|
# Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их
|
||||||
venv:
|
venv:
|
||||||
@ -62,3 +63,7 @@ up:
|
|||||||
# Эмулятор ИС Сканирование (параметры — SCAN_HOTFOLDER_PATH, SCAN_FIXTURES_DIR, SCAN_EMULATOR_DELAY из api/.env)
|
# Эмулятор ИС Сканирование (параметры — SCAN_HOTFOLDER_PATH, SCAN_FIXTURES_DIR, SCAN_EMULATOR_DELAY из api/.env)
|
||||||
emulator:
|
emulator:
|
||||||
cd api && $(BIN)/python -m src.scan_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
|
||||||
|
|||||||
73
api/tests/golden_update.py
Normal file
73
api/tests/golden_update.py
Normal 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())
|
||||||
32
api/tests/unit/normalizer/test_golden.py
Normal file
32
api/tests/unit/normalizer/test_golden.py
Normal 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"
|
||||||
Loading…
x
Reference in New Issue
Block a user