Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
168 lines
7.0 KiB
Python
168 lines
7.0 KiB
Python
import re
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
import yaml
|
||
|
||
from arm_statements.issues import WarningCode, make_warning
|
||
from arm_statements.loader import LABEL_KEYS, Rules, RulesError, load_rules
|
||
from arm_statements.model import DocumentType, Edition, FormVariant, Severity
|
||
|
||
RULES_DIR = Path(__file__).parent.parent / "rules"
|
||
|
||
SPEC_WARNINGS = {
|
||
"JSON_FAILED": ("error", "file"),
|
||
"TABLE_PARSE_ERROR": ("error", "file"),
|
||
"TABLE_LOST": ("error", "file"),
|
||
"TABLE_SKIPPED": ("info", "file"),
|
||
"CODE_COLUMN_NOT_FOUND": ("error", "document"),
|
||
"SEGMENT_WITHOUT_CODES": ("error", "document"),
|
||
"SHEET_NOT_JOINED": ("error", "document"),
|
||
"CONTINUATION_TYPE_CONFLICT": ("error", "document"),
|
||
"SEGMENT_COLUMNS_MISMATCH": ("error", "document"),
|
||
"COLUMN_WITHOUT_DATE": ("error", "document"),
|
||
"COLUMN_DATE_UNCERTAIN": ("error", "document"),
|
||
"HEADER_ROW_NOT_FOUND": ("error", "document"),
|
||
"TYPE_CONFLICT": ("error", "document"),
|
||
"NO_ORGANIZATION_NO_INN": ("error", "document"),
|
||
"PERIOD_UNKNOWN": ("error", "document"),
|
||
"REPORT_DATE_UNCERTAIN": ("error", "document"),
|
||
"XLSB_SCALE_SUSPECT": ("error", "document"),
|
||
"INN_NOT_FOUND": ("info", "document"),
|
||
"INN_UNCERTAIN": ("info", "document"),
|
||
"ORGANIZATION_NOT_FOUND": ("info", "document"),
|
||
"ORGANIZATION_UNCERTAIN": ("info", "document"),
|
||
"REPORT_DATE_UNPARSED": ("info", "document"),
|
||
"UNITS_NOT_FOUND": ("info", "document"),
|
||
"FILE_TYPE_MISMATCH": ("info", "file"),
|
||
"COLUMN_SKIPPED": ("info", "document"),
|
||
"FRACTIONAL_IN_THOUSANDS": ("info", "document"),
|
||
"XLSB_NO_FORMATS": ("info", "document"),
|
||
"NUMBER_FORMAT_UNPARSED": ("info", "document"),
|
||
"VALUE_OUT_OF_RANGE": ("info", "document"),
|
||
"SHEET_OTHER": ("info", "file"),
|
||
"ROW_SPLIT": ("info", "document"),
|
||
"XLS_NO_CODEPAGE": ("info", "file"),
|
||
}
|
||
|
||
|
||
def test_warning_table_matches_spec(rules: Rules) -> None:
|
||
assert {c: (w.severity.value, w.scope) for c, w in rules.warnings.items()} == SPEC_WARNINGS
|
||
|
||
|
||
def test_simplified_income_relation_is_soft(rules: Rules) -> None:
|
||
(rule,) = rules.checks.relations_for(DocumentType.INCOME, FormVariant.SIMPLIFIED)
|
||
assert rule.target == "2400" and not rule.strict
|
||
assert rule.abs_terms == frozenset({"2120", "2330", "2350", "2410"})
|
||
assert rules.checks.abs_codes(DocumentType.INCOME, FormVariant.FULL) == frozenset(
|
||
{"2120", "2210", "2220", "2330", "2350", "2410"}
|
||
)
|
||
|
||
|
||
def test_code_orders_with_repeats(rules: Rules) -> None:
|
||
fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL)
|
||
assert fsbu.count("1220") == len(("1220", "1220")) and "1120" not in fsbu
|
||
ras = rules.code_orders.sequence(Edition.RAS_66N, DocumentType.BALANCE, FormVariant.FULL)
|
||
assert "1120" in ras and ras.index("1110") < ras.index("1100")
|
||
assert rules.code_orders.editions == (Edition.RAS_66N, Edition.FSBU4)
|
||
assert rules.code_orders.sequence(Edition.FSBU4, DocumentType.DISCLOSURE, FormVariant.FULL) == ()
|
||
|
||
|
||
def test_public_rule_types(rules: Rules) -> None:
|
||
assert isinstance(rules.checks.tolerance, Decimal) and rules.checks.tolerance == Decimal("1")
|
||
assert all(isinstance(e, Edition) for e in rules.code_orders.editions)
|
||
fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.INCOME, FormVariant.SIMPLIFIED)
|
||
assert isinstance(fsbu, tuple) and all(isinstance(c, str) for c in fsbu)
|
||
assert (rules.code_ranges.min_codes_continuation, rules.code_ranges.min_codes) == (1, 3)
|
||
assert tuple(rules.headers.labels) == LABEL_KEYS
|
||
|
||
|
||
def test_warning_code_enum_matches_dictionary(rules: Rules) -> None:
|
||
assert {c.value for c in WarningCode} == set(rules.warnings) == set(SPEC_WARNINGS)
|
||
assert WarningCode.XLS_NO_CODEPAGE == "XLS_NO_CODEPAGE"
|
||
|
||
|
||
def test_no_code_to_name_mapping_anywhere() -> None:
|
||
code = re.compile(r"^\d{4,5}$")
|
||
|
||
def walk(node: object) -> None:
|
||
if isinstance(node, dict):
|
||
for key, value in node.items():
|
||
assert not (code.match(str(key)) and isinstance(value, str) and re.search(r"[А-Яа-яЁё]", value))
|
||
walk(value)
|
||
elif isinstance(node, list):
|
||
for item in node:
|
||
walk(item)
|
||
|
||
for path in RULES_DIR.glob("*.yaml"):
|
||
walk(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||
|
||
|
||
def test_broken_repeat_count_rejected(tmp_path: Path) -> None:
|
||
for path in RULES_DIR.glob("*.yaml"):
|
||
(tmp_path / path.name).write_bytes(path.read_bytes())
|
||
orders = tmp_path / "statement_code_orders.yaml"
|
||
orders.write_text(
|
||
orders.read_text(encoding="utf-8").replace('- "1520" # «Обязательство', '# "1520"'), encoding="utf-8"
|
||
)
|
||
with pytest.raises(RulesError, match="statement_code_orders"):
|
||
load_rules(tmp_path)
|
||
|
||
|
||
def test_unknown_severity_rejected(tmp_path: Path) -> None:
|
||
for path in RULES_DIR.glob("*.yaml"):
|
||
(tmp_path / path.name).write_bytes(path.read_bytes())
|
||
target = tmp_path / "statement_warnings.yaml"
|
||
target.write_text(
|
||
target.read_text(encoding="utf-8").replace("severity: info", "severity: warn", 1), encoding="utf-8"
|
||
)
|
||
with pytest.raises(RulesError, match="statement_warnings"):
|
||
load_rules(tmp_path)
|
||
|
||
|
||
def test_make_warning_takes_severity_from_dictionary(rules: Rules) -> None:
|
||
warning = make_warning(WarningCode.COLUMN_SKIPPED, "колонка «Изменение, %» пропущена", rules, column_index=5)
|
||
assert (warning.code, warning.severity, warning.column_index) == ("COLUMN_SKIPPED", Severity.INFO, 5)
|
||
assert make_warning("JSON_FAILED", "x", rules).severity is Severity.ERROR
|
||
|
||
|
||
def test_make_warning_unknown_code_raises(rules: Rules) -> None:
|
||
with pytest.raises(KeyError, match="NOT_A_CODE"):
|
||
make_warning("NOT_A_CODE", "x", rules)
|
||
|
||
|
||
def test_label_keys_are_checked(tmp_path: Path) -> None:
|
||
for path in RULES_DIR.glob("*.yaml"):
|
||
(tmp_path / path.name).write_bytes(path.read_bytes())
|
||
target = tmp_path / "statement_headers.yaml"
|
||
target.write_text(target.read_text(encoding="utf-8").replace(" okpo:", " okpo_x:", 1), encoding="utf-8")
|
||
with pytest.raises(RulesError, match="statement_headers"):
|
||
load_rules(tmp_path)
|
||
|
||
|
||
def test_absolute_period_kinds_come_from_dictionary(rules: Rules) -> None:
|
||
expected = [
|
||
"range_end",
|
||
"day_month_year",
|
||
"quarter",
|
||
"half",
|
||
"months",
|
||
"month_range",
|
||
"year",
|
||
]
|
||
data = yaml.safe_load((RULES_DIR / "statement_periods.yaml").read_text(encoding="utf-8"))
|
||
assert [p["kind"] for p in data["absolute"]] == expected
|
||
assert [p.kind for p in rules.periods.absolute] == expected
|
||
|
||
|
||
def test_repeats_for_unknown_form_rejected(tmp_path: Path) -> None:
|
||
for path in RULES_DIR.glob("*.yaml"):
|
||
(tmp_path / path.name).write_bytes(path.read_bytes())
|
||
orders = tmp_path / "statement_code_orders.yaml"
|
||
orders.write_text(
|
||
orders.read_text(encoding="utf-8").replace(" INCOME: []", " DISCLOSURE: []", 1), encoding="utf-8"
|
||
)
|
||
with pytest.raises(RulesError, match="repeats"):
|
||
load_rules(tmp_path)
|