Удалён пропуск явных правил, когда в колонке нет членов правой части: спека statement-checks требует считать отсутствующий код нулём, при ненулевой цели — жёсткое нарушение. Тест плана test_per_column_and_null_as_zero противоречил спеке (1600 = 1700 без строки 1700 — нарушение) и заменён двумя тестами по спеке. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
120 lines
5.1 KiB
Python
120 lines
5.1 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
|
||
)
|
||
|
||
|
||
@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)
|