Адрес ячейки остаётся в message. Добавлен инвариант-тест по golden: line_refs предупреждений документа входят в ref строк, locator нет, column_index есть среди колонок. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
172 lines
8.2 KiB
Python
172 lines
8.2 KiB
Python
"""Golden на обезличенных входах Review Focus 1–5 (план 1, задача 20).
|
||
|
||
Входы `golden/*.candidate.json` собраны из `builders` один раз и лежат файлами; мусорный текст ячеек — дословно из
|
||
реальных распознаваний, названия, ИНН и суммы — обезличенные и синтетические. Эталон `*.expected.json` —
|
||
`to_jsonable(normalize_candidate(...).document)`; пересборка — `make golden-update` (`GOLDEN_UPDATE=1`).
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
from collections.abc import Callable
|
||
from decimal import Decimal as D
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from arm_statements import (
|
||
document_status,
|
||
normalize_candidate,
|
||
status_input,
|
||
status_input_from_contract,
|
||
to_contract,
|
||
)
|
||
from arm_statements.loader import Rules
|
||
from arm_statements.model import DocStatus, DocumentType, FormCandidate, LineStatus, StatementDocument
|
||
from arm_statements.serialize import candidate_from_dict, to_jsonable
|
||
from arm_statements.tests.builders import hdr, ocr
|
||
|
||
GOLDEN = Path(__file__).parent / "golden"
|
||
FULL_CONFIDENCE = 100.0
|
||
NAMES = ["aep3_balance", "aep3_income", "glued_balance", "kody_title_balance", "npz_income"]
|
||
HEAD = ["Наименование показателя", "Код", "На 30.09.2025"]
|
||
FULL_HEADER = hdr(organization_name="ООО «Пример»", inn="7700000000", report_date="30.09.2025", units="тыс. руб.")
|
||
|
||
|
||
def run(name: str, rules: Rules) -> StatementDocument:
|
||
data = json.loads((GOLDEN / f"{name}.candidate.json").read_text(encoding="utf-8"))
|
||
result = normalize_candidate(candidate_from_dict(data), rules)
|
||
assert result.document is not None
|
||
return result.document
|
||
|
||
|
||
@pytest.mark.parametrize("name", NAMES)
|
||
def test_golden_snapshot(name: str, rules: Rules) -> None:
|
||
actual = to_jsonable(run(name, rules))
|
||
target = GOLDEN / f"{name}.expected.json"
|
||
if os.environ.get("GOLDEN_UPDATE") == "1":
|
||
target.write_text(json.dumps(actual, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
pytest.fail(f"эталон {target.name} пересобран — просмотрите diff и запустите без GOLDEN_UPDATE")
|
||
assert actual == json.loads(target.read_text(encoding="utf-8"))
|
||
|
||
|
||
def assert_status_is_function_of_contract(doc: StatementDocument) -> None:
|
||
contract = json.loads(json.dumps(to_contract(doc), ensure_ascii=False))
|
||
assert status_input_from_contract(contract) == status_input(doc)
|
||
assert document_status(status_input(doc)) is doc.status
|
||
|
||
|
||
@pytest.mark.parametrize("name", NAMES)
|
||
def test_status_is_function_of_contract_document(name: str, rules: Rules) -> None:
|
||
assert_status_is_function_of_contract(run(name, rules))
|
||
|
||
|
||
def _disclosure() -> FormCandidate:
|
||
return ocr([["Текст"]], type_hint=DocumentType.DISCLOSURE)
|
||
|
||
|
||
def _without_lines() -> FormCandidate:
|
||
return ocr([["Итого", "15 000"]], type_hint=DocumentType.BALANCE, header=FULL_HEADER)
|
||
|
||
|
||
def _absolute_column_without_report_date() -> FormCandidate:
|
||
rows = [
|
||
HEAD,
|
||
["Запасы", "1210", "60"],
|
||
["Дебиторы", "1230", "40"],
|
||
["Итого II", "1200", "100"],
|
||
]
|
||
header = hdr(organization_name="ООО «Пример»", inn="7700000000", units="тыс. руб.")
|
||
return ocr(rows, type_hint=DocumentType.BALANCE, header=header)
|
||
|
||
|
||
EXTRA: dict[str, Callable[[], FormCandidate]] = {
|
||
"disclosure": _disclosure,
|
||
"without_lines": _without_lines,
|
||
"absolute_column_without_report_date": _absolute_column_without_report_date,
|
||
}
|
||
|
||
|
||
@pytest.mark.parametrize("case", sorted(EXTRA))
|
||
def test_status_is_function_of_contract_document_edge_cases(case: str, rules: Rules) -> None:
|
||
doc = normalize_candidate(EXTRA[case](), rules).document
|
||
assert doc is not None
|
||
assert_status_is_function_of_contract(doc)
|
||
|
||
|
||
def test_edge_cases_cover_their_branch(rules: Rules) -> None:
|
||
disclosure = normalize_candidate(_disclosure(), rules).document
|
||
assert disclosure is not None and disclosure.document_type is DocumentType.DISCLOSURE
|
||
assert disclosure.status is DocStatus.DONE
|
||
empty = normalize_candidate(_without_lines(), rules).document
|
||
assert empty is not None and empty.lines == ()
|
||
assert [w.code for w in empty.warnings if w.code == "CODE_COLUMN_NOT_FOUND"] == ["CODE_COLUMN_NOT_FOUND"]
|
||
assert status_input(empty).layout_found is False and empty.status is DocStatus.NEEDS_CHECK
|
||
absolute = normalize_candidate(_absolute_column_without_report_date(), rules).document
|
||
assert absolute is not None and absolute.header.report_date.value is None
|
||
assert status_input(absolute).period_known is True
|
||
|
||
|
||
def test_aep3_junk_codes(rules: Rules) -> None: # Review Focus 1
|
||
doc = run("aep3_balance", rules)
|
||
by_text = {ln.line_code_text: ln for ln in doc.lines}
|
||
assert (by_text["| 140"].line_status, by_text["| 140"].line_code) == (LineStatus.OK, "1140")
|
||
confidence = by_text["| 140"].line_code_confidence
|
||
assert confidence is not None and confidence < FULL_CONFIDENCE
|
||
for junk in ("НИ", "изо :"):
|
||
assert by_text[junk].line_status is LineStatus.DETAIL and by_text[junk].parent_ref is not None
|
||
refs = [ln.ref for ln in doc.lines]
|
||
assert len(refs) == len(set(refs)) and [ln.row_index for ln in doc.lines] == list(range(len(doc.lines)))
|
||
assert by_text["НИ"].parent_ref == by_text["1110"].ref and by_text["изо :"].parent_ref == by_text["| 140"].ref
|
||
assert len(doc.lines) == len(json.loads((GOLDEN / "aep3_balance.candidate.json").read_text("utf-8"))["rows"]) - 1
|
||
|
||
|
||
def test_aep3_dirty_income_amounts(rules: Rules) -> None: # Review Focus 2
|
||
doc = run("aep3_income", rules)
|
||
values = {v.text: v for ln in doc.lines for v in ln.values if v.text}
|
||
assert (values["(7744536}"].amount, values["(7744536}"].needs_review) == (D("-7744536"), False)
|
||
assert (values["211(1 |"].amount, values["211(1 |"].needs_review) == (None, True)
|
||
assert (values["42831 1"].amount, values["42831 1"].needs_review) == (D("428311"), False)
|
||
assert doc.checks is not None and doc.checks.relations_failed == ()
|
||
assert doc.status is DocStatus.NEEDS_CHECK
|
||
|
||
|
||
def test_glued_rows(rules: Rules) -> None: # Review Focus 3
|
||
doc = run("glued_balance", rules)
|
||
split = [ln for ln in doc.lines if "#" in ln.ref]
|
||
assert [(ln.line_code, ln.values[0].amount) for ln in split] == [("1500", D("917775")), ("1700", D("2371333"))]
|
||
assert [ln.ref for ln in split] == ["row:13#1", "row:13#2"]
|
||
assert len({ln.ref for ln in doc.lines}) == len(doc.lines)
|
||
assert [w.code for w in doc.warnings if w.code == "ROW_SPLIT"] == ["ROW_SPLIT"]
|
||
assert not [v for ln in doc.lines for v in ln.values if v.needs_review]
|
||
assert not [ln for ln in doc.lines if ln.needs_review]
|
||
|
||
|
||
def test_kody_title_block(rules: Rules) -> None: # Review Focus 4
|
||
doc = run("kody_title_balance", rules)
|
||
assert "12267" not in [ln.line_code for ln in doc.lines]
|
||
assert doc.header.okopf.value == "12267" and doc.status is DocStatus.DONE
|
||
assert not [w for w in doc.warnings if w.code == "CODE_COLUMN_NOT_FOUND"]
|
||
assert all(ln.ref.startswith("Баланс!C") for ln in doc.lines)
|
||
|
||
|
||
def test_npz_continuation_single_code(rules: Rules) -> None: # Review Focus 5
|
||
doc = run("npz_income", rules)
|
||
(line_2400,) = [ln for ln in doc.lines if ln.line_code == "2400"]
|
||
assert [v.column_index for v in line_2400.values] == [c.column_index for c in doc.columns]
|
||
assert all(v.amount is not None for v in line_2400.values)
|
||
assert [v.amount for v in line_2400.values] == [D("264"), D("238")]
|
||
assert "," in doc.locator and not [w for w in doc.warnings if w.code.startswith("SEGMENT_")]
|
||
assert doc.status is DocStatus.DONE
|
||
|
||
|
||
@pytest.mark.parametrize("name", NAMES)
|
||
def test_document_warnings_reference_document_parts(name: str, rules: Rules) -> None:
|
||
"""I-2: line_refs предупреждения — ref строк документа; locator нет; column_index — из колонок."""
|
||
doc = run(name, rules)
|
||
line_refs = {ln.ref for ln in doc.lines}
|
||
column_indexes = {c.column_index for c in doc.columns}
|
||
for warning in doc.warnings:
|
||
assert set(warning.line_refs) <= line_refs, warning
|
||
assert warning.locator is None, warning
|
||
assert warning.column_index is None or warning.column_index in column_indexes, warning
|