Манифест statements_manifest.yaml по 18 реальным файлам statements-xlsx (сверен прогоном парсера, R-13, OQ-137), property-тест по манифесту, снимки форм в golden_helpers/golden_update/test_golden, словари форм в покрытии, синтетика st06 для вариантов заголовков. Мёртвые записи словаря удалены (name_caption, метки okved и okud_label) либо покрыты st06. Refs #14 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
83 lines
3.5 KiB
Python
83 lines
3.5 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",
|
||
"statement_headers": "statement_headers.yaml",
|
||
"statement_periods": "statement_periods.yaml",
|
||
"statement_checks": "statement_checks.yaml",
|
||
}
|
||
# Словари без записей распознавания: соотношения §7 — выражения над кодами, а не `Pattern` (в покрытии не участвуют)
|
||
PATTERNLESS = frozenset({"statement_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
|
||
]
|