Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
160 lines
7.3 KiB
Python
160 lines
7.3 KiB
Python
from datetime import date
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
import src.normalizer.ocr.answer_set as answer_set_module
|
||
from arm_statements import DocStatus, DocumentType, EnvelopeStatus, PeriodKind, Severity, default_rules
|
||
from src.contract import ErrorCode
|
||
from src.normalizer.ocr.answer_set import NO_TABLES_MESSAGE, normalize_answer_set
|
||
from tests.unit.normalizer.ocr_builders import BAD, BALANCE, HEAD, INCOME, OK, merged, ocr_table, response
|
||
|
||
RULES = default_rules()
|
||
|
||
|
||
def test_one_json_two_forms_copy_header() -> None:
|
||
res = normalize_answer_set(
|
||
merged(
|
||
(
|
||
"j.json",
|
||
response(
|
||
ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME)
|
||
),
|
||
)
|
||
),
|
||
RULES,
|
||
)
|
||
assert [(d.locator, d.document_type, d.header.form_name) for d in res.documents] == [
|
||
("file:j.json;table:0", DocumentType.BALANCE, "Бухгалтерский баланс"),
|
||
("file:j.json;table:1", DocumentType.INCOME, "Отчет о финансовых результатах"),
|
||
]
|
||
assert {d.header.inn.value for d in res.documents} == {"7700000000"}
|
||
assert res.documents[0].status is DocStatus.DONE and res.error is None
|
||
|
||
|
||
def test_lost_ofr_json_is_file_error() -> None:
|
||
res = normalize_answer_set(
|
||
merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE))), ("j_ofr.json", response(error=BAD))),
|
||
RULES,
|
||
)
|
||
assert res.status is EnvelopeStatus.NEEDS_CHECK and len(res.documents) == 1
|
||
assert [(w.code, w.severity, w.locator, w.message) for w in res.file_warnings] == [
|
||
("JSON_FAILED", Severity.ERROR, "file:j_ofr.json", "INVALID_DOCUMENT: Не удалось определить тип документа.")
|
||
]
|
||
|
||
|
||
def test_table_exception_is_file_warning(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
real = answer_set_module.normalize_candidate
|
||
|
||
def flaky(candidate: Any, rules: Any = None) -> Any:
|
||
if candidate.locator.endswith("table:1"):
|
||
raise RuntimeError("сломалось")
|
||
return real(candidate, rules)
|
||
|
||
monkeypatch.setattr(answer_set_module, "normalize_candidate", flaky)
|
||
res = normalize_answer_set(
|
||
merged(
|
||
(
|
||
"j.json",
|
||
response(
|
||
ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME)
|
||
),
|
||
)
|
||
),
|
||
RULES,
|
||
)
|
||
assert [d.document_type for d in res.documents] == [DocumentType.BALANCE]
|
||
[w] = res.file_warnings
|
||
assert (w.code, w.severity, w.locator) == ("TABLE_PARSE_ERROR", Severity.ERROR, "file:j.json;table:1")
|
||
assert w.message == "BACKEND_PARSING_ERROR: RuntimeError: сломалось"
|
||
assert res.status is EnvelopeStatus.NEEDS_CHECK
|
||
|
||
|
||
def test_broken_table_structure_is_table_parse_error() -> None:
|
||
res = normalize_answer_set(
|
||
merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), {"label": {}, "rows": "x"}))), RULES
|
||
)
|
||
assert [w.code for w in res.file_warnings] == ["TABLE_PARSE_ERROR"] and len(res.documents) == 1
|
||
|
||
|
||
def test_text_only_table_skipped_and_no_documents() -> None:
|
||
res = normalize_answer_set(merged(("j.json", response(ocr_table("", [("Руководитель", "Иванов")])))), RULES)
|
||
assert [(w.code, w.severity, w.locator) for w in res.file_warnings] == [
|
||
("TABLE_SKIPPED", Severity.INFO, "file:j.json;table:0")
|
||
]
|
||
assert res.status is EnvelopeStatus.ERROR
|
||
assert (res.error.code, res.error.message) == (ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE)
|
||
|
||
|
||
def test_lost_table_with_numbers() -> None:
|
||
res = normalize_answer_set(
|
||
merged(
|
||
("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("", [("Итого", "", "15 000")])))
|
||
),
|
||
RULES,
|
||
)
|
||
assert [(w.code, w.severity, w.locator) for w in res.file_warnings] == [
|
||
("TABLE_LOST", Severity.ERROR, "file:j.json;table:1")
|
||
]
|
||
assert res.status is EnvelopeStatus.NEEDS_CHECK
|
||
|
||
|
||
def test_all_json_failed_first_code_and_all_listed() -> None:
|
||
res = normalize_answer_set(merged(("j.json", response(error=BAD)), ("j_ofr.json", "не объект")), RULES)
|
||
assert res.status is EnvelopeStatus.ERROR and res.documents == ()
|
||
assert (res.error.code, res.error.message) == (ErrorCode.INVALID_DOCUMENT, BAD["message"])
|
||
assert [(w.code, w.locator) for w in res.file_warnings] == [
|
||
("JSON_FAILED", "file:j.json"),
|
||
("JSON_FAILED", "file:j_ofr.json"),
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("data_ocr", "message"),
|
||
[
|
||
(None, "ответ ИС без data_ocr"),
|
||
({**HEAD, "table_structure": None}, "ответ ИС без table_structure"),
|
||
({**HEAD, "table_structure": []}, "в ответе ИС нет таблиц"),
|
||
],
|
||
)
|
||
def test_successful_json_without_tables(data_ocr: object, message: str) -> None:
|
||
res = normalize_answer_set(merged(("j.json", {"error": OK, "data_ocr": data_ocr})), RULES)
|
||
assert [(w.code, w.severity, w.message) for w in res.file_warnings] == [("JSON_FAILED", Severity.ERROR, message)]
|
||
assert res.error is not None and res.error.code is ErrorCode.RECOGNITION_FAILED
|
||
|
||
|
||
@pytest.mark.parametrize("raw", [None, {"files": "x"}, {"job_id": "j"}])
|
||
def test_not_merged(raw: object) -> None:
|
||
res = normalize_answer_set(raw, RULES)
|
||
assert res.status is EnvelopeStatus.ERROR and res.error is not None
|
||
assert res.error.code is ErrorCode.INVALID_FORMAT
|
||
|
||
|
||
# Сквозной тест дат шлюза: строки OCR из test_parses_real_forms (правило — в пакете, план 1, задача 5)
|
||
@pytest.mark.parametrize(
|
||
("label", "expected"),
|
||
[
|
||
("На 30 июня\n2025 г", date(2025, 6, 30)),
|
||
("За январь - июнь\n2025 г.", date(2025, 6, 30)),
|
||
("30 сентября 2025 r.", date(2025, 9, 30)),
|
||
("3l.12.2О24", date(2024, 12, 31)),
|
||
("9 мес. 2025", date(2025, 9, 30)),
|
||
("30.09.2025 0:00:00", date(2025, 9, 30)),
|
||
],
|
||
)
|
||
def test_ocr_column_dates_end_to_end(label: str, expected: date) -> None:
|
||
rows = [("Наименование показателя", "Код", label), *BALANCE[1:]]
|
||
head = {**HEAD, "date": {"form_name": "2|0|2|4|", "confidence": 75.0}}
|
||
res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", rows), head=head))), RULES)
|
||
doc = res.documents[0]
|
||
assert [(c.period_kind, c.period_date) for c in doc.columns] == [(PeriodKind.ABSOLUTE, expected)]
|
||
assert doc.header.report_date.value == date(2024, 12, 31)
|
||
|
||
|
||
@pytest.mark.parametrize("second", [response(), response(ocr_table("", [("Руководитель", "Иванов")]))])
|
||
def test_one_failed_one_successful_without_documents_is_recognition_failed(second: dict[str, Any]) -> None:
|
||
res = normalize_answer_set(merged(("j.json", response(error=BAD)), ("j_ofr.json", second)), RULES)
|
||
assert res.status is EnvelopeStatus.ERROR and res.documents == ()
|
||
assert (res.error.code, res.error.message) == (ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE)
|
||
assert ("JSON_FAILED", "file:j.json") in [(w.code, w.locator) for w in res.file_warnings]
|