diff --git a/api/src/parser/book.py b/api/src/parser/book.py index 9899874..becfe08 100644 --- a/api/src/parser/book.py +++ b/api/src/parser/book.py @@ -143,7 +143,7 @@ class BookParser: if read.no_codepage: warnings.append(_file_warning(make_warning(WarningCode.XLS_NO_CODEPAGE, NO_CODEPAGE_WARNING, rules))) sheets = read.sheets - plan = plan_sheets(sheets, lambda sheet: is_osv_sheet(sheet, self._rules), rules) + plan = plan_sheets(sheets, lambda sheet: is_osv_sheet(sheet, self._rules), rules, deadline) osv_names = {sheet.name for sheet in plan.osv} other_names = {sheet.name for sheet in plan.other} reader_notes = _reader_notes(read) diff --git a/api/src/parser/statements/adapter.py b/api/src/parser/statements/adapter.py index cbf59cf..ff1a83d 100644 --- a/api/src/parser/statements/adapter.py +++ b/api/src/parser/statements/adapter.py @@ -53,7 +53,9 @@ class SheetPlan: other: tuple[Sheet, ...] -def plan_sheets(sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules) -> SheetPlan: +def plan_sheets( + sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules, deadline: Deadline +) -> SheetPlan: """Листы по порядку книги: ОСВ; форма; продолжение (в том числе слабое — лист из 1–2 кодов сразу после листа формы или продолжения); прочее. Лист ОСВ или «прочее» прерывает «сразу после».""" osv: list[Sheet] = [] @@ -61,6 +63,7 @@ def plan_sheets(sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: other: list[Sheet] = [] last_type: DocumentType | None = None # тип последнего блока последнего листа формы; None — формы «сразу до» нет for sheet in sheets: + deadline.check(f"лист «{sheet.name}»: вид листа") if is_osv(sheet): osv.append(sheet) last_type = None diff --git a/api/tests/unit/parser/statements/test_adapter.py b/api/tests/unit/parser/statements/test_adapter.py index 9c8d208..b0950b2 100644 --- a/api/tests/unit/parser/statements/test_adapter.py +++ b/api/tests/unit/parser/statements/test_adapter.py @@ -138,11 +138,11 @@ def test_plan_sheets_weak_continuation_only_after_form(rules: StatementRules) -> def is_osv(sheet: Sheet) -> bool: return sheet.name == "ОСВ" - plan = plan_sheets([ofr, weak], is_osv, rules) + plan = plan_sheets([ofr, weak], is_osv, rules, NO_DEADLINE) assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT), ("2400", CONTINUATION)] - plan = plan_sheets([osv, weak], is_osv, rules) + plan = plan_sheets([osv, weak], is_osv, rules, NO_DEADLINE) assert (plan.osv, plan.statements, plan.other) == ((osv,), (), (weak,)) - plan = plan_sheets([ofr, notes, weak], is_osv, rules) + plan = plan_sheets([ofr, notes, weak], is_osv, rules, NO_DEADLINE) assert [s.name for s in plan.other] == ["Примечания", "2400"] diff --git a/api/tests/unit/parser/test_book_parser.py b/api/tests/unit/parser/test_book_parser.py index 0f7a2be..de0d974 100644 --- a/api/tests/unit/parser/test_book_parser.py +++ b/api/tests/unit/parser/test_book_parser.py @@ -536,6 +536,61 @@ def test_deadline_exceeded_is_timeout(rules: Rules) -> None: assert "превышен лимит" in outcome.error.message +def test_sheet_kind_classification_is_interrupted_by_deadline(rules: Rules) -> None: + """I-1: классификация листов (`plan_sheets`) проверяет дедлайн на каждом листе, книга из «прочего» — TIMEOUT.""" + notes: list[Cells] = [["Примечания"], ["Текст без кодов и сумм"]] + clock = itertools.count(step=CLOCK_STEP) + content = xlsx({"Лист 1": notes, "Лист 2": notes, "Лист 3": notes}) + + outcome = book(rules, timeout_seconds=SHORT_TIMEOUT, clock=lambda: float(next(clock))).parse(content, "XLSX") + + assert outcome.error is not None + assert outcome.error.code is ErrorCode.TIMEOUT + assert "вид листа" in outcome.error.message + + +def test_package_exception_on_candidate_is_recognition_failed_with_collected_warnings( + rules: Rules, monkeypatch: pytest.MonkeyPatch +) -> None: + """I-2(а), P4-11: исключение пакета на кандидате — ошибка книги, предупреждения файла до него сохраняются.""" + + def broken(candidate: object, statement_rules: object) -> object: + raise ValueError("сбой пакета\nвторая строка") + + monkeypatch.setattr("src.parser.statements.pipeline.normalize_candidate", broken) + notes: list[Cells] = [["Примечания"], ["Текст без кодов и сумм"]] + + outcome = book(rules).parse(xlsx({"Примечания": notes, "ББ": BALANCE_ROWS}), "XLSX") + + assert outcome.status is ResultStatus.ERROR + assert outcome.error is not None + assert outcome.error.code is ErrorCode.RECOGNITION_FAILED + assert outcome.error.message == "ValueError: сбой пакета вторая строка" + assert [(w.code, w.locator) for w in file_warnings_of(outcome)] == [("SHEET_OTHER", "sheet:Примечания")] + + +def test_deadline_on_forms_path_is_timeout_in_adapter(rules: Rules) -> None: + """I-2(б): дедлайн истекает на сборке кандидатов адаптера (после классификации листа).""" + ticks = itertools.chain([0.0] * 3, itertools.repeat(1e9)) # старт, создание дедлайна, вид листа + + outcome = book(rules, timeout_seconds=100, clock=lambda: next(ticks)).parse(xlsx({"ББ": BALANCE_ROWS}), "XLSX") + + assert outcome.error is not None + assert outcome.error.code is ErrorCode.TIMEOUT + assert "форма" in outcome.error.message + + +def test_deadline_on_forms_path_is_timeout_in_pipeline(rules: Rules) -> None: + """I-2(б): дедлайн истекает перед нормализацией кандидата в `StatementPipeline`.""" + ticks = itertools.chain([0.0] * 4, itertools.repeat(1e9)) # + проверка адаптера + + outcome = book(rules, timeout_seconds=100, clock=lambda: next(ticks)).parse(xlsx({"ББ": BALANCE_ROWS}), "XLSX") + + assert outcome.error is not None + assert outcome.error.code is ErrorCode.TIMEOUT + assert "форма «sheet:ББ»" in outcome.error.message + + def test_unexpected_exception_is_recognition_failed_in_one_line(rules: Rules, monkeypatch: pytest.MonkeyPatch) -> None: def broken(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: raise ValueError("сбой стратегии\nвторая строка") diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py index 9b1b3ef..bc3188d 100644 --- a/api/tests/unit/test_worker.py +++ b/api/tests/unit/test_worker.py @@ -35,6 +35,7 @@ from src.contract import ( from src.core.config import NATSSettings from src.core.nats import NatsConnection, NatsPublishError from src.core.worker import RecognitionWorker, result_key +from arm_statements import RulesError from src.parser import NullParser, ParseOutcome, get_parser, require_statements_version API_DIR = Path(__file__).resolve().parents[2] @@ -935,3 +936,15 @@ def test_require_statements_version_reads_the_marker(tmp_path: Path) -> None: assert require_statements_version(marker) == "abc1234" assert require_statements_version() # копия пакета в репозитории размечена sync-statements + + +def test_get_parser_wraps_rules_error_into_runtime_error(monkeypatch: pytest.MonkeyPatch) -> None: + """I-2(в): ошибка словарей пакета при старте — `RuntimeError`, сервис не стартует.""" + + def broken() -> object: + raise RulesError("битый словарь форм") + + monkeypatch.setattr("src.parser.default_rules", broken) + + with pytest.raises(RuntimeError, match="битый словарь форм"): + get_parser()