Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
211 lines
10 KiB
Python
211 lines
10 KiB
Python
"""Проверки §7 ядра (spec statement-checks; design D-4, D-5): перенос проверок tabular-parser."""
|
||
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
|
||
from src.domain.statement_checks import (
|
||
ChecksReport,
|
||
FailedCheck,
|
||
cross_form_check,
|
||
load_check_rules,
|
||
run_checks,
|
||
)
|
||
|
||
D = Decimal
|
||
RULES = load_check_rules()
|
||
|
||
|
||
def _lines(*items: tuple[str, dict[int, str | None]]) -> list[tuple[str, dict[int, Decimal | None]]]:
|
||
return [(code, {i: (D(v) if v is not None else None) for i, v in values.items()}) for code, values in items]
|
||
|
||
|
||
def test_rules_loaded_from_copy() -> None:
|
||
assert RULES.tolerance == D("1")
|
||
assert RULES.rounding_tolerance_percent == D("0.001")
|
||
relations = [rule.relation for rule in RULES.relations_for("BALANCE")]
|
||
assert "1600 = 1100 + 1200" in relations and "1600 = 1700" in relations
|
||
income = {rule.relation: rule for rule in RULES.relations_for("INCOME")}
|
||
assert income["2100 = 2110 - 2120"].abs_terms == frozenset({"2120"})
|
||
assert income["2400 = 2300 - 2410 + 2420"].strict is False
|
||
|
||
|
||
def test_hard_violation() -> None:
|
||
report = run_checks(
|
||
"BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "1000"})), [0], RULES
|
||
)
|
||
assert FailedCheck(0, "1600 = 1100 + 1200", D("1000"), D("900")) in report.failed
|
||
|
||
|
||
def test_relation_skipped_without_target() -> None:
|
||
report = run_checks("BALANCE", _lines(("1110", {0: "5"}), ("1150", {0: "7"})), [0], RULES)
|
||
assert report == ChecksReport(failed=(), soft_failed=())
|
||
|
||
|
||
def test_tolerance_one_unit() -> None:
|
||
report = run_checks("BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"})), [0], RULES)
|
||
assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed)
|
||
|
||
|
||
def test_abs_terms_minus_sign() -> None:
|
||
report = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "-60"}), ("2100", {0: "40"})), [0], RULES)
|
||
assert all(item.relation != "2100 = 2110 - 2120" for item in report.failed)
|
||
|
||
|
||
def test_rounding_goes_soft() -> None:
|
||
# расхождение 5 при числах ~1 000 000: 5 ≤ 1 000 000 × 0,001 % = 10 → мягкое «в пределах округления»
|
||
report = run_checks(
|
||
"BALANCE", _lines(("1100", {0: "500000"}), ("1200", {0: "500000"}), ("1600", {0: "1000005"})), [0], RULES
|
||
)
|
||
assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed)
|
||
assert any(item.relation == "1600 = 1100 + 1200" for item in report.soft_failed)
|
||
|
||
|
||
def test_soft_relation_not_hard() -> None:
|
||
report = run_checks("INCOME", _lines(("2300", {0: "100"}), ("2410", {0: "20"}), ("2400", {0: "50"})), [0], RULES)
|
||
assert all(item.relation != "2400 = 2300 - 2410 + 2420" for item in report.failed)
|
||
assert any(item.relation == "2400 = 2300 - 2410 + 2420" for item in report.soft_failed)
|
||
|
||
|
||
def test_sublines_soft() -> None:
|
||
report = run_checks("BALANCE", _lines(("1150", {0: "10"}), ("11501", {0: "6"}), ("11502", {0: "2"})), [0], RULES)
|
||
assert any(item.relation == "1150 = 11501 + 11502" for item in report.soft_failed)
|
||
assert report.failed == ()
|
||
|
||
|
||
def test_target_present_no_terms_hard_failure() -> None:
|
||
# Спека: отсутствующий код = 0; если цель присутствует но членов нет, это нарушение
|
||
# Правило 1600 = 1100 + 1200, в колонке только 1600=1000 → 1600 ≠ 0, жёсткое нарушение
|
||
report = run_checks("BALANCE", _lines(("1600", {0: "1000"})), [0], RULES)
|
||
assert any(
|
||
item.relation == "1600 = 1100 + 1200" and item.expected == D("1000") and item.actual == D("0")
|
||
for item in report.failed
|
||
)
|
||
|
||
|
||
def test_target_without_self_sum_hard_failure() -> None:
|
||
# Спека: пропуск только если цель отсутствует; если цель есть, правило проверяется
|
||
# Правило 1600 = 1700, в колонке только 1600=1000 (нет 1300, 1400, 1500) → нарушение
|
||
report = run_checks("BALANCE", _lines(("1600", {0: "1000"})), [0], RULES)
|
||
assert any(
|
||
item.relation == "1600 = 1700" and item.expected == D("1000") and item.actual == D("0")
|
||
for item in report.failed
|
||
)
|
||
|
||
|
||
def _by_relation(items: tuple[FailedCheck, ...], relation: str) -> list[FailedCheck]:
|
||
return [item for item in items if item.relation == relation]
|
||
|
||
|
||
def test_relation_checked_per_column_and_none_is_zero() -> None:
|
||
# колонка 0: 900 = 300 + 600; колонка 1: 1000 ≠ 900; колонка 2: 300 = 300 + None(=0)
|
||
lines = _lines(
|
||
("1100", {0: "300", 1: "300", 2: "300"}),
|
||
("1200", {0: "600", 1: "600", 2: None}),
|
||
("1600", {0: "900", 1: "1000", 2: "300"}),
|
||
)
|
||
report = run_checks("BALANCE", lines, [0, 1, 2], RULES)
|
||
assert _by_relation(report.failed, "1600 = 1100 + 1200") == [
|
||
FailedCheck(1, "1600 = 1100 + 1200", D("1000"), D("900"))
|
||
]
|
||
assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == []
|
||
|
||
|
||
def test_tolerance_exceeded_by_one_unit_is_violation() -> None:
|
||
# допуск 1: 902 при 300 + 600 — расхождение 2 > 1, число мало, округление (0,001 %) не спасает
|
||
lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "902"}))
|
||
report = run_checks("BALANCE", lines, [0], RULES)
|
||
assert _by_relation(report.failed, "1600 = 1100 + 1200") == [
|
||
FailedCheck(0, "1600 = 1100 + 1200", D("902"), D("900"))
|
||
]
|
||
|
||
|
||
def test_tolerance_boundary_is_inclusive() -> None:
|
||
lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"}))
|
||
report = run_checks("BALANCE", lines, [0], RULES)
|
||
assert _by_relation(report.failed, "1600 = 1100 + 1200") == []
|
||
assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == []
|
||
|
||
|
||
def test_abs_terms_positive_expense_holds_and_mismatch_fails() -> None:
|
||
# 66н пишет расход в скобках, 1С — со знаком минус; здесь положительный 2120: 100 − 60 = 40 выполнено по модулю
|
||
holds = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "60"}), ("2100", {0: "40"})), [0], RULES)
|
||
assert _by_relation(holds.failed, "2100 = 2110 - 2120") == []
|
||
broken = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "60"}), ("2100", {0: "50"})), [0], RULES)
|
||
failed = _by_relation(broken.failed, "2100 = 2110 - 2120")
|
||
assert len(failed) == 1
|
||
assert (failed[0].column_index, failed[0].expected, failed[0].actual) == (0, D("50"), D("40"))
|
||
|
||
|
||
def test_subline_attached_to_last_parent_by_row_order() -> None:
|
||
# M-9: 12310 и 12320 идут под строкой 1230, хотя префикса 1231/1232 среди четырёхзначных кодов нет
|
||
lines = _lines(("1230", {0: "10"}), ("12310", {0: "6"}), ("12320", {0: "2"}))
|
||
report = run_checks("BALANCE", lines, [0], RULES)
|
||
assert [item.relation for item in _by_relation(report.soft_failed, "1230 = 12310 + 12320")] == [
|
||
"1230 = 12310 + 12320"
|
||
]
|
||
|
||
|
||
def test_subline_prefers_last_parent_over_prefix() -> None:
|
||
# 12310 стоит под 1230 (ближайшая строка выше с теми же тремя цифрами), а не под 1231, хотя префикс 1231 есть
|
||
lines = _lines(("1231", {0: "5"}), ("1230", {0: "10"}), ("12310", {0: "6"}), ("12320", {0: "2"}))
|
||
report = run_checks("BALANCE", lines, [0], RULES)
|
||
relations = {item.relation for item in report.soft_failed}
|
||
assert "1230 = 12310 + 12320" in relations
|
||
assert not any(relation.startswith("1231 =") for relation in relations)
|
||
|
||
|
||
def test_rounding_threshold_boundary() -> None:
|
||
# порог = 0,001 % наибольшего по модулю числа соотношения; при итоге 10 000 000 он ровно 100
|
||
assert D("10000000") * RULES.rounding_tolerance_percent / D("100") == D("100")
|
||
halves = {0: "4999950"}
|
||
at_threshold = run_checks(
|
||
"BALANCE", _lines(("1100", halves), ("1200", halves), ("1600", {0: "10000000"})), [0], RULES
|
||
)
|
||
assert _by_relation(at_threshold.failed, "1600 = 1100 + 1200") == []
|
||
assert [item.expected - item.actual for item in _by_relation(at_threshold.soft_failed, "1600 = 1100 + 1200")] == [
|
||
D("100")
|
||
]
|
||
above = run_checks("BALANCE", _lines(("1100", halves), ("1200", halves), ("1600", {0: "10000001"})), [0], RULES)
|
||
assert [item.expected - item.actual for item in _by_relation(above.failed, "1600 = 1100 + 1200")] == [D("101")]
|
||
assert _by_relation(above.soft_failed, "1600 = 1100 + 1200") == []
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("b1370", "i2400", "expected"),
|
||
[
|
||
((D("500"), D("300")), D("200"), None),
|
||
(
|
||
(D("500"), D("300")),
|
||
D("150"),
|
||
FailedCheck(-1, "1370 − 1370(начало года) = 2400", D("200"), D("150")),
|
||
),
|
||
((None, D("300")), D("200"), None),
|
||
((D("500"), D("300")), None, None),
|
||
],
|
||
)
|
||
def test_cross_form(
|
||
b1370: tuple[Decimal | None, Decimal | None], i2400: Decimal | None, expected: FailedCheck | None
|
||
) -> None:
|
||
assert cross_form_check(b1370, i2400, D("1")) == expected
|
||
|
||
|
||
def test_broken_rules_file(tmp_path) -> None:
|
||
bad = tmp_path / "statement_checks.yaml"
|
||
bad.write_text("tolerance: 1\nrelations:\n - {form: BALANCE, relation: '16A0 = 1100', strict: true}\n", "utf-8")
|
||
with pytest.raises(ValueError, match="statement_checks.yaml"):
|
||
load_check_rules(bad)
|
||
|
||
|
||
def test_target_with_empty_cell_counts_as_zero_and_is_checked() -> None:
|
||
"""M-1 (откат): цель присутствует, если строка есть в форме; пустая ячейка (`None`) = 0 — как в tabular-parser
|
||
(`_amounts`, null -> 0): оба производителя отдают значение на каждую колонку каждой кодированной строки, пустая
|
||
или нераспознанная ячейка итога при ненулевых слагаемых — жёсткое нарушение (а не пропуск правила)."""
|
||
terms = (("1100", {0: "300", 1: "300"}), ("1200", {0: "600", 1: "600"}))
|
||
report = run_checks("BALANCE", _lines(*terms, ("1600", {0: None, 1: "0"})), [0, 1], RULES)
|
||
failed = _by_relation(report.failed, "1600 = 1100 + 1200")
|
||
assert [(item.column_index, item.expected, item.actual) for item in failed] == [
|
||
(0, D("0"), D("900")),
|
||
(1, D("0"), D("900")),
|
||
]
|