arm-tabular-parser/api/tests/rules_coverage.py
Andrey Epifancev bc143ef140 test: golden-снимки, линтер координат, покрытие словарей; make golden-update
- golden_helpers, scripts/golden_update.py (--check), test_golden на 66 ОСВ и синтетике; make golden-update/golden-check/test-fixtures
- тест покрытия словарей (R-22): счётчик срабатываний Pattern; из osv_headers.yaml удалены неиспользуемые service_columns kpp/code/quantity/currency
- синтетика s14–s16 в генераторе и таблице ожиданий; skip вместо падения при отсутствии osv/synthetic (T11.1); комментарий R-21 в s07 (T11.2)
- линтер: предел адреса 10 вместо 4 (T3.1), обращение cell(3, 4) (T3.8); реальные файлы не фиксируют BU/RUB (T3.9)
- README: golden-процесс; отмечены Task 12 и пункты 4.2, 4.3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 15:28:41 +04:00

78 lines
3.1 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",
"periods": "periods.yaml",
"data_types": "data_types.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
]