У 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
85 lines
3.6 KiB
Python
85 lines
3.6 KiB
Python
"""Помощники теста покрытия словарей (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
|
||
]
|