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>
This commit is contained in:
parent
e6e4cd877b
commit
7dd7d5dd40
@ -28,7 +28,8 @@ from sqlalchemy import delete, func, insert, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.contract import request_subject
|
||||
from src.contract.result import OsvDocument, RecognitionResult
|
||||
from src.contract.result import OsvDocument
|
||||
from src.contract.versions import parse_result
|
||||
from src.domain.consolidation_periods import reporting_date
|
||||
from src.domain.osv_mapping import build_entries, document_fields
|
||||
from src.domain.policy import FINISHED_STATUSES
|
||||
@ -349,7 +350,7 @@ async def make_entries_from_result(
|
||||
assert job.file_id == file.id # noqa: S101 - защита от перепутанных аргументов в тестах
|
||||
payload = {**result_json, "job_id": str(job.id), "file_id": str(file.id)}
|
||||
payload["service"] = {"version": "test", **result_json.get("service", {"name": "tabular-parser"})}
|
||||
result = RecognitionResult.model_validate(payload)
|
||||
result = parse_result(json.dumps(payload).encode()) # модель — по MAJOR `schema_version` (снимки 1.x и 2.0)
|
||||
sheets = [doc for doc in result.documents if isinstance(doc, OsvDocument)]
|
||||
chart = {
|
||||
code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id))
|
||||
|
||||
@ -6,13 +6,14 @@ import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
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
|
||||
@ -44,18 +45,19 @@ def fixtures_skip_reason() -> str | None:
|
||||
)
|
||||
|
||||
|
||||
def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult":
|
||||
"""Эталон нормализатора scan-gateway (`ocr/expected_normalize/<name>.json`)
|
||||
как `RecognitionResult` контракта v1.2.
|
||||
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.result import RecognitionResult # noqa: PLC0415 - цикл с conftest
|
||||
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": "1.2",
|
||||
"schema_version": version,
|
||||
"job_id": str(job_id),
|
||||
"file_id": str(file_id),
|
||||
"service": {"name": "scan-gateway", "version": "golden"},
|
||||
"service": _golden_service({"name": "scan-gateway"}, version),
|
||||
"status": data["status"],
|
||||
"documents": data["documents"],
|
||||
}
|
||||
@ -63,18 +65,68 @@ def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "Recogni
|
||||
payload["error"] = data["error"]
|
||||
if data.get("metrics") is not None:
|
||||
payload["metrics"] = data["metrics"]
|
||||
return RecognitionResult.model_validate(payload)
|
||||
return parse_result(json.dumps(payload).encode())
|
||||
|
||||
|
||||
def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult":
|
||||
"""Golden-снимок парсера как `RecognitionResult`: подставляет `job_id`/`file_id` и версию сервиса."""
|
||||
from src.contract.result import RecognitionResult # noqa: PLC0415 - тяжёлые импорты лениво: цикл с conftest
|
||||
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,
|
||||
*,
|
||||
|
||||
@ -169,8 +169,9 @@ async def test_delete_file_with_statement_form(
|
||||
file_id = await _form_file_with_comment(seeded, fixtures_dir)
|
||||
document_ids = list((await seeded.execute(select(Document.id).where(Document.stored_file_id == file_id))).scalars())
|
||||
assert await _count(seeded, FinancialStatementValue) > 0 and await _count(seeded, Comment) == 1
|
||||
issues = await _count(seeded, DocumentIssue) # предупреждения сервиса из снимка 2.0
|
||||
await make_document_issue(seeded, document_id=document_ids[0]) # v5.19: document_issue уходит вместе с файлом
|
||||
assert await _count(seeded, DocumentIssue) == 1
|
||||
assert await _count(seeded, DocumentIssue) == issues + 1
|
||||
if superseded:
|
||||
await seeded.execute(update(StoredFile).where(StoredFile.id == file_id).values(status=FileStatus.SUPERSEDED))
|
||||
await seeded.flush()
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
«Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14)."""
|
||||
|
||||
import datetime
|
||||
import functools
|
||||
import json
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
@ -51,10 +52,14 @@ from tests.factories import (
|
||||
make_stored_file,
|
||||
)
|
||||
from tests.fake_engine import FakeEngine
|
||||
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
|
||||
from tests.fixtures_helpers import GOLDEN_STATEMENTS, golden_result_v1
|
||||
from tests.fixtures_helpers import map_golden_snapshot as _map_golden_snapshot
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
|
||||
|
||||
# формы — из снимков, сведённых к 1.2 (модуль писался под приём `1.x`); правка формы 2.0 — `test_statements_v2_edit.py`
|
||||
map_golden_snapshot = functools.partial(_map_golden_snapshot, result_builder=golden_result_v1)
|
||||
|
||||
KA_RF = "ka_rf@armka.local"
|
||||
RKA = "rka@armka.local" # только DOCUMENT_VIEW
|
||||
ROSTOV = "Ростовский региональный филиал"
|
||||
|
||||
@ -46,7 +46,7 @@ def test_skip_reason_names_variable_and_path(monkeypatch: pytest.MonkeyPatch, tm
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None:
|
||||
"""ocr_result читает эталон нормализатора и создаёт RecognitionResult
|
||||
"""ocr_result читает эталон нормализатора (контракт 2.0) и создаёт файл результата
|
||||
с правильным порядком документов.
|
||||
"""
|
||||
job_id, file_id = uuid.uuid4(), uuid.uuid4()
|
||||
@ -54,18 +54,19 @@ def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None:
|
||||
assert result.job_id == job_id and result.file_id == file_id
|
||||
assert result.service.name == "scan-gateway"
|
||||
assert [doc.document_type for doc in result.documents] == ["BALANCE", "INCOME"]
|
||||
assert result.status == "needs_check" and result.schema_version == "1.2"
|
||||
assert result.status == "needs_check" and result.schema_version == "2.0"
|
||||
assert result.error is None
|
||||
assert result.metrics is not None and result.metrics.rows == 60 # noqa: PLR2004 - эталон aep_6
|
||||
assert result.metrics is not None and result.metrics.rows == 88 # noqa: PLR2004 - эталон aep_6
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_ocr_result_keeps_error_of_failed_golden(fixtures_dir: Path) -> None:
|
||||
"""Эталон с ошибкой: код и сообщение переносятся в результат, метрик нет."""
|
||||
"""Эталон с ошибкой: код и сообщение переносятся в результат, в метриках — предупреждение файла уровня error."""
|
||||
result = ocr_result(fixtures_dir / OCR_EXPECTED / "aep_5.json", job_id=uuid.uuid4(), file_id=uuid.uuid4())
|
||||
assert result.status == "error" and result.documents == []
|
||||
assert result.error is not None and result.error.code == "INVALID_DOCUMENT"
|
||||
assert result.metrics is None
|
||||
assert result.metrics is not None and result.metrics.warnings is not None
|
||||
assert [(w.code, w.severity) for w in result.metrics.warnings] == [("JSON_FAILED", "error")]
|
||||
|
||||
|
||||
def test_ocr_result_keeps_empty_metrics(tmp_path: Path) -> None:
|
||||
|
||||
@ -57,6 +57,7 @@ KNOWN_OUT_OF_PLAN = frozenset(
|
||||
"76.77.2",
|
||||
"76.77.5",
|
||||
"76.АФ", # osv-dkb/primanych
|
||||
"76.Н.1", # synthetic/s42_subaccounts_totals_reversed (tabular-parser#59)
|
||||
}
|
||||
)
|
||||
TIME_BUDGET_SECONDS = 60.0 # tasks 3.4: маппинг всех снимков — не дольше минуты
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""Маппинг форм ББ/ОФР (spec osv-result-mapping «Цикл маппинга…», «Документ формы ББ/ОФР», «Повтор формы в файле и
|
||||
прочие документы», «Статус документа формы»; design D-1…D-3, D-6) на golden-снимках fixtures."""
|
||||
|
||||
import functools
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
@ -35,10 +36,14 @@ from src.models.statements import (
|
||||
from src.repositories.counterparties import CounterpartyIndex
|
||||
from src.services.result_mapping import MAPPING_WARNINGS_KEY, map_file
|
||||
from tests.factories import make_document_issue, make_legal_entity
|
||||
from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, golden_result, map_golden_snapshot, ocr_result
|
||||
from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, golden_result_v1, ocr_result
|
||||
from tests.fixtures_helpers import map_golden_snapshot as _map_golden_snapshot
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
|
||||
|
||||
# модуль проверяет маппинг форм `1.x` (Срез 3b): снимки 2.0 сводятся к 1.2; путь 2.0 — `test_mapping_statements_v2.py`
|
||||
map_golden_snapshot = functools.partial(_map_golden_snapshot, result_builder=golden_result_v1)
|
||||
|
||||
MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3"
|
||||
NPZ = GOLDEN_STATEMENTS / "npz" / "2025-Q3"
|
||||
SYNTH = GOLDEN_STATEMENTS / "synthetic"
|
||||
@ -312,9 +317,9 @@ async def test_duplicate_column_index_does_not_fail_mapping(
|
||||
)
|
||||
)
|
||||
).all()
|
||||
first = columns[0]
|
||||
assert sorted(i for i, _ in stored) == sorted({c["column_index"] for c in columns})
|
||||
assert (first["column_index"], first["label"]) in stored and (first["column_index"], "дубль") not in stored
|
||||
first = columns[0] # снимок сведён к 1.2: колонки пронумерованы с 0, повтор номера первой колонки — тоже 0
|
||||
assert sorted(i for i, _ in stored) == list(range(len({c["column_index"] for c in columns})))
|
||||
assert (0, first["label"]) in stored and (0, "дубль") not in stored
|
||||
file = await seeded.get(StoredFile, income.stored_file_id)
|
||||
assert file is not None and file.status is FileStatus.PROCESSED
|
||||
assert any("повторяется — оставлена первая" in w for w in await _mapping_warnings(seeded, income))
|
||||
@ -354,7 +359,7 @@ async def test_repeated_form_mapping_is_idempotent(seeded: AsyncSession, fixture
|
||||
file.status = FileStatus.PROCESSING
|
||||
await seeded.flush()
|
||||
chart = {code: key for code, key in await seeded.execute(select(ChartOfAccount.code, ChartOfAccount.id))}
|
||||
result = golden_result(path, job_id=job.id, file_id=file.id)
|
||||
result = golden_result_v1(path, job_id=job.id, file_id=file.id)
|
||||
|
||||
await map_file(
|
||||
seeded,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user