arm-tabular-parser/api/tests/unit/parser/test_hierarchy.py
Andrey Epifancev 68c5e2cce0 fix(parser): дедлайн внутри стратегий иерархии, тесты дедлайна (T6.1, T6.5)
strategy_sums проверяет Deadline каждые DEADLINE_CHECK_EVERY = 500 шагов
внутреннего перебора поддеревьев (худший случай O(n²) завершается TIMEOUT,
а не висит); линейные стратегии — каждые 500 строк (D-8). Strategy —
Protocol (rows, deadline=None); resolve_levels передаёт дедлайн.
Тесты: истечение внутри sums, проверка перед каждой стратегией, пакинг
линейных стратегий, часы по умолчанию — time.monotonic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 13:40:20 +04:00

459 lines
17 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.

"""Каскад стратегий иерархии строк (spec «Иерархия строк — каскад стратегий», design D-3, §1.0 п. 5, §1.3).
Строки данных — `BodyRow` (их строит классификатор Задачи 8); номера строк в тестах произвольные: стратегии и
`parents` работают по порядку и уровням строк, а не по номерам или текстам.
"""
import re
from collections.abc import Sequence
from decimal import Decimal
import pytest
from src.parser.deadline import Deadline
from src.parser.errors import InvalidFormatError, ParseTimeoutError
from src.parser.hierarchy import (
DEADLINE_CHECK_EVERY,
STRATEGIES,
TRIVIAL_STRATEGY,
BodyRow,
Levels,
Strategy,
is_consistent,
parents,
resolve_levels,
strategy_account_pattern,
strategy_indent,
strategy_outline,
strategy_sums,
)
ACCOUNT = re.compile(r"^(\d{2,3}(?:\.\d{1,2}){0,2})(?:,\s*.*)?$")
NO_SUMS: tuple[Decimal | None, ...] = (None,) * 6
FIRST = 17 # произвольное начало данных: стратегии не знают номеров строк
def money(*values: str | None) -> tuple[Decimal | None, ...]:
assert len(values) == len(NO_SUMS)
return tuple(None if value is None else Decimal(value) for value in values)
def body( # noqa: PLR0913 - фабрика тестовых строк: все признаки оформления именованные
row: int,
text: str,
*,
outline: int = 0,
indent: int = 0,
bold: bool = False,
sums: tuple[Decimal | None, ...] = NO_SUMS,
) -> BodyRow:
match = ACCOUNT.fullmatch(text)
rank = match.group(1).count(".") + 1 if match else None
return BodyRow(
row=row,
text=text,
is_account=match is not None,
account_rank=rank,
bold=bold,
indent=indent,
outline=outline,
sums=sums,
)
def numbered(*specs: tuple[str, dict[str, object]]) -> list[BodyRow]:
"""Строки подряд с произвольного номера `FIRST`."""
return [body(FIRST + offset, text, **options) for offset, (text, options) in enumerate(specs)] # type: ignore[arg-type]
def levels_list(rows: Sequence[BodyRow], by_row: dict[int, int]) -> list[int]:
return [by_row[item.row] for item in rows]
def never_expires() -> Deadline:
return Deadline(3600)
# --- Каскад: какая стратегия выбирается ---
def test_strategies_are_ordered_outline_indent_account_pattern_sums() -> None:
assert [name for name, _ in STRATEGIES] == ["outline", "indent", "account_pattern", "sums"]
def test_outline_levels_win_first() -> None:
rows = numbered(
("66", {"outline": 0, "indent": 3}),
("66.01", {"outline": 1}),
("Юг-Финанс", {"outline": 2}),
("66.03", {"outline": 1}),
("Юг-Финанс", {"outline": 2}),
)
levels = resolve_levels(rows, None, never_expires())
assert levels.strategy == "outline"
assert levels_list(rows, levels.by_row) == [0, 1, 2, 1, 2]
assert levels.parent == {17: None, 18: 17, 19: 18, 20: 17, 21: 20}
def test_outline_is_shifted_to_zero_when_table_is_grouped_entirely() -> None:
rows = numbered(("60", {"outline": 1}), ("60.01", {"outline": 2}), ("60.02", {"outline": 2}))
assert strategy_outline(rows) == {17: 0, 18: 1, 19: 1}
def test_outline_with_single_level_is_not_applicable() -> None:
rows = numbered(("60", {"outline": 0}), ("60.01", {"outline": 0}))
assert strategy_outline(rows) is None
def test_indent_is_used_when_outline_is_flat() -> None:
rows = numbered(
("Расчеты", {"indent": 0}),
("Поставщики", {"indent": 2}),
("ООО Ромашка", {"indent": 4}),
("Покупатели", {"indent": 2}),
)
levels = resolve_levels(rows, None, never_expires())
assert levels.strategy == "indent"
assert levels_list(rows, levels.by_row) == [0, 1, 2, 1]
@pytest.mark.parametrize(
"indents",
[(0, 2, 4), (0, 1, 2), (0, 3, 7)],
ids=["leading-spaces", "alignment-indent", "uneven-steps"],
)
def test_indent_levels_are_ranks_of_distinct_values_in_any_units(indents: tuple[int, int, int]) -> None:
top, middle, deep = indents
rows = numbered(
("Группа", {"indent": top}),
("Подгруппа", {"indent": middle}),
("Строка", {"indent": deep}),
("Строка 2", {"indent": deep}),
("Группа 2", {"indent": top}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 2, 21: 0}
def test_indent_with_single_value_is_not_applicable() -> None:
rows = numbered(("Группа", {"indent": 2}), ("Строка", {"indent": 2}))
assert strategy_indent(rows) is None
def test_account_pattern_when_no_outline_and_no_indent() -> None:
rows = numbered(("60", {}), ("60.01", {}), ("ООО Ромашка", {}), ("60.02", {}), ("ООО Лютик", {}))
levels = resolve_levels(rows, None, never_expires())
assert levels.strategy == "account_pattern"
assert levels_list(rows, levels.by_row) == [0, 1, 2, 1, 2]
def test_account_pattern_levels_are_relative_to_minimal_rank() -> None:
rows = numbered(("60.01", {}), ("60.01.1", {}), ("Контрагент", {}), ("60.02", {}))
assert strategy_account_pattern(rows) == {17: 0, 18: 1, 19: 2, 20: 0}
def test_account_pattern_account_with_name_after_comma_is_an_account() -> None:
rows = numbered(("60", {}), ("60.01, Расчеты с поставщиками", {}), ("ООО Ромашка", {}))
assert strategy_account_pattern(rows) == {17: 0, 18: 1, 19: 2}
def test_account_pattern_bold_groups_and_their_plain_details() -> None:
rows = numbered(
("60", {"bold": True}),
("60.01", {"bold": True}),
("ООО Ромашка", {"bold": True}),
("Договор 1", {}),
("Договор 2", {}),
("ООО Лютик", {"bold": True}),
("Договор 3", {}),
("60.02", {"bold": True}),
("ИП Иванов", {}),
)
assert levels_list(rows, strategy_account_pattern(rows) or {}) == [0, 1, 2, 3, 3, 2, 3, 1, 2]
def test_account_pattern_without_accounts_is_not_applicable() -> None:
rows = numbered(("ООО Ромашка", {}), ("Договор", {}))
assert strategy_account_pattern(rows) is None
def test_sums_parent_equals_sum_of_two_children() -> None:
rows = numbered(
("Расчеты", {"sums": money("100.00", None, "50.00", "30.00", "120.00", None)}),
("ООО Ромашка", {"sums": money("60.00", None, "50.00", None, "110.00", None)}),
("ООО Лютик", {"sums": money("40.00", None, None, "30.00", "10.00", None)}),
)
levels = resolve_levels(rows, None, never_expires())
assert levels.strategy == "sums"
assert levels_list(rows, levels.by_row) == [0, 1, 1]
assert levels.parent == {17: None, 18: 17, 19: 17}
def test_sums_builds_nested_tree_and_several_roots() -> None:
rows = numbered(
("Счёт", {"sums": money("100.00", None, None, None, "100.00", None)}),
("Субсчёт А", {"sums": money("70.00", None, None, None, "70.00", None)}),
("Контрагент 1", {"sums": money("30.00", None, None, None, "30.00", None)}),
("Контрагент 2", {"sums": money("40.00", None, None, None, "40.00", None)}),
("Субсчёт Б", {"sums": money("30.00", None, None, None, "30.00", None)}),
("Контрагент 3", {"sums": money("30.00", None, None, None, "30.00", None)}),
("Другой счёт", {"sums": money(None, "5.00", None, None, None, "5.00")}),
("Контрагент 4", {"sums": money(None, "5.00", None, None, None, "5.00")}),
)
assert levels_list(rows, strategy_sums(rows) or {}) == [0, 1, 2, 2, 1, 2, 0, 1]
def test_sums_without_any_matching_parent_is_not_applicable() -> None:
rows = numbered(
("А", {"sums": money("1.00", None, None, None, "1.00", None)}),
("Б", {"sums": money("2.00", None, None, None, "2.00", None)}),
)
assert strategy_sums(rows) is None
def test_inconsistent_outline_is_rejected_and_next_strategy_is_used() -> None:
# outline даёт первой строке уровень 1 — ребёнок без родителя; отступы дают корректное дерево
rows = numbered(
("Группа", {"outline": 1, "indent": 0}),
("Строка", {"outline": 0, "indent": 2}),
("Строка 2", {"outline": 1, "indent": 2}),
)
levels = resolve_levels(rows, None, never_expires())
assert levels.strategy == "indent"
assert levels_list(rows, levels.by_row) == [0, 1, 1]
def test_no_strategy_fits_raises_invalid_format() -> None:
rows = numbered(("Строка", {}), ("Строка 2", {}))
with pytest.raises(InvalidFormatError, match="иерархия строк не определена"):
resolve_levels(rows, None, never_expires())
# --- «Итого» ---
def test_total_is_level_zero_without_parent_even_if_formatted_deeper() -> None:
rows = numbered(
("62", {"outline": 0}),
("62.01", {"outline": 1}),
("Покупатель", {"outline": 2}),
("Итого", {"outline": 2, "indent": 4}),
)
total = rows[-1].row
levels = resolve_levels(rows, total, never_expires())
assert levels.strategy == "outline"
assert levels.by_row[total] == 0
assert levels.parent[total] is None
def test_total_is_not_a_child_of_last_account_in_account_pattern() -> None:
rows = numbered(("60", {}), ("60.01", {}), ("ООО Ромашка", {}), ("Итого", {}))
total = rows[-1].row
levels = resolve_levels(rows, total, never_expires())
assert levels.strategy == "account_pattern"
assert levels_list(rows, levels.by_row) == [0, 1, 2, 0]
assert levels.parent[total] is None
def test_total_does_not_break_sums_strategy() -> None:
rows = numbered(
("Расчеты", {"sums": money("10.00", None, None, None, "10.00", None)}),
("А", {"sums": money("4.00", None, None, None, "4.00", None)}),
("Б", {"sums": money("6.00", None, None, None, "6.00", None)}),
("Итого", {"sums": money("10.00", None, None, None, "10.00", None)}),
)
total = rows[-1].row
levels = resolve_levels(rows, total, never_expires())
assert levels.strategy == "sums"
assert levels_list(rows, levels.by_row) == [0, 1, 1, 0]
def test_table_with_only_total_has_trivial_hierarchy() -> None:
rows = numbered(("Итого", {"sums": money("0.00", None, None, None, "0.00", None)}))
levels = resolve_levels(rows, rows[0].row, never_expires())
assert levels == Levels(by_row={17: 0}, parent={17: None}, strategy=TRIVIAL_STRATEGY)
def test_single_row_without_total_has_trivial_hierarchy() -> None:
# пустая ОСВ, где «Итого» без сумм не распознано как TOTAL: одна строка — дерево из одного корня
rows = numbered(("Итого", {}))
levels = resolve_levels(rows, None, never_expires())
assert levels == Levels(by_row={17: 0}, parent={17: None}, strategy=TRIVIAL_STRATEGY)
def test_single_body_row_with_total_has_trivial_hierarchy() -> None:
rows = numbered(("58", {"outline": 1}), ("Итого", {}))
levels = resolve_levels(rows, rows[-1].row, never_expires())
assert levels == Levels(by_row={17: 0, 18: 0}, parent={17: None, 18: None}, strategy=TRIVIAL_STRATEGY)
def test_empty_table_has_trivial_hierarchy() -> None:
assert resolve_levels([], None, never_expires()) == Levels(by_row={}, parent={}, strategy=TRIVIAL_STRATEGY)
# --- is_consistent ---
def test_is_consistent_accepts_tree_with_root_first_and_total_zero() -> None:
rows = numbered(("60", {}), ("60.01", {}), ("А", {}), ("Итого", {}))
assert is_consistent(rows, {17: 0, 18: 1, 19: 2, 20: 0}, 20)
@pytest.mark.parametrize(
("by_row", "total"),
[
({17: 1, 18: 1, 19: 2, 20: 0}, 20), # первая строка не корень
({17: 0, 18: 1, 19: 2, 20: 1}, 20), # «Итого» не на уровне 0
({17: 0, 18: 1, 19: 2}, 20), # у «Итого» нет уровня
({17: 0, 18: 1, 20: 0}, 20), # у строки нет уровня
({17: 0, 18: -1, 19: 0, 20: 0}, 20), # отрицательный уровень
],
ids=["first-not-root", "total-nested", "total-missing", "row-missing", "negative"],
)
def test_is_consistent_rejects_broken_trees(by_row: dict[int, int], total: int) -> None:
rows = numbered(("60", {}), ("60.01", {}), ("А", {}), ("Итого", {}))
assert not is_consistent(rows, by_row, total)
def test_is_consistent_without_total() -> None:
rows = numbered(("60", {}), ("А", {}))
assert is_consistent(rows, {17: 0, 18: 1}, None)
# --- parents: по структуре, не по имени (Review Focus 3) ---
def test_parents_are_nearest_previous_rows_with_smaller_level() -> None:
rows = numbered(("60", {}), ("60.01", {}), ("А", {}), ("Договор", {}), ("Б", {}), ("62", {}), ("В", {}))
by_row = {17: 0, 18: 1, 19: 2, 20: 3, 21: 2, 22: 0, 23: 1}
assert parents(rows, by_row) == {17: None, 18: 17, 19: 18, 20: 19, 21: 18, 22: None, 23: 22}
def test_same_named_counterparties_under_different_subaccounts_have_different_parents() -> None:
rows = numbered(
("66", {"outline": 0}),
("66.01", {"outline": 1}),
("Юг-Финанс", {"outline": 2}),
("66.03", {"outline": 1}),
("Юг-Финанс", {"outline": 2}),
)
first, second = (item.row for item in rows if item.text == "Юг-Финанс")
for by_row in (strategy_outline(rows), strategy_account_pattern(rows)):
assert by_row is not None
tree = parents(rows, by_row)
assert tree[first] == rows[1].row # 66.01
assert tree[second] == rows[3].row # 66.03
# --- Дедлайн ---
def test_expired_deadline_raises_timeout() -> None:
now = [0.0]
deadline = Deadline(5, clock=lambda: now[0])
now[0] = 6.0
rows = numbered(("60", {"outline": 0}), ("60.01", {"outline": 1}))
with pytest.raises(ParseTimeoutError, match="иерархия"):
resolve_levels(rows, None, deadline)
CLOCK_READS_CREATE_AND_ONE_CHECK = 2 # чтение часов при создании Deadline + одна проверка на DEADLINE_CHECK_EVERY строк
class TickingClock:
"""Часы, которые сдвигаются на 1 с при каждом обращении: дедлайн истекает на заданной по счёту проверке."""
def __init__(self) -> None:
self.calls = 0
def __call__(self) -> float:
value = float(self.calls)
self.calls += 1
return value
@pytest.mark.parametrize("expired_at", range(len(STRATEGIES)), ids=[name for name, _ in STRATEGIES])
def test_deadline_is_checked_before_each_strategy(expired_at: int) -> None:
# ни одна стратегия не подходит, каскад проходит все четыре; строк меньше DEADLINE_CHECK_EVERY — проверки
# только между стратегиями: создание — t=0, перед k-й стратегией — t=k
rows = numbered(("Строка", {}), ("Строка 2", {}))
name = STRATEGIES[expired_at][0]
with pytest.raises(ParseTimeoutError) as raised:
resolve_levels(rows, None, Deadline(expired_at, clock=TickingClock()))
assert raised.value.message == f"превышен лимит {expired_at} с на шаге иерархия ({name})"
def _never_matching_sums(count: int) -> list[BodyRow]:
"""Строки без outline/отступов/счетов с растущими суммами: ни одна не равна сумме следующих — худший случай sums."""
return [
body(FIRST + offset, f"Строка {offset}", sums=money(str(offset + 1), None, None, None, None, None))
for offset in range(count)
]
def test_deadline_expires_inside_sums_scan() -> None:
rows = _never_matching_sums(60) # ≈ 1800 шагов перебора > DEADLINE_CHECK_EVERY, строк < DEADLINE_CHECK_EVERY
# четыре проверки между стратегиями (t=1…4) проходят; следующая — уже внутри перебора sums
deadline = Deadline(len(STRATEGIES), clock=TickingClock())
with pytest.raises(ParseTimeoutError, match=r"иерархия \(sums\): перебор поддеревьев"):
resolve_levels(rows, None, deadline)
def test_sums_without_deadline_finishes_worst_case() -> None:
assert strategy_sums(_never_matching_sums(60)) is None
@pytest.mark.parametrize(
("strategy", "name"),
[(strategy_outline, "outline"), (strategy_indent, "indent"), (strategy_account_pattern, "account_pattern")],
)
def test_linear_strategies_check_deadline_every_n_rows(strategy: Strategy, name: str) -> None:
rows = [body(FIRST + offset, "60") for offset in range(DEADLINE_CHECK_EVERY)]
clock = TickingClock()
deadline = Deadline(0, clock=clock) # истекает на первой же проверке
with pytest.raises(ParseTimeoutError, match=rf"иерархия \({name}\)"):
strategy(rows, deadline)
assert clock.calls == CLOCK_READS_CREATE_AND_ONE_CHECK