hierarchy.py: BodyRow, Levels, стратегии outline (сдвиг к 0), indent (ранги различных отступов — единицы alignment.indent и пробелов не смешиваются), account_pattern (ранг кода + жирность, ASSUMPTION в docstring), sums (жадно, без рекурсии); is_consistent, parents (по позициям и уровням, Review Focus 3), resolve_levels: «Итого» в стратегии не передаётся и получает уровень 0; меньше двух строк кроме «Итого» — дерево «trivial». deadline.py: кооперативный Deadline (R-3). Unit-тесты; fixtures-тест — в Задаче 8 (R-2). Отметки плана и tasks.md 2.3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
394 lines
14 KiB
Python
394 lines
14 KiB
Python
"""Каскад стратегий иерархии строк (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 (
|
||
STRATEGIES,
|
||
TRIVIAL_STRATEGY,
|
||
BodyRow,
|
||
Levels,
|
||
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)
|