arm-core/api/tests/fixtures_helpers.py
Andrey Epifancev 7dd7d5dd40 test: golden fixtures контракта 2.0 в тестах ядра (#144)
fixtures #26/#27 (19.10.2026) перевели снимки форм и OCR на контракт 2.0,
а загрузчики тестов читали их моделью 1.x: 164 падения на main.

- golden_result/ocr_result: модель по MAJOR schema_version, для 2.0 —
  service.statements_version копии пакета ядра;
- golden_result_v1: снимок 2.0 → 1.2 (строки с кодом, колонки с 0, без
  null в шапке) для модулей приёма 1.x — test_mapping_statements и
  test_api_statements; путь 2.0 покрывают *_v2;
- make_entries_from_result — parse_result; ожидания fixtures_marker под
  эталоны 2.0; удаление файла считает замечания сервиса снимка;
- 76.Н.1 (synthetic s42, tabular-parser#59) — в KNOWN_OUT_OF_PLAN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:34:20 +04:00

176 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Поиск каталога реальных данных `fixtures` (соседний репозиторий workspace) для тестов с маркером `fixtures`."""
import json
import os
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING:
from src.contract.result import RecognitionResult
from src.contract.result_v2 import RecognitionResultV2
from src.models.directories import LegalEntity
from src.models.enums import RecognitionStatus
from src.models.files import Document
FIXTURES_ENV = "ARM_KA_FIXTURES"
GOLDEN_STATEMENTS = Path("golden") / "expected_statement_parse"
OCR_EXPECTED = Path("ocr") / "expected_normalize"
def resolve_fixtures_dir() -> Path:
"""Каталог fixtures: `ARM_KA_FIXTURES`, иначе соседний `../../fixtures` относительно `arm-core/api`.
Не проверяет существование: это делает вызывающий код (пропуск теста с причиной).
"""
explicit = os.environ.get(FIXTURES_ENV)
if explicit:
return Path(explicit)
return Path(__file__).resolve().parents[3] / "fixtures"
def fixtures_skip_reason() -> str | None:
"""Причина пропуска тестов с маркером `fixtures` или `None`, если каталог доступен."""
path = resolve_fixtures_dir()
if path.is_dir():
return None
return (
f"ARM_KA_FIXTURES недоступен: нет каталога {path} "
f"(задайте {FIXTURES_ENV} или положите fixtures рядом с arm-core)"
)
def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult | RecognitionResultV2":
"""Эталон нормализатора scan-gateway (`ocr/expected_normalize/<name>.json`) как файл результата: модель — по MAJOR
`schema_version` эталона (без поля — v1.2, эталоны до контракта 2.0).
"""
from src.contract.versions import parse_result # noqa: PLC0415 - цикл с conftest
data = json.loads(path.read_text("utf-8"))
version = data.get("schema_version", "1.2")
payload: dict[str, object] = {
"schema_version": version,
"job_id": str(job_id),
"file_id": str(file_id),
"service": _golden_service({"name": "scan-gateway"}, version),
"status": data["status"],
"documents": data["documents"],
}
if data.get("error") is not None:
payload["error"] = data["error"]
if data.get("metrics") is not None:
payload["metrics"] = data["metrics"]
return parse_result(json.dumps(payload).encode())
def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult | RecognitionResultV2":
"""Golden-снимок парсера как файл результата (модель — по MAJOR `schema_version`): подставляет `job_id`/`file_id`
и версию сервиса."""
from src.contract.versions import parse_result # noqa: PLC0415 - тяжёлые импорты лениво: цикл с conftest
data = json.loads(path.read_text("utf-8"))
data["service"] = _golden_service(data["service"], data["schema_version"])
return parse_result(json.dumps(data | {"job_id": str(job_id), "file_id": str(file_id)}).encode())
def golden_result_v1(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult":
"""Golden-снимок как результат контракта 1.2 — для тестов маппинга и API формы на пути `1.x` (ядро принимает оба
MAJOR). Снимок 2.0 сводится к 1.2: строки без кода отброшены (в 1.x `line_code` обязателен), колонки формы
перенумерованы по порядку с 0, как отдавал сервис 1.x (в 2.0 `column_index` — номер столбца листа), предупреждения
файла — текстом; поля, которых нет в 1.x, модель отбрасывает сама."""
from src.contract.result import RecognitionResult # noqa: PLC0415 - цикл с conftest
data = json.loads(path.read_text("utf-8"))
if data["schema_version"].startswith("2."):
data["schema_version"] = "1.2"
metrics = data.get("metrics")
if metrics and metrics.get("warnings") is not None:
metrics["warnings"] = [warning["message"] for warning in metrics["warnings"]]
for document in data["documents"]:
if "lines" in document:
_statement_as_v1(document)
data["service"] = {"version": "golden", **data["service"]}
return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)})
def _statement_as_v1(document: dict[str, Any]) -> None:
"""Форма снимка 2.0 → 1.2 на месте: строки с кодом, колонки 0…n-1 в порядке первого появления `column_index`
(повтор номера остаётся повтором), `null` в шапке — отсутствие поля (в 1.x часть полей шапки не допускает null)."""
index: dict[int, int] = {}
for column in document["columns"]:
index.setdefault(column["column_index"], len(index))
document["header"] = {key: value for key, value in document["header"].items() if value is not None}
for column in document["columns"]:
column["column_index"] = index[column["column_index"]]
document["lines"] = [line for line in document["lines"] if line.get("line_code")]
for line in document["lines"]:
line["values"] = [value | {"column_index": index[value["column_index"]]} for value in line["values"]]
checks = document.get("checks") or {}
for key in ("relations_failed", "soft_relations_failed"):
checks[key] = [item | {"column_index": index[item["column_index"]]} for item in checks.get(key) or ()]
def _golden_service(service: dict[str, object], schema_version: str) -> dict[str, object]:
"""`service` снимка: версия сервиса `golden`, у контракта 2.0 — ещё `statements_version` копии пакета ядра (в
снимках версии вырезаны, а при формах поле обязательно; совпадение с ядром — как у сервиса той же сборки)."""
from src.contract.versions import MAJOR_V2, major # noqa: PLC0415 - цикл с conftest
from src.services.statements_engine import default_engine # noqa: PLC0415
result: dict[str, object] = {"version": "golden", **service}
if major(schema_version) == MAJOR_V2:
result.setdefault("statements_version", default_engine().version)
return result
async def map_golden_snapshot( # noqa: PLR0913 - все входы именованные
session: AsyncSession,
*,
entity: "LegalEntity",
path: Path,
uploaded_by: uuid.UUID | None,
result_builder: Callable[..., "RecognitionResult"] | None = None,
job_status: "RecognitionStatus | None" = None,
) -> "list[Document]":
"""Golden-снимок `path` → файл ЮЛ `entity` и его документы в БД теста (`flush`, без commit).
Как маппер: файл `PROCESSING` с текущим заданием `done`, затем боевой `map_file` с планом счетов seed и
настоящим `CounterpartyIndex` (справочник читается на момент вызова — ЮЛ и алиасы должны быть созданы
до маппинга). Возвращает документы файла отсортированные по (document_type, osv_account_code).
`result_builder` — функция создания RecognitionResult (по умолчанию golden_result); `job_status` — статус
текущего задания (по умолчанию `done`; `needs_check` — как после результата с `status = needs_check`).
"""
# ленивые импорты: `conftest` импортирует этот модуль раньше `tests.factories` (цикл)
from src.models.accounting import ChartOfAccount # noqa: PLC0415
from src.models.enums import FileStatus, RecognitionStatus # noqa: PLC0415
from src.models.files import Document # noqa: PLC0415
from src.repositories.counterparties import CounterpartyIndex # noqa: PLC0415
from src.services.result_mapping import map_file # noqa: PLC0415
from tests.factories import make_stored_file # noqa: PLC0415
file, job = await make_stored_file(
session,
entity=entity,
status=FileStatus.PROCESSING,
job_status=job_status or RecognitionStatus.done,
original_name=path.name,
uploaded_by=uploaded_by,
)
chart = {
code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id))
}
index = await CounterpartyIndex.load(session)
builder = result_builder or golden_result
result = builder(path, job_id=job.id, file_id=file.id)
await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=datetime.now(UTC))
documents = await session.execute(
select(Document)
.where(Document.stored_file_id == file.id)
.order_by(Document.document_type, Document.osv_account_code)
)
return list(documents.scalars())