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