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
This commit is contained in:
parent
a592c68509
commit
f6e8da9cf6
@ -14,7 +14,9 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам (
|
|||||||
новый формат = новая фикстура + синоним, не новый `if`.
|
новый формат = новая фикстура + синоним, не новый `if`.
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
- property-тесты инвариантов (§1.4) на всех файлах `../fixtures/osv/**` и `../fixtures/statements-xlsx/**`;
|
- property-тесты инвариантов (§1.4) на всех файлах `../fixtures/osv/**` и `../fixtures/statements-xlsx/**`; для пакета ДКБ
|
||||||
|
`../fixtures/osv-dkb/**` — `tests/fixtures/test_osv_dkb.py`: ожидаемые `needs_check` — манифест
|
||||||
|
`tests/osv_dkb_expectations.yaml` (данные), период — по имени каталога (`2025-Q3`, `2024-10_2025-09`);
|
||||||
- снимки `../fixtures/golden/expected_osv_parse/*.json` — после ручной сверки «Итого»;
|
- снимки `../fixtures/golden/expected_osv_parse/*.json` — после ручной сверки «Итого»;
|
||||||
- синтетика только для вариантов, которых нет в фикстурах (шапка вариант 2, Вал./Кол., xlsb, без outline level).
|
- синтетика только для вариантов, которых нет в фикстурах (шапка вариант 2, Вал./Кол., xlsb, без outline level).
|
||||||
|
|
||||||
@ -68,7 +70,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
|
|||||||
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
|
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
|
||||||
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
|
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
|
||||||
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
|
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
|
||||||
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 91 файл: 72 реальных + 19 синтетических) — регрессия, а не
|
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 113 файлов: 72 реальных + 22 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 19 синтетических) — регрессия, а не
|
||||||
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
|
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
|
||||||
проверяют property-тест и ручная сверка «Итого».
|
проверяют property-тест и ручная сверка «Итого».
|
||||||
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
|
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
|
||||||
|
|||||||
@ -20,6 +20,7 @@ sys.path[:0] = [str(API_ROOT), str(API_ROOT / "tests")]
|
|||||||
|
|
||||||
from fixtures_helpers import ( # noqa: E402
|
from fixtures_helpers import ( # noqa: E402
|
||||||
fixtures_skip_reason,
|
fixtures_skip_reason,
|
||||||
|
osv_dkb_golden_files,
|
||||||
osv_fixture_files,
|
osv_fixture_files,
|
||||||
resolve_fixtures_dir,
|
resolve_fixtures_dir,
|
||||||
synthetic_files,
|
synthetic_files,
|
||||||
@ -43,7 +44,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
print(reason, file=sys.stderr)
|
print(reason, file=sys.stderr)
|
||||||
return EXIT_NO_FIXTURES
|
return EXIT_NO_FIXTURES
|
||||||
root = resolve_fixtures_dir()
|
root = resolve_fixtures_dir()
|
||||||
files = [*osv_fixture_files(root), *synthetic_files(root)]
|
files = [*osv_fixture_files(root), *osv_dkb_golden_files(root), *synthetic_files(root)]
|
||||||
report = sync_snapshots(root, files, get_parser(), write=not options.check)
|
report = sync_snapshots(root, files, get_parser(), write=not options.check)
|
||||||
|
|
||||||
for diff in report.diffs:
|
for diff in report.diffs:
|
||||||
|
|||||||
@ -7,6 +7,8 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
from fixtures_helpers import (
|
from fixtures_helpers import (
|
||||||
fixtures_skip_reason,
|
fixtures_skip_reason,
|
||||||
|
osv_dkb_files,
|
||||||
|
osv_dkb_golden_files,
|
||||||
osv_fixture_files,
|
osv_fixture_files,
|
||||||
resolve_fixtures_dir,
|
resolve_fixtures_dir,
|
||||||
synthetic_files,
|
synthetic_files,
|
||||||
@ -76,9 +78,12 @@ def fixtures_dir() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
||||||
"""Параметры `osv_file` (реальные ОСВ) и `synthetic_file` (синтетика); без каталога — один параметр со skip."""
|
"""Параметры `osv_file` (реальные ОСВ), `dkb_file` (пакет ДКБ), `dkb_golden_file` (ДКБ до порога снимка) и
|
||||||
|
`synthetic_file` (синтетика); без каталога — один параметр со skip."""
|
||||||
sources = {
|
sources = {
|
||||||
"osv_file": (osv_fixture_files, fixtures_skip_reason),
|
"osv_file": (osv_fixture_files, fixtures_skip_reason),
|
||||||
|
"dkb_file": (osv_dkb_files, fixtures_skip_reason),
|
||||||
|
"dkb_golden_file": (osv_dkb_golden_files, fixtures_skip_reason),
|
||||||
"synthetic_file": (synthetic_files, lambda: fixtures_skip_reason() or synthetic_skip_reason()),
|
"synthetic_file": (synthetic_files, lambda: fixtures_skip_reason() or synthetic_skip_reason()),
|
||||||
}
|
}
|
||||||
for argname, (listing, skip_reason) in sources.items():
|
for argname, (listing, skip_reason) in sources.items():
|
||||||
|
|||||||
92
api/tests/dkb_helpers.py
Normal file
92
api/tests/dkb_helpers.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""Ожидания для пакета ДКБ `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)
|
||||||
17
api/tests/fixtures/test_golden.py
vendored
17
api/tests/fixtures/test_golden.py
vendored
@ -1,5 +1,6 @@
|
|||||||
"""Golden-снимки разбора (design D-10): результат парсера для каждого файла `fixtures/osv/**` и синтетики равен
|
"""Golden-снимки разбора (design D-10): результат парсера для каждого файла `fixtures/osv/**`, синтетики и файлов
|
||||||
снимку в `fixtures/golden/expected_osv_parse/` (зеркало дерева `osv/`), без volatile-полей.
|
`fixtures/osv-dkb/**` до `GOLDEN_MAX_BYTES` равен снимку в `fixtures/golden/expected_osv_parse/` (зеркало дерева
|
||||||
|
`osv/`, пакет ДКБ — в подкаталоге `osv-dkb/`), без volatile-полей.
|
||||||
|
|
||||||
Снимок — регрессия, а не оракул (оракулы — ручная сверка «Итого» и property-тест). Обновление — `make golden-update`,
|
Снимок — регрессия, а не оракул (оракулы — ручная сверка «Итого» и property-тест). Обновление — `make golden-update`,
|
||||||
отдельным коммитом в репозиторий fixtures с просмотром diff.
|
отдельным коммитом в репозиторий fixtures с просмотром diff.
|
||||||
@ -24,17 +25,21 @@ def test_osv_file_matches_golden_snapshot(fixtures_dir: Path, osv_file: Path, pa
|
|||||||
check_snapshot(fixtures_dir, osv_file, parser)
|
check_snapshot(fixtures_dir, osv_file, parser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dkb_file_matches_golden_snapshot(fixtures_dir: Path, dkb_golden_file: Path, parser: Parser) -> None:
|
||||||
|
"""Пакет ДКБ: снимки только у файлов не больше `GOLDEN_MAX_BYTES` (большие — property-тесты `test_osv_dkb.py`)."""
|
||||||
|
check_snapshot(fixtures_dir, dkb_golden_file, parser)
|
||||||
|
|
||||||
|
|
||||||
def test_synthetic_file_matches_golden_snapshot(fixtures_dir: Path, synthetic_file: Path, parser: Parser) -> None:
|
def test_synthetic_file_matches_golden_snapshot(fixtures_dir: Path, synthetic_file: Path, parser: Parser) -> None:
|
||||||
check_snapshot(fixtures_dir, synthetic_file, parser)
|
check_snapshot(fixtures_dir, synthetic_file, parser)
|
||||||
|
|
||||||
|
|
||||||
def test_every_snapshot_has_a_source_file(fixtures_dir: Path) -> None:
|
def test_every_snapshot_has_a_source_file(fixtures_dir: Path) -> None:
|
||||||
"""Лишний снимок (файл удалён или переименован) — ошибка: `make golden-update` его удаляет."""
|
"""Лишний снимок (файл удалён или переименован) — ошибка: `make golden-update` его удаляет."""
|
||||||
from fixtures_helpers import osv_fixture_files, synthetic_files # noqa: PLC0415 - только для этого теста
|
from fixtures_helpers import osv_dkb_golden_files, osv_fixture_files, synthetic_files # noqa: PLC0415 - один тест
|
||||||
|
|
||||||
expected = {
|
sources = [*osv_fixture_files(fixtures_dir), *osv_dkb_golden_files(fixtures_dir), *synthetic_files(fixtures_dir)]
|
||||||
golden_path(fixtures_dir, file) for file in [*osv_fixture_files(fixtures_dir), *synthetic_files(fixtures_dir)]
|
expected = {golden_path(fixtures_dir, file) for file in sources}
|
||||||
}
|
|
||||||
directory = fixtures_dir / "golden" / "expected_osv_parse"
|
directory = fixtures_dir / "golden" / "expected_osv_parse"
|
||||||
present = set(directory.rglob("*.json")) if directory.is_dir() else set()
|
present = set(directory.rglob("*.json")) if directory.is_dir() else set()
|
||||||
|
|
||||||
|
|||||||
138
api/tests/fixtures/test_osv_dkb.py
vendored
Normal file
138
api/tests/fixtures/test_osv_dkb.py
vendored
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"""Property-тесты пакета ДКБ `../fixtures/osv-dkb/**` (issue #10): многолистовые книги, статусы `needs_check` как
|
||||||
|
ожидаемые данные, период каталога, файлы 9–14 МБ без golden.
|
||||||
|
|
||||||
|
Инварианты проверяются на каждом файле независимо от снимков. Ожидаемые нарушения контрольных сумм — манифест
|
||||||
|
`osv_dkb_expectations.yaml` (`dkb_helpers`), а не условия по имени файла: чего в манифесте нет, должно сойтись.
|
||||||
|
Период документа — по имени каталога (`2025-Q3`, `2024-10_2025-09`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dkb_helpers import expected_period, expected_violations, load_expectations, violated_kinds
|
||||||
|
from fixtures_helpers import GOLDEN_MAX_BYTES, OSV_EXTENSIONS, osv_dkb_files, osv_dkb_golden_files
|
||||||
|
from golden_helpers import golden_path
|
||||||
|
from result_contract import assert_valid_result
|
||||||
|
|
||||||
|
from src.contract import FileType, OsvDocument, ResultStatus
|
||||||
|
from src.parser import ParseOutcome, get_parser
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.fixtures
|
||||||
|
|
||||||
|
EXPECTED_FILES = 25 # пакет ДКБ 06.10.2026 без отчётов «Анализ счета»: 22 файла до порога снимка + 3 большие
|
||||||
|
EXPECTED_WITHOUT_GOLDEN = 3 # Агрокомплекс 62, 76, 76 (скользящий): 9–14 МБ
|
||||||
|
MAX_PARSE_SECONDS_SMALL = 5
|
||||||
|
MAX_PARSE_SECONDS_LARGE = 30
|
||||||
|
ROLLING_WARNING = "не с начала года"
|
||||||
|
|
||||||
|
|
||||||
|
def _relative(fixtures_dir: Path, file: Path) -> str:
|
||||||
|
return file.relative_to(fixtures_dir / "osv-dkb").as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(file: Path) -> tuple[ParseOutcome, float]:
|
||||||
|
started = time.monotonic()
|
||||||
|
outcome = get_parser().parse(file.read_bytes(), cast(FileType, file.suffix.lstrip(".").upper()))
|
||||||
|
return outcome, time.monotonic() - started
|
||||||
|
|
||||||
|
|
||||||
|
def _documents(outcome: ParseOutcome) -> list[OsvDocument]:
|
||||||
|
assert outcome.error is None, outcome.error
|
||||||
|
assert outcome.documents
|
||||||
|
assert all(isinstance(document, OsvDocument) for document in outcome.documents)
|
||||||
|
return cast(list[OsvDocument], list(outcome.documents))
|
||||||
|
|
||||||
|
|
||||||
|
def test_dkb_package_is_complete(fixtures_dir: Path) -> None:
|
||||||
|
files = osv_dkb_files(fixtures_dir)
|
||||||
|
|
||||||
|
assert len(files) == EXPECTED_FILES
|
||||||
|
assert all(path.suffix.lower() in OSV_EXTENSIONS for path in files)
|
||||||
|
assert len(files) - len(osv_dkb_golden_files(fixtures_dir)) == EXPECTED_WITHOUT_GOLDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_expectations_point_to_existing_files_and_sheets(fixtures_dir: Path) -> None:
|
||||||
|
existing = {_relative(fixtures_dir, path) for path in osv_dkb_files(fixtures_dir)}
|
||||||
|
|
||||||
|
assert set(load_expectations()) <= existing, sorted(set(load_expectations()) - existing)
|
||||||
|
|
||||||
|
|
||||||
|
def test_files_over_threshold_have_no_golden_snapshot(fixtures_dir: Path) -> None:
|
||||||
|
"""Большие файлы (десятки МБ JSON) снимка не имеют — только property-тесты; снимок у остальных обязателен."""
|
||||||
|
for path in osv_dkb_files(fixtures_dir):
|
||||||
|
snapshot = golden_path(fixtures_dir, path)
|
||||||
|
assert snapshot.is_file() == (path.stat().st_size <= GOLDEN_MAX_BYTES), path.name
|
||||||
|
|
||||||
|
|
||||||
|
def _check_statuses(relative: str, outcome: ParseOutcome, documents: list[OsvDocument]) -> None:
|
||||||
|
locators = [document.locator for document in documents]
|
||||||
|
assert len(set(locators)) == len(locators)
|
||||||
|
assert all(locator.startswith("sheet:") for locator in locators)
|
||||||
|
# Статус результата — по нарушениям листов; ожидаемые нарушения — из манифеста
|
||||||
|
found = {document.locator: violated_kinds(document) for document in documents}
|
||||||
|
assert found == {locator: expected_violations(relative, locator) for locator in locators}, found
|
||||||
|
expected_status = ResultStatus.NEEDS_CHECK if any(found.values()) else ResultStatus.DONE
|
||||||
|
assert outcome.status is expected_status, outcome.metrics
|
||||||
|
assert (relative in load_expectations()) == (expected_status is ResultStatus.NEEDS_CHECK)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_period(directory: str, outcome: ParseOutcome, documents: list[OsvDocument]) -> None:
|
||||||
|
expected = expected_period(directory)
|
||||||
|
for document in documents:
|
||||||
|
header = document.header
|
||||||
|
assert header.period_year == expected.year, document.locator
|
||||||
|
assert header.period_quarter == expected.quarter, document.locator
|
||||||
|
has_rolling_warning = any(
|
||||||
|
ROLLING_WARNING in warning for warning in (outcome.metrics.warnings or []) if outcome.metrics
|
||||||
|
)
|
||||||
|
assert has_rolling_warning is expected.rolling, document.locator
|
||||||
|
if expected.rolling:
|
||||||
|
assert header.is_cumulative is None
|
||||||
|
else:
|
||||||
|
assert header.is_cumulative is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_structure(outcome: ParseOutcome, documents: list[OsvDocument]) -> None:
|
||||||
|
total_data_rows = 0
|
||||||
|
for document in documents:
|
||||||
|
rows = document.rows
|
||||||
|
header = document.header
|
||||||
|
assert header.organization_name and header.account_code_raw and header.data_type, document.locator
|
||||||
|
by_number = {row.row_number: row for row in rows}
|
||||||
|
assert len(by_number) == len(rows) # номера строк уникальны
|
||||||
|
assert [row.row_number for row in rows] == sorted(by_number)
|
||||||
|
totals = [row for row in rows if row.group_type == "TOTAL"]
|
||||||
|
assert totals and all(row.level == 0 and row.parent_row_number is None for row in totals), document.locator
|
||||||
|
for row in rows:
|
||||||
|
if row.parent_row_number is None:
|
||||||
|
assert row.level == 0
|
||||||
|
continue
|
||||||
|
parent = by_number[row.parent_row_number]
|
||||||
|
assert parent.row_number < row.row_number
|
||||||
|
assert parent.level < row.level
|
||||||
|
assert parent.group_type != "TOTAL"
|
||||||
|
if row.group_type == "ACCOUNT":
|
||||||
|
assert row.account_code_raw
|
||||||
|
accounts = [row for row in rows if row.group_type == "ACCOUNT"]
|
||||||
|
assert accounts, document.locator
|
||||||
|
assert all(
|
||||||
|
row.account_code_raw and row.account_code_raw.startswith(header.account_code_raw) for row in accounts
|
||||||
|
)
|
||||||
|
total_data_rows += len(rows) - len(totals)
|
||||||
|
assert outcome.metrics is not None
|
||||||
|
assert outcome.metrics.rows == total_data_rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_dkb_file_invariants(fixtures_dir: Path, dkb_file: Path) -> None:
|
||||||
|
"""Один разбор файла — все инварианты: статусы листов по манифесту, период каталога, структура строк, контракт."""
|
||||||
|
outcome, seconds = _parse(dkb_file)
|
||||||
|
documents = _documents(outcome)
|
||||||
|
|
||||||
|
_check_statuses(_relative(fixtures_dir, dkb_file), outcome, documents)
|
||||||
|
_check_period(dkb_file.parent.name, outcome, documents)
|
||||||
|
_check_structure(outcome, documents)
|
||||||
|
limit = MAX_PARSE_SECONDS_LARGE if dkb_file.stat().st_size > GOLDEN_MAX_BYTES else MAX_PARSE_SECONDS_SMALL
|
||||||
|
assert seconds < limit, f"{dkb_file.name}: {seconds:.2f} с"
|
||||||
|
assert_valid_result(outcome)
|
||||||
@ -7,6 +7,9 @@ FIXTURES_ENV = "ARM_KA_FIXTURES"
|
|||||||
OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
|
OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
|
||||||
_SYNTHETIC = "synthetic"
|
_SYNTHETIC = "synthetic"
|
||||||
_OSV_DKB = "osv-dkb"
|
_OSV_DKB = "osv-dkb"
|
||||||
|
# Golden-снимок делается для файла пакета ДКБ не больше 1 МиБ: у больших (Агрокомплекс, 9–14 МБ, ~50 тыс. строк)
|
||||||
|
# снимок — десятки МБ JSON, для них остаются property-тесты инвариантов
|
||||||
|
GOLDEN_MAX_BYTES = 1024 * 1024
|
||||||
_ANALYSIS_REPORTS = "analysis-reports"
|
_ANALYSIS_REPORTS = "analysis-reports"
|
||||||
# Не фикстуры: скрытые файлы и каталоги («.DS_Store», «.cache/») и lock-файлы Excel («~$книга.xlsx», пока она открыта)
|
# Не фикстуры: скрытые файлы и каталоги («.DS_Store», «.cache/») и lock-файлы Excel («~$книга.xlsx», пока она открыта)
|
||||||
_IGNORED_PREFIXES = (".", "~$")
|
_IGNORED_PREFIXES = (".", "~$")
|
||||||
@ -65,6 +68,11 @@ def osv_dkb_files(root: Path) -> list[Path]:
|
|||||||
return sorted(path for path in found if reports not in path.parents)
|
return sorted(path for path in found if reports not in path.parents)
|
||||||
|
|
||||||
|
|
||||||
|
def osv_dkb_golden_files(root: Path) -> list[Path]:
|
||||||
|
"""Файлы пакета ДКБ, для которых хранится golden-снимок: размер не больше `GOLDEN_MAX_BYTES`."""
|
||||||
|
return [path for path in osv_dkb_files(root) if path.stat().st_size <= GOLDEN_MAX_BYTES]
|
||||||
|
|
||||||
|
|
||||||
def synthetic_files(root: Path) -> list[Path]:
|
def synthetic_files(root: Path) -> list[Path]:
|
||||||
"""Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые, lock-файлы `~$…` и пояснения вроде `README.md` не
|
"""Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые, lock-файлы `~$…` и пояснения вроде `README.md` не
|
||||||
считаются); отсортированы."""
|
считаются); отсортированы."""
|
||||||
|
|||||||
@ -40,8 +40,10 @@ __all__ = [
|
|||||||
SNAPSHOT_DIR = "expected_osv_parse"
|
SNAPSHOT_DIR = "expected_osv_parse"
|
||||||
GOLDEN_DIR = "golden"
|
GOLDEN_DIR = "golden"
|
||||||
OSV_DIR = "osv"
|
OSV_DIR = "osv"
|
||||||
|
OSV_DKB_DIR = "osv-dkb"
|
||||||
SYNTHETIC_DIR = "synthetic"
|
SYNTHETIC_DIR = "synthetic"
|
||||||
REAL_PART = "real"
|
REAL_PART = "real"
|
||||||
|
DKB_PART = "dkb"
|
||||||
JSON_INDENT = 2
|
JSON_INDENT = 2
|
||||||
# Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность
|
# Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность
|
||||||
VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms"))
|
VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms"))
|
||||||
@ -63,9 +65,18 @@ def normalize_result(result: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _mirrored(fixtures_root: Path, file: Path) -> Path:
|
||||||
|
"""Путь файла в зеркале снимков: относительно `osv/`, а для пакета ДКБ — `osv-dkb/<путь внутри osv-dkb>`."""
|
||||||
|
dkb = fixtures_root / OSV_DKB_DIR
|
||||||
|
if dkb in file.parents:
|
||||||
|
return Path(OSV_DKB_DIR) / file.relative_to(dkb)
|
||||||
|
return file.relative_to(fixtures_root / OSV_DIR)
|
||||||
|
|
||||||
|
|
||||||
def golden_path(fixtures_root: Path, file: Path) -> Path:
|
def golden_path(fixtures_root: Path, file: Path) -> Path:
|
||||||
"""`golden/expected_osv_parse/<путь файла относительно osv/ без расширения>.json` (зеркало дерева `osv/`)."""
|
"""`golden/expected_osv_parse/<путь файла без расширения>.json`: зеркало дерева `osv/`, а файлы пакета ДКБ —
|
||||||
relative = file.relative_to(fixtures_root / OSV_DIR)
|
в подкаталоге `osv-dkb/`."""
|
||||||
|
relative = _mirrored(fixtures_root, file)
|
||||||
return fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR / relative.parent / f"{relative.stem}.json"
|
return fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR / relative.parent / f"{relative.stem}.json"
|
||||||
|
|
||||||
|
|
||||||
@ -125,15 +136,16 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *
|
|||||||
"""Сверяет снимки `files` с текущим разбором; при `write` — перезаписывает изменившиеся и удаляет лишние.
|
"""Сверяет снимки `files` с текущим разбором; при `write` — перезаписывает изменившиеся и удаляет лишние.
|
||||||
|
|
||||||
Лишний снимок — файл в `golden/expected_osv_parse/`, которому не соответствует ни один файл из `files`.
|
Лишний снимок — файл в `golden/expected_osv_parse/`, которому не соответствует ни один файл из `files`.
|
||||||
Снимки делятся на части по исходному каталогу: `synthetic/` (синтетика `osv/synthetic`) и остальные (реальные
|
Снимки делятся на части по исходному каталогу: `synthetic/` (синтетика `osv/synthetic`), `osv-dkb/` (пакет ДКБ,
|
||||||
файлы `osv/`). Если в `files` нет ни одного файла части (каталог отсутствует или пуст — например, fixtures без
|
только файлы до `GOLDEN_MAX_BYTES`) и остальные (реальные файлы `osv/`). Если в `files` нет ни одного файла части
|
||||||
синтетики), её снимки не лишние: они попадают в `report.protected` и не удаляются (T12.3).
|
(каталог отсутствует или пуст — например, fixtures без синтетики), её снимки не лишние: они попадают в
|
||||||
|
`report.protected` и не удаляются (T12.3).
|
||||||
Для изменившегося снимка в `report.diffs` — unified diff; для нового — весь текст как добавленный.
|
Для изменившегося снимка в `report.diffs` — unified diff; для нового — весь текст как добавленный.
|
||||||
"""
|
"""
|
||||||
report = SyncReport()
|
report = SyncReport()
|
||||||
expected: set[Path] = set()
|
expected: set[Path] = set()
|
||||||
files = list(files)
|
files = list(files)
|
||||||
present_parts = {_source_part(file.relative_to(fixtures_root / OSV_DIR)) for file in files}
|
present_parts = {_source_part(_mirrored(fixtures_root, file)) for file in files}
|
||||||
for file in files:
|
for file in files:
|
||||||
target = golden_path(fixtures_root, file)
|
target = golden_path(fixtures_root, file)
|
||||||
expected.add(target)
|
expected.add(target)
|
||||||
@ -166,8 +178,11 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *
|
|||||||
|
|
||||||
|
|
||||||
def _source_part(relative: Path) -> str:
|
def _source_part(relative: Path) -> str:
|
||||||
"""Часть дерева по пути относительно `osv/` (или каталога снимков): `synthetic` или `real`."""
|
"""Часть дерева по пути в зеркале (относительно `osv/` или каталога снимков): `synthetic`, `dkb` или `real`."""
|
||||||
return SYNTHETIC_DIR if relative.parts and relative.parts[0] == SYNTHETIC_DIR else REAL_PART
|
first = relative.parts[0] if relative.parts else ""
|
||||||
|
if first == SYNTHETIC_DIR:
|
||||||
|
return SYNTHETIC_DIR
|
||||||
|
return DKB_PART if first == OSV_DKB_DIR else REAL_PART
|
||||||
|
|
||||||
|
|
||||||
def check_snapshot(fixtures_root: Path, file: Path, parser: Parser) -> None:
|
def check_snapshot(fixtures_root: Path, file: Path, parser: Parser) -> None:
|
||||||
|
|||||||
24
api/tests/osv_dkb_expectations.yaml
Normal file
24
api/tests/osv_dkb_expectations.yaml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Ожидаемые нарушения контрольных сумм §1.4 в пакете ДКБ (fixtures/osv-dkb/**), по листам.
|
||||||
|
# Ключ — путь файла относительно osv-dkb; значения листа — виды нарушений: total_row («Итого» ≠ корень / Σ уровня 0),
|
||||||
|
# balance_identity (начало + обороты ≠ конец в строке), parent_sum (родитель ≠ Σ детей).
|
||||||
|
# Файлов и листов, которых здесь нет, тест ждёт `done` без нарушений. Причина — данные файла, а не дефект разбора
|
||||||
|
# (иначе чинить парсер): запись удаляется, когда вопрос закрыт.
|
||||||
|
needs_check:
|
||||||
|
"tsimlyansky/2025-Q3/91_ 9мес.2025.xls":
|
||||||
|
reason: "OQ-132: аналитика счёта 91 несёт только обороты, сальдо закрыто на 91.09"
|
||||||
|
sheets: {"sheet:Лист_1": [balance_identity, parent_sum]}
|
||||||
|
"primanych/2025-Q3/Оборотно-сальдовая ведомость по счету 91 за 9 месяцев 2025 г. ООО Приманыческий.xls":
|
||||||
|
reason: "OQ-132: то же, счёт 91"
|
||||||
|
sheets: {"sheet:Лист_1": [balance_identity, parent_sum]}
|
||||||
|
"nkz/2025-Q3/НКЗ_ОСВ_58+66+67_Субсчета+Контрагенты_2025.09-01_24459.xls":
|
||||||
|
reason: "лист 58: сумма детей ≠ родитель (строки 12, 24), см. arm-tabular-parser#11 A11-5; листы 66, 67 сходятся"
|
||||||
|
sheets: {"sheet:58": [parent_sum]}
|
||||||
|
"agrokompleks/2025-Q3/ОСВ_сч62_2025_9мес.xls":
|
||||||
|
reason: "аналитика магазинов 62.04 без сальдо на конец (тот же класс, что OQ-132); Итого и родители сходятся"
|
||||||
|
sheets: {"sheet:TDSheet": [balance_identity]}
|
||||||
|
"agrokompleks/2025-Q3/ОСВ_сч76_2025_9мес.xls":
|
||||||
|
reason: "аналитика магазинов 76.11 и 76.41.1 без сальдо на конец; пустые названия контрагентов («не задано»)"
|
||||||
|
sheets: {"sheet:TDSheet": [balance_identity, parent_sum]}
|
||||||
|
"agrokompleks/2024-10_2025-09/ОСВ_сч76_01.10.2024-30.09.2025.xls":
|
||||||
|
reason: "то же, что у 76 за 9 месяцев"
|
||||||
|
sheets: {"sheet:TDSheet": [balance_identity, parent_sum]}
|
||||||
35
api/tests/unit/test_dkb_helpers.py
Normal file
35
api/tests/unit/test_dkb_helpers.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"""Ожидания пакета ДКБ: период по каталогу и манифест нарушений (без каталога fixtures)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dkb_helpers import ExpectedPeriod, expected_period, expected_violations, load_expectations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("directory", "expected"),
|
||||||
|
[
|
||||||
|
("2025-Q3", ExpectedPeriod(2025, "Q3")),
|
||||||
|
("2024-Q1", ExpectedPeriod(2024, "Q1")),
|
||||||
|
("2024-10_2025-09", ExpectedPeriod(2025, None, rolling=True)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_period_by_catalog_name(directory: str, expected: ExpectedPeriod) -> None:
|
||||||
|
assert expected_period(directory) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("directory", ["2025", "2025-FY", "2025-09_2024-10", "x"])
|
||||||
|
def test_unknown_catalog_name_is_an_error(directory: str) -> None:
|
||||||
|
with pytest.raises(ValueError, match="каталога периода|конец периода"):
|
||||||
|
expected_period(directory)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expectations_have_reasons_and_known_kinds() -> None:
|
||||||
|
kinds = {"total_row", "balance_identity", "parent_sum"}
|
||||||
|
for path, sheets in load_expectations().items():
|
||||||
|
assert sheets, path
|
||||||
|
for locator, found in sheets.items():
|
||||||
|
assert locator.startswith("sheet:"), (path, locator)
|
||||||
|
assert found and set(found) <= kinds, (path, locator, found)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlisted_sheet_is_expected_to_converge() -> None:
|
||||||
|
assert expected_violations("nm/2025-Q3/нет такого.xlsx", "sheet:60") == []
|
||||||
@ -75,3 +75,15 @@ def test_unified_diff_names_files_and_shows_changed_lines() -> None:
|
|||||||
assert "-b" in diff
|
assert "-b" in diff
|
||||||
assert "+c" in diff
|
assert "+c" in diff
|
||||||
assert unified_diff("a\n", "a\n", name="x") == ""
|
assert unified_diff("a\n", "a\n", name="x") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_dkb_snapshot_path_mirrors_osv_dkb_under_its_own_subdirectory(tmp_path: Path) -> None:
|
||||||
|
file = tmp_path / "osv-dkb" / "nm" / "2025-Q3" / "НМ ОСВ 63.xlsx"
|
||||||
|
|
||||||
|
assert golden_path(tmp_path, file) == (
|
||||||
|
tmp_path / "golden" / SNAPSHOT_DIR / "osv-dkb" / "nm" / "2025-Q3" / "НМ ОСВ 63.json"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
golden_path(tmp_path, tmp_path / "osv" / "rb" / "a.xlsx")
|
||||||
|
== tmp_path / "golden" / SNAPSHOT_DIR / "rb" / "a.json"
|
||||||
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user