arm-tabular-parser/api/tests/rules_coverage.py
Andrey Epifancev 49400682bc feat(osv): счета без сальдо по аналитике (91, 99) вне тождества строки (OQ-132, #11 п. 2)
У 91 и 99 строки аналитики несут только обороты, сальдо закрыто на субсчёт закрытия
(91.09), поэтому каждая ОСВ 91 получала needs_check. Список счетов — данные
api/rules/osv_checks.yaml (accounts_without_analytic_balance: 91, 99; 90 не входит).

- тождество строки на таких счетах не проверяется;
- «сумма детей = родитель» сохраняется: обороты всегда, сальдо — только у родителя
  с дочерними субсчетами (корень -> субсчета); у родителя с одной аналитикой сальдо
  не сверяется;
- «Итого» сверяется как обычно;
- строка относится к счёту списка по коду ближайшего счёта ветки (91, 91.01; не 910).

Тесты: unit checks/rules, манифест osv_dkb_expectations (две ОСВ 91 теперь done).
README, CLAUDE.md, openspec/specs/osv-parsing, api/rules/README.md обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-08 00:18:36 +04:00

85 lines
3.6 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.

"""Помощники теста покрытия словарей (R-22): обход записей `Rules` и счётчик срабатываний `Pattern`.
Вынесены из `tests/fixtures/test_rules_coverage.py`, чтобы сам механизм проверялся без каталога fixtures
(`tests/unit/test_rules_coverage_helpers.py`).
"""
import re
from collections import Counter
from collections.abc import Iterator
from typing import Any
import pytest
from pydantic import BaseModel
from src.parser.rules import Pattern, Rules
DICTIONARIES = {
"headers": "osv_headers.yaml",
"groups": "osv_groups.yaml",
"checks": "osv_checks.yaml",
"periods": "periods.yaml",
"data_types": "data_types.yaml",
"statement_headers": "statement_headers.yaml",
"statement_periods": "statement_periods.yaml",
"statement_checks": "statement_checks.yaml",
}
# Словари без записей распознавания: соотношения §7 — выражения над кодами, список счетов проверок ОСВ — коды счетов,
# а не `Pattern` (в покрытии не участвуют)
PATTERNLESS = frozenset({"statement_checks.yaml", "osv_checks.yaml"})
def iter_patterns(rules: Rules) -> Iterator[tuple[str, str, Pattern]]:
"""(файл словаря, путь ключа, Pattern) для каждой записи распознавания; порядок — как в моделях."""
for attribute, file_name in DICTIONARIES.items():
yield from _walk(file_name, attribute_value=getattr(rules, attribute), path="")
def _walk(file_name: str, attribute_value: Any, path: str) -> Iterator[tuple[str, str, Pattern]]:
if isinstance(attribute_value, Pattern):
yield file_name, path, attribute_value
elif isinstance(attribute_value, BaseModel):
for name in type(attribute_value).model_fields:
yield from _walk(file_name, getattr(attribute_value, name), f"{path}.{name}" if path else name)
elif isinstance(attribute_value, dict):
for key, inner in attribute_value.items():
yield from _walk(file_name, inner, f"{path}.{key}")
elif isinstance(attribute_value, list | tuple):
for index, inner in enumerate(attribute_value):
yield from _walk(file_name, inner, f"{path}[{index}]")
class MatchCounter:
"""Считает совпадения `Pattern.search`/`search_normalized` по идентичности объекта паттерна."""
def __init__(self) -> None:
self.hits: Counter[int] = Counter()
def install(self, monkeypatch: pytest.MonkeyPatch) -> None:
original_search = Pattern.search
original_normalized = Pattern.search_normalized
counter = self
def search(pattern: Pattern, text: str) -> re.Match[str] | None:
found = original_search(pattern, text)
if found is not None:
counter.hits[id(pattern)] += 1
return found
def search_normalized(pattern: Pattern, normalized: str) -> re.Match[str] | None:
found = original_normalized(pattern, normalized)
if found is not None:
counter.hits[id(pattern)] += 1
return found
monkeypatch.setattr(Pattern, "search", search)
monkeypatch.setattr(Pattern, "search_normalized", search_normalized)
def uncovered(rules: Rules, counter: MatchCounter) -> list[str]:
return [
f"{file_name}: {path}: {pattern.source!r}"
for file_name, path, pattern in iter_patterns(rules)
if counter.hits[id(pattern)] == 0
]