arm-tabular-parser/api/tests/dkb_helpers.py
Andrey Epifancev f6e8da9cf6 test(fixtures): обобщение тестов и golden на osv-dkb/** (многолистовые книги, ожидаемые needs_check, период каталога)
- test_osv_dkb.py: на каждом из 25 файлов пакета ДКБ — статусы листов по манифесту osv_dkb_expectations.yaml (данные, не
  if по имени файла), период по каталогу (2025-Q3, 2024-10_2025-09), структура строк, контракт, время разбора.
- golden: osv-dkb/** до порога GOLDEN_MAX_BYTES = 1 МиБ (22 из 25 файлов; три файла Агрокомплекса 9–14 МБ — без снимков);
  golden_update, golden_helpers (подкаталог osv-dkb/), conftest (dkb_file, dkb_golden_file), хелперы.
- dkb_helpers: expected_period, манифест; юнит-тесты без каталога fixtures.

Stacked on #15. Closes #10

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 13:23:34 +04:00

93 lines
4.3 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.

"""Ожидания для пакета ДКБ `fixtures/osv-dkb/**`: период каталога и манифест ожидаемых нарушений контрольных сумм.
Каталог пакета назван по периоду шапки документа (не по имени файла): `2025-Q3` — «9 месяцев 2025 г.»,
`2024-10_2025-09` — скользящие 12 месяцев «Октябрь 2024 г. – Сентябрь 2025 г.». Ожидаемые статусы — данные
(`osv_dkb_expectations.yaml`), а не условия по имени файла: всё, чего в манифесте нет, должно быть `done` без
нарушений §1.4.
"""
import re
from dataclasses import dataclass
from functools import cache
from pathlib import Path
import yaml
from src.contract import OsvDocument
from src.contract.result import OsvChecks
__all__ = [
"CheckKind",
"ExpectedPeriod",
"expected_period",
"expected_violations",
"load_expectations",
"violated_kinds",
]
CheckKind = str
EXPECTATIONS_FILE = Path(__file__).with_name("osv_dkb_expectations.yaml")
QUARTER_DIR = re.compile(r"^(?P<year>\d{4})-(?P<quarter>Q[1-4])$")
RANGE_DIR = re.compile(r"^(?P<start_year>\d{4})-(?P<start_month>\d{2})_(?P<end_year>\d{4})-(?P<end_month>\d{2})$")
MONTHS_IN_YEAR = 12
@dataclass(frozen=True)
class ExpectedPeriod:
"""Период документа по каталогу: год и квартал (`None` — не определён), скользящий ли это диапазон."""
year: int
quarter: str | None
rolling: bool = False
def expected_period(directory_name: str) -> ExpectedPeriod:
"""`2025-Q3` -> (2025, Q3); `2024-10_2025-09` -> год конца 2025, квартал не определён, скользящий период
(не с начала года или не ровно квартал: парсер не выдаёт его ни за квартальный, ни за нарастающий)."""
quarter = QUARTER_DIR.fullmatch(directory_name)
if quarter is not None:
return ExpectedPeriod(int(quarter.group("year")), quarter.group("quarter"))
span = RANGE_DIR.fullmatch(directory_name)
if span is None:
raise ValueError(f"имя каталога периода не распознано: {directory_name!r}")
months = (
(int(span.group("end_year")) - int(span.group("start_year"))) * MONTHS_IN_YEAR
+ int(span.group("end_month"))
- int(span.group("start_month"))
+ 1
)
if months <= 0:
raise ValueError(f"конец периода раньше начала: {directory_name!r}")
return ExpectedPeriod(int(span.group("end_year")), None, rolling=True)
@cache
def load_expectations() -> dict[str, dict[str, list[str]]]:
"""`{путь файла относительно osv-dkb: {locator: [виды нарушений]}}` из манифеста; у каждой записи есть причина."""
raw = yaml.safe_load(EXPECTATIONS_FILE.read_text(encoding="utf-8")) or {}
found: dict[str, dict[str, list[str]]] = {}
for path, entry in (raw.get("needs_check") or {}).items():
if not entry.get("reason"):
raise ValueError(f"{EXPECTATIONS_FILE.name}: у {path!r} нет причины (`reason`)")
found[path] = {locator: sorted(kinds) for locator, kinds in entry["sheets"].items()}
return found
def expected_violations(relative_path: str, locator: str) -> list[CheckKind]:
"""Ожидаемые виды нарушений листа; пусто — лист должен сойтись."""
return load_expectations().get(relative_path, {}).get(locator, [])
def violated_kinds(document: OsvDocument) -> list[CheckKind]:
"""Виды нарушений листа: `total_row` («Итого» ≠ корень), `balance_identity`, `parent_sum` (отсортированы)."""
checks: OsvChecks | None = document.checks
assert checks is not None, document.locator
kinds: list[CheckKind] = []
if not checks.total_row_matches_root:
kinds.append("total_row")
if checks.balance_identity_violations:
kinds.append("balance_identity")
if checks.parent_sum_violations:
kinds.append("parent_sum")
return sorted(kinds)