Срез 2a: парсер ОСВ из XLS/XLSX/XLSB — динамическое обнаружение структуры (§1), контракт v1.1, golden на 66 реальных файлах #6

Merged
aep merged 45 commits from feat/5-slice-2a-osv-parser into main 2026-10-05 19:18:07 +03:00
24 changed files with 1046 additions and 51 deletions
Showing only changes of commit bc143ef140 - Show all commits

View File

@ -3,7 +3,7 @@ VENV := api/.venv
BIN := $(CURDIR)/$(VENV)/bin
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
.PHONY: venv lint test test-integration check docker-build check-full up
.PHONY: venv lint test test-integration test-fixtures golden-update golden-check check docker-build check-full up
# Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их
venv:
@ -22,6 +22,19 @@ test:
test-integration:
cd api && $(BIN)/pytest -m integration
# Тесты на реальных данных из ../fixtures (ARM_KA_FIXTURES): golden-снимки, покрытие словарей, property-тест
test-fixtures:
cd api && $(BIN)/pytest -m fixtures
# Golden-снимки разбора ОСВ: перезапись в ../fixtures/golden/expected_osv_parse/ + unified diff изменившихся.
# Результат обязательно просмотреть и коммитить в репозиторий fixtures отдельным коммитом (снимок — регрессия, не оракул)
golden-update:
cd api && ENV_FILE=.env.test $(BIN)/python scripts/golden_update.py
# То же без записи: код возврата 1, если снимки расходятся с разбором
golden-check:
cd api && ENV_FILE=.env.test $(BIN)/python scripts/golden_update.py --check
check: lint test
# Сборка образа из корня репозитория

View File

@ -83,12 +83,37 @@ openspec/ требования и дельта текущего ср
| `make lint` | `ruff check .`, `ruff format --check .`, `mypy src` |
| `make test` | `pytest -m "not integration"` — без Docker |
| `make test-integration` | `pytest -m integration` — против NATS и MinIO из compose |
| `make test-fixtures` | `pytest -m fixtures` — тесты на реальных ОСВ и синтетике из `../fixtures`: golden-снимки, покрытие словарей, property-тест |
| `make golden-update` | перезаписывает golden-снимки в `../fixtures/golden/expected_osv_parse/` и печатает unified diff изменившихся |
| `make golden-check` | то же без записи; код возврата 1, если снимки расходятся с разбором |
| `make check` | `make lint` + `make test` (включая идентичность копии контракта с `../docs/contracts`) |
| `make check-full` | `make check` + `make test-integration` + `docker build`; запускать перед PR |
| `make up` | `docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio` |
Интеграционные тесты (`make test-integration`, `make check-full`) поднимают сервис в процессе теста и вступают в queue group `tabular-parser` на реальной шине: на шине compose не должно работать другого экземпляра tabular-parser (и наоборот: на время теста in-process воркер ответит `NOT_IMPLEMENTED` на реальные запросы других производителей).
## Тесты на реальных данных и golden-снимки
Тесты с маркером `fixtures` читают файлы из соседнего репозитория `fixtures` (`ARM_KA_FIXTURES=<путь>`, по умолчанию
`../fixtures` рядом с репозиторием); без каталога они пропускаются с причиной. Для каждого файла `fixtures/osv/**`
(включая `osv/synthetic/`) в `fixtures/golden/expected_osv_parse/<тот же путь без расширения>.json` лежит **golden-снимок**
результата разбора (как его печатает CLI, без `job_id`, `file_id`, `service.version` и `metrics.duration_ms`).
`tests/fixtures/test_golden.py` сравнивает снимок с текущим разбором; расхождение — падение теста с unified diff.
Снимок — **регрессия, а не оракул**: он фиксирует, что парсер выдаёт сейчас, и ничего не доказывает про корректность
(её проверяют ручная сверка «Итого» и property-тест). Поэтому после намеренной правки парсера:
```bash
make golden-update # перезаписать снимки, diff изменившихся — в stdout
git -C ../fixtures diff # просмотреть каждое изменение: оно должно быть следствием правки
git -C ../fixtures add golden && git -C ../fixtures commit # снимки — отдельным коммитом в репозитории fixtures
```
Править снимки вручную нельзя; новый файл в `osv/` без снимка — падение «нет снимка: выполните make golden-update».
Дополнительно: `tests/fixtures/test_rules_coverage.py` требует, чтобы каждая запись словарей `api/rules/*.yaml`
сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. `api/rules/README.md`), а
`tests/unit/parser/test_no_coordinates.py` — AST-линтер «без координат» в `src/parser/**`.
## Запуск локально против compose
```bash

View File

@ -10,7 +10,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
| Файл | Что описывает |
|---|---|
| `osv_headers.yaml` | маркер листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; служебные колонки (ИНН, КПП, код, количество, валюта); строка «Итого»; метки шапки (счёт, период, выводимые данные, валюта, параметры, субконто) |
| `osv_headers.yaml` | маркер листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `extra` под своим заголовком); строка «Итого»; метки шапки (счёт, период, выводимые данные, валюта, параметры, субконто) |
| `osv_groups.yaml` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
| `periods.yaml` | текст периода -> `period_quarter` и `is_cumulative` (§1.5); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
| `data_types.yaml` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.»; коды валют ISO 4217; синонимы валюты (`руб.` -> `RUB`) |
@ -32,6 +32,19 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
- Ошибка в словаре (нет обязательного ключа, неизвестный ключ, неверный регэксп или тип) — `RuntimeError` при
загрузке с именем файла и ключом.
## Покрытие словарей
Каждая запись распознавания (один `Pattern` в любом из четырёх словарей: `sheet_markers`, `statement_markers`,
`groups`, `debit`, `credit`, `service_columns`, `total_row`, `header_markers`, `mapping`, правила и `year`/`end`
периодов, `header`/`row`/`currency_aliases` типов данных) должна сработать хотя бы раз на реальных ОСВ или синтетике:
это проверяет `tests/fixtures/test_rules_coverage.py` (счётчик совпадений `Pattern`, сообщение — файл, ключ и паттерн
несработавших записей). Перечень `currency_codes` — список значений ISO 4217, а не синонимы, в покрытие не входит.
Несработавшая запись — либо мёртвый синоним (удалить из YAML вместе с тестом словаря), либо пробел в фикстурах
(добавить файл синтетики `s1x_…` с описанием в README fixtures). Так удалены `service_columns.kpp|code|quantity|currency`:
код читает только `inn`, остальные колонки идут в `extra`. Синтетика `s14`–`s16` закрывает остальные записи, которых нет в
реальных ОСВ (формы ББ/ОФР по одному признаку, названия групп сумм «Входящее/Начальное/Конечное/Исходящее сальдо»,
шапка без организации, период «2024», «Детализация: …», группировки `OTHER`).
## Что намеренно не в штатном словаре
Синоним «нач. остаток» / «начальный остаток» для группы `opening` не включён: он нужен, чтобы показать, что новый

View File

@ -38,18 +38,12 @@ debit:
credit:
- '^(кредит|кт|к т)\b'
# Служебные колонки таблицы по тексту заголовка: ИНН, КПП, код, количество, валюта. Ключ — имя колонки в `extra`
# Служебные колонки таблицы по тексту заголовка. Код знает только колонку ИНН; прочие колонки таблицы (КПП, код,
# количество, валюта и т. п.) попадают в `rows[].extra` под текстом своего заголовка и словаря не требуют.
# (Записи kpp/code/quantity/currency удалены: код их не читал, тест покрытия словарей R-22 их не находил.)
service_columns:
inn:
- '\bинн\b'
kpp:
- '\bкпп\b'
code:
- '^код\b'
quantity:
- '^(количество|кол)\b'
currency:
- '^валюта\b'
# Итоговая строка таблицы (начало текста первой колонки)
total_row:

View File

@ -0,0 +1,65 @@
"""Перезапись golden-снимков разбора ОСВ и diff изменившихся (design D-10).
Из каталога `api/`: `ENV_FILE=.env.test .venv/bin/python scripts/golden_update.py [--check]` (то же — `make
golden-update`, из корня репозитория). Файлы — `fixtures/osv/**` включая `synthetic/`; снимки — в
`fixtures/golden/expected_osv_parse/` (зеркало `osv/`). Без флага снимки перезаписываются, для изменившихся печатается
unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с разбором).
Снимок — регрессия, а не оракул: перед коммитом в репозиторий fixtures просмотрите diff (`git -C ../fixtures diff`)
и убедитесь, что каждое изменение — следствие намеренной правки парсера.
"""
import argparse
import sys
from collections.abc import Sequence
from pathlib import Path
API_ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(API_ROOT), str(API_ROOT / "tests")]
from fixtures_helpers import ( # noqa: E402
fixtures_skip_reason,
osv_fixture_files,
resolve_fixtures_dir,
synthetic_files,
)
from golden_helpers import sync_snapshots # noqa: E402
from src.parser import get_parser # noqa: E402
EXIT_OK = 0
EXIT_DIFFERS = 1
EXIT_NO_FIXTURES = 3
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Golden-снимки разбора ОСВ: перезапись + diff, либо --check")
parser.add_argument("--check", action="store_true", help="только сравнить снимки с разбором, ничего не писать")
options = parser.parse_args(argv)
reason = fixtures_skip_reason()
if reason is not None:
print(reason, file=sys.stderr)
return EXIT_NO_FIXTURES
root = resolve_fixtures_dir()
files = [*osv_fixture_files(root), *synthetic_files(root)]
report = sync_snapshots(root, files, get_parser(), write=not options.check)
for diff in report.diffs:
sys.stdout.write(diff if diff.endswith("\n") else diff + "\n")
mode = "режим --check, ничего не записано" if options.check else "снимки записаны"
print(
f"файлов: {len(files)}; без изменений: {report.unchanged}; новых: {len(report.created)}; "
f"изменено: {len(report.changed)}; лишних снимков: {len(report.orphans)} ({mode})",
file=sys.stderr,
)
for orphan in report.orphans:
print(f"лишний снимок{'' if options.check else ' удалён'}: {orphan}", file=sys.stderr)
return EXIT_DIFFERS if options.check and report.differs else EXIT_OK
if __name__ == "__main__":
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8")
raise SystemExit(main())

View File

@ -5,7 +5,13 @@ from collections.abc import Callable
from pathlib import Path
import pytest
from fixtures_helpers import fixtures_skip_reason, osv_fixture_files, resolve_fixtures_dir, synthetic_files
from fixtures_helpers import (
fixtures_skip_reason,
osv_fixture_files,
resolve_fixtures_dir,
synthetic_files,
synthetic_skip_reason,
)
from src.core.config import Settings
@ -51,6 +57,15 @@ def minimal_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Callabl
return build
@pytest.fixture(scope="session")
def synthetic_dir(fixtures_dir: Path) -> Path:
"""`fixtures/osv/synthetic`; тест пропускается с причиной, если синтетики в каталоге нет."""
reason = synthetic_skip_reason()
if reason is not None:
pytest.skip(reason)
return fixtures_dir / "osv" / "synthetic"
@pytest.fixture(scope="session")
def fixtures_dir() -> Path:
"""Каталог `../fixtures`; тест пропускается с причиной, если каталога нет."""
@ -62,11 +77,14 @@ def fixtures_dir() -> Path:
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
"""Параметры `osv_file` (реальные ОСВ) и `synthetic_file` (синтетика); без каталога — один параметр со skip."""
sources = {"osv_file": osv_fixture_files, "synthetic_file": synthetic_files}
for argname, listing in sources.items():
sources = {
"osv_file": (osv_fixture_files, fixtures_skip_reason),
"synthetic_file": (synthetic_files, lambda: fixtures_skip_reason() or synthetic_skip_reason()),
}
for argname, (listing, skip_reason) in sources.items():
if argname not in metafunc.fixturenames:
continue
reason = fixtures_skip_reason()
reason = skip_reason()
if reason is not None:
metafunc.parametrize(
argname, [pytest.param(None, marks=pytest.mark.skip(reason=reason))], ids=["нет-фикстур"]

63
api/tests/fixtures/test_golden.py vendored Normal file
View File

@ -0,0 +1,63 @@
"""Golden-снимки разбора (design D-10): результат парсера для каждого файла `fixtures/osv/**` и синтетики равен
снимку в `fixtures/golden/expected_osv_parse/` (зеркало дерева `osv/`), без volatile-полей.
Снимок — регрессия, а не оракул (оракулы — ручная сверка «Итого» и property-тест). Обновление — `make golden-update`,
отдельным коммитом в репозиторий fixtures с просмотром diff.
"""
from pathlib import Path
import pytest
from golden_helpers import golden_path, render, snapshot_text, unified_diff
from src.parser import Parser, get_parser
pytestmark = pytest.mark.fixtures
MISSING = "нет снимка: выполните make golden-update ({path})"
MAX_DIFF_CHARS = 6000
@pytest.fixture(scope="module")
def parser() -> Parser:
return get_parser()
def check_snapshot(fixtures_dir: Path, file: Path, parser: Parser) -> None:
snapshot = golden_path(fixtures_dir, file)
if not snapshot.is_file():
pytest.fail(MISSING.format(path=snapshot.relative_to(fixtures_dir).as_posix()), pytrace=False)
stored = snapshot.read_text(encoding="utf-8")
current = snapshot_text(render(parser, file))
if stored != current:
diff = unified_diff(stored, current, name=snapshot.relative_to(fixtures_dir).as_posix())
if len(diff) > MAX_DIFF_CHARS:
diff = (
diff[:MAX_DIFF_CHARS] + f"\n… (diff обрезан, всего {len(diff)} символов; полный — make golden-update)"
)
pytest.fail(
"результат разбора отличается от снимка (намеренная правка парсера — make golden-update и коммит "
f"в fixtures с просмотром diff):\n{diff}",
pytrace=False,
)
def test_osv_file_matches_golden_snapshot(fixtures_dir: Path, osv_file: Path, parser: Parser) -> None:
check_snapshot(fixtures_dir, osv_file, parser)
def test_synthetic_file_matches_golden_snapshot(fixtures_dir: Path, synthetic_file: Path, parser: Parser) -> None:
check_snapshot(fixtures_dir, synthetic_file, parser)
def test_every_snapshot_has_a_source_file(fixtures_dir: Path) -> None:
"""Лишний снимок (файл удалён или переименован) — ошибка: `make golden-update` его удаляет."""
from fixtures_helpers import osv_fixture_files, synthetic_files # noqa: PLC0415 - только для этого теста
expected = {
golden_path(fixtures_dir, file) for file in [*osv_fixture_files(fixtures_dir), *synthetic_files(fixtures_dir)]
}
directory = fixtures_dir / "golden" / "expected_osv_parse"
present = set(directory.rglob("*.json")) if directory.is_dir() else set()
assert sorted(path.relative_to(fixtures_dir).as_posix() for path in present - expected) == []

View File

@ -64,8 +64,8 @@ def test_every_real_osv_header_and_group_schema(osv_file: Path, rules: Rules) ->
assert doc.period_quarter is not None
assert doc.period_year is not None
assert doc.is_cumulative is not None
assert doc.data_type == "BU"
assert doc.currency == "RUB"
assert doc.data_type is not None # T3.9: значение («BU» и др.) не фиксируем — файлы не спецификация
assert doc.currency is not None
assert doc.header_variant == variant == 1
assert doc.warnings == ()
assert groups[0].type == "ACCOUNT"

View File

@ -0,0 +1,63 @@
"""Покрытие словарей `rules/*.yaml` (design D-10, ruling R-22): каждая запись распознавания срабатывает хотя бы раз на
реальных ОСВ и синтетике; мёртвый синоним — повод удалить его или добавить файл синтетики `s14_*`.
Запись — один `Pattern` словаря (`sheet_markers`, `statement_markers`, `groups`, `debit`, `credit`, `service_columns`,
`total_row`, `header_markers`, `mapping`, правила периодов, `year`, `end`, `header`/`row` типов данных,
`currency_aliases`). Перечень кодов `currency_codes` — список значений, не синонимы: в покрытие не входит. Записи
находятся обходом моделей `Rules`, поэтому новый паттерн в любом словаре попадает под проверку автоматически.
«Срабатывание» — `search`/`search_normalized` вернул совпадение. Счётчик — обёртка вокруг методов `Pattern` в тесте,
боевой код не меняется.
"""
from pathlib import Path
import pytest
from fixtures_helpers import osv_fixture_files, synthetic_files
from rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered
from src.parser import get_parser
from src.parser.osv import OsvParser
from src.parser.rules import DEFAULT_RULES_DIR, Rules, load_rules
pytestmark = pytest.mark.fixtures
@pytest.fixture(scope="module")
def coverage(fixtures_dir: Path) -> tuple[Rules, MatchCounter, int]:
monkeypatch = pytest.MonkeyPatch()
try:
counter = MatchCounter()
counter.install(monkeypatch)
rules = load_rules(DEFAULT_RULES_DIR)
base = get_parser()
assert isinstance(base, OsvParser)
parser = OsvParser(rules, max_bytes=base.max_bytes, timeout_seconds=base.timeout_seconds)
files = [*osv_fixture_files(fixtures_dir), *synthetic_files(fixtures_dir)]
for file in files:
parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type]
return rules, counter, len(files)
finally:
monkeypatch.undo()
def test_every_dictionary_entry_fires_on_fixtures_and_synthetic(coverage: tuple[Rules, MatchCounter, int]) -> None:
rules, counter, files = coverage
dead = uncovered(rules, counter)
assert dead == [], (
f"записи словарей, не сработавшие ни на одном из {files} файлов (удалите синоним или добавьте синтетику "
"s14_*):\n" + "\n".join(dead)
)
def test_coverage_sees_every_dictionary_entry_and_all_files(coverage: tuple[Rules, MatchCounter, int]) -> None:
"""Счётчик не пуст и обход видит записи всех четырёх словарей (защита от «покрытия» на пустом месте)."""
rules, counter, files = coverage
entries = list(iter_patterns(rules))
assert {file_name for file_name, _, _ in entries} == set(DICTIONARIES.values())
assert sum(counter.hits.values()) > len(entries)
assert files >= 66 # noqa: PLR2004 - реальные ОСВ из fixtures
assert not any(path.startswith("currency_codes") for _, path, _ in entries)

View File

@ -58,6 +58,7 @@ def test_real_osv_phrases_are_covered_by_dictionaries(osv_file: Path, rules: Rul
assert rules.periods.year.search(period_text) is not None, title
data_type_line = next(t for t in texts if any(p.search(t) for p in headers.header_markers["data_type"]))
assert first_match(rules.data_types.header, _after_colon(data_type_line)) == "BU"
# T3.9: реальные файлы не спецификация — не фиксируем «BU» и «RUB», проверяем, что словарь значение опознал
assert first_match(rules.data_types.header, _after_colon(data_type_line)) is not None
currency_line = next(t for t in texts if any(p.search(t) for p in headers.header_markers["currency"]))
assert rules.data_types.currency_code(_after_colon(currency_line)) == "RUB"
assert rules.data_types.currency_code(_after_colon(currency_line)) is not None

View File

@ -24,6 +24,16 @@ def fixtures_skip_reason() -> str | None:
return f"каталог фикстур не найден: {root} (задайте {FIXTURES_ENV}=<путь к репозиторию fixtures>)"
def synthetic_skip_reason() -> str | None:
"""Причина пропуска тестов на синтетике: каталог fixtures есть, но `osv/synthetic` отсутствует или пуст
(например, fixtures на main до слияния ветки с синтетикой). `None` — файлы есть или нет самих fixtures (тогда
причину даёт `fixtures_skip_reason`)."""
root = resolve_fixtures_dir()
if not (root / "osv").is_dir() or synthetic_files(root):
return None
return f"нет синтетики: {root / 'osv' / _SYNTHETIC} отсутствует или пуст (сгенерируйте: см. README fixtures)"
def osv_fixture_files(root: Path) -> list[Path]:
"""Все `*.xls/*.xlsx/*.xlsb` под `root/osv`, кроме каталога synthetic; отсортированы."""
osv = root / "osv"

147
api/tests/golden_helpers.py Normal file
View File

@ -0,0 +1,147 @@
"""Golden-снимки разбора ОСВ (design D-10): результат парсера в `fixtures/golden/expected_osv_parse/`.
Снимок — регрессия, а не оракул: он фиксирует то, что парсер выдаёт сейчас. Корректность проверяют ручная сверка
«Итого» трёх файлов (отчёт задачи) и property-тест `tests/fixtures/test_osv_property.py`.
Результат — тот же, что печатает CLI `src.parser.cli` (`core.worker.build_result` → `to_wire`), без полей, меняющихся
от запуска к запуску (`VOLATILE`). Порядок ключей и `warnings` не трогается: он значим для diff.
"""
import copy
import difflib
import json
from collections.abc import Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from src.contract import ResultStatus, ServiceInfo
from src.core.config import Settings
from src.core.worker import build_result
from src.parser import Parser
from src.parser.cli import STUB_ID, _error_payload, _file_type
__all__ = [
"SNAPSHOT_DIR",
"SyncReport",
"VOLATILE",
"golden_path",
"normalize_result",
"render",
"snapshot_text",
"sync_snapshots",
"unified_diff",
]
SNAPSHOT_DIR = "expected_osv_parse"
GOLDEN_DIR = "golden"
OSV_DIR = "osv"
JSON_INDENT = 2
# Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность
VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms"))
def normalize_result(result: dict[str, Any]) -> dict[str, Any]:
"""Копия результата без `VOLATILE`-полей; порядок остальных ключей и элементов списков сохраняется."""
normalized = copy.deepcopy(result)
for volatile in VOLATILE:
if isinstance(volatile, str):
normalized.pop(volatile, None)
else:
parent, key = volatile
inner = normalized.get(parent)
if isinstance(inner, dict):
inner.pop(key, None)
return normalized
def golden_path(fixtures_root: Path, file: Path) -> Path:
"""`golden/expected_osv_parse/<путь файла относительно osv/ без расширения>.json` (зеркало дерева `osv/`)."""
relative = file.relative_to(fixtures_root / OSV_DIR)
return fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR / relative.parent / f"{relative.stem}.json"
def render(parser: Parser, file: Path) -> dict[str, Any]:
"""Результат разбора файла, как его выдаёт CLI: `to_wire` с фиксированными `job_id`/`file_id`, затем нормализация.
Ошибка разбора выглядит как в CLI — `{"status": "error", "error": {...}}`; файл с неизвестным расширением — ошибка
использования (`ValueError`).
"""
file_type = _file_type(file, None)
if file_type is None:
raise ValueError(f"неизвестное расширение файла {file.suffix!r}: {file}")
outcome = parser.parse(file.read_bytes(), file_type)
if outcome.status is ResultStatus.ERROR:
return normalize_result(_error_payload(outcome))
service = ServiceInfo.model_validate(
{"name": Settings.model_fields["SERVICE_NAME"].default, "version": Settings.model_fields["VERSION"].default}
)
result = build_result(outcome, job_id=STUB_ID, file_id=STUB_ID, service=service, metrics=outcome.metrics)
return normalize_result(result.to_wire())
def snapshot_text(normalized: dict[str, Any]) -> str:
"""Текст снимка: как вывод CLI — без экранирования кириллицы, отступ 2, порядок ключей модели, «\\n» в конце."""
return json.dumps(normalized, ensure_ascii=False, indent=JSON_INDENT) + "\n"
def unified_diff(stored: str, current: str, *, name: str) -> str:
"""Unified diff «снимок в fixtures → разбор сейчас»; пустая строка, если тексты равны."""
return "".join(
difflib.unified_diff(
stored.splitlines(keepends=True),
current.splitlines(keepends=True),
fromfile=f"{name} (в fixtures)",
tofile=f"{name} (разбор сейчас)",
)
)
@dataclass
class SyncReport:
"""Итог сверки/обновления снимков: пути относительно корня fixtures."""
created: list[Path] = field(default_factory=list)
changed: list[Path] = field(default_factory=list)
orphans: list[Path] = field(default_factory=list)
unchanged: int = 0
diffs: list[str] = field(default_factory=list)
@property
def differs(self) -> bool:
return bool(self.created or self.changed or self.orphans)
def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *, write: bool) -> SyncReport:
"""Сверяет снимки `files` с текущим разбором; при `write` — перезаписывает изменившиеся и удаляет лишние.
Лишний снимок — файл в `golden/expected_osv_parse/`, которому не соответствует ни один файл из `files`.
Для изменившегося снимка в `report.diffs` — unified diff; для нового — весь текст как добавленный.
"""
report = SyncReport()
expected: set[Path] = set()
for file in files:
target = golden_path(fixtures_root, file)
expected.add(target)
current = snapshot_text(render(parser, file))
name = target.relative_to(fixtures_root).as_posix()
if not target.is_file():
report.created.append(target.relative_to(fixtures_root))
report.diffs.append(unified_diff("", current, name=name))
else:
stored = target.read_text(encoding="utf-8")
if stored == current:
report.unchanged += 1
continue
report.changed.append(target.relative_to(fixtures_root))
report.diffs.append(unified_diff(stored, current, name=name))
if write:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(current, encoding="utf-8")
snapshots = fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR
for stale in sorted(snapshots.rglob("*.json")) if snapshots.is_dir() else []:
if stale not in expected:
report.orphans.append(stale.relative_to(fixtures_root))
if write:
stale.unlink()
return report

View File

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

View File

@ -1,8 +1,9 @@
"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`.
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл (см. `osv/synthetic/README.md` в репозитории
fixtures). Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты —
метки времени книги и zip постоянны, порядок записи фиксирован.
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
ОСВ (R-22); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl +
`xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок
записи фиксирован.
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
@ -196,6 +197,10 @@ class OsvSpec:
bold_accounts: bool = False
repeat_header_after: int | None = None # после стольких строк данных повторяется заголовок (печать по страницам)
detail: str = "Контрагенты, Договоры"
detail_label: str = "Детализация по субсчетам, субконто" # метка перед перечнем субконто (вариант 2)
org_name: str | None = ORG # None — в шапке нет строки с названием организации
group_titles: dict[str, str] | None = None # названия групп сумм вместо `GROUP_TITLES`
labels_below: bool = False # вариант 1: «Счет» и «Контрагенты» — отдельными строками под подзаголовками сумм
GROUP_TITLES = {
@ -222,24 +227,33 @@ def _groups(spec: OsvSpec) -> list[tuple[str, int, int]]:
def _header_rows(sheet: SheetSpec, spec: OsvSpec, top: int, label: str, sub_label: str) -> int:
"""Блок заголовка таблицы с `top`: строка групп и строка Дебет/Кредит; возвращает номер следующей строки."""
sheet.put(top, 1, label)
sheet.put(top + 1, 1, sub_label)
"""Блок заголовка таблицы с `top`: строка групп и строка Дебет/Кредит; возвращает номер следующей строки.
`labels_below` (только для блока с меткой «Счет»): метки группировок и «ИНН» — отдельными строками под
Дебет/Кредит (так печатает 1С), в колонках сумм у них пусто.
"""
titles = spec.group_titles or GROUP_TITLES
below = spec.labels_below and bool(label)
label_row = top + 2 if below else top # строка меток группировок (первая) и колонок ИНН/КПП
sheet.put(label_row, 1, label)
sheet.put(label_row + 1, 1, sub_label)
service_row = label_row + 1 if below else top + 1
if spec.inn_col is not None:
sheet.put(top + 1, spec.inn_col, "ИНН")
sheet.put(service_row, spec.inn_col, "ИНН")
if spec.kpp_col is not None:
sheet.put(top + 1, spec.kpp_col, "КПП")
sheet.put(service_row, spec.kpp_col, "КПП")
for key, col, width in _groups(spec):
sheet.put(top, col, GROUP_TITLES[key])
sheet.put(top, col, titles[key])
sheet.merge(top, col, top, col + width - 1)
if width == PAIR_WIDTH:
sheet.put(top + 1, col, DEBIT)
sheet.put(top + 1, col + 1, CREDIT)
return top + 2
return top + 4 if below else top + 2
def _header_v1(sheet: SheetSpec, spec: OsvSpec) -> int:
sheet.put(1, 1, ORG)
if spec.org_name is not None:
sheet.put(1, 1, spec.org_name)
sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account} за {spec.period}")
sheet.put(4, 1, spec.data_type_line)
sheet.put(7, 1, "Параметры:")
@ -248,10 +262,11 @@ def _header_v1(sheet: SheetSpec, spec: OsvSpec) -> int:
def _header_v2(sheet: SheetSpec, spec: OsvSpec) -> int:
sheet.put(1, 1, ORG)
if spec.org_name is not None:
sheet.put(1, 1, spec.org_name)
sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account}")
sheet.put(3, 1, f"Период: {spec.period}")
sheet.put(4, 1, f"Детализация по субсчетам, субконто: {spec.detail}")
sheet.put(4, 1, f"{spec.detail_label}: {spec.detail}")
sheet.put(5, 1, spec.data_type_line)
top = 7
nxt = _header_rows(sheet, spec, top, "Субконто", "")
@ -366,6 +381,16 @@ def statement_sheet(name: str = "Бухгалтерский баланс") -> Sh
return sheet
def form_sheet(name: str, *lines: str) -> SheetSpec:
"""Лист формы без таблицы: в шапке только `lines` (так проверяется каждый признак формы по отдельности)."""
sheet = SheetSpec(name)
for row, line in enumerate(lines, start=1):
sheet.put(row, 1, line)
sheet.put(len(lines) + 2, 1, "Показатель")
sheet.put(len(lines) + 2, 2, "Код")
return sheet
# --- Файлы ---
@ -447,6 +472,8 @@ def _s06() -> list[SheetSpec]:
def _s07() -> list[SheetSpec]:
# Смешанные знаки у детей (кредитовое у Альфы, дебетовое у Беты) намеренно проверяют правило R-21: свёрнутое сальдо
# родителя сверяется по нетто, а не по сумме модулей
first = leaf("ООО Альфа", pair(None, None), pair("100000.00", "350000.50"), inn="7701234567")
second = leaf("ООО Бета", pair(None, None), pair("60000.00", "10000.00"), inn="7707654321")
third = leaf("ООО Гамма", pair(None, None), pair("20000.00", "20000.00"), inn="5001234567")
@ -499,6 +526,58 @@ def _s13() -> list[SheetSpec]:
return [build_osv(spec, base_tree())]
def _s14() -> list[SheetSpec]:
"""Три листа форм, опознаваемые каждый одним своим признаком (покрытие `statement_markers`), и лист ОСВ."""
return [
form_sheet("Форма 1", "Форма по ОКУД 0710001"),
form_sheet("ОФР", "Отчет о финансовых результатах"),
form_sheet("Форма 2", "Форма по ОКУД 0710002"),
build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"),
]
def _s15() -> list[SheetSpec]:
"""Названия групп сумм: «Входящее/Конечное сальдо» на первом листе, «Начальное/Исходящее сальдо» на втором."""
first = OsvSpec(
account="60",
inn_col=5,
first_sum_col=7,
group_titles={"opening": "Входящее сальдо", "turnover": "Обороты", "closing": "Конечное сальдо"},
)
second = OsvSpec(
account="62",
inn_col=3,
first_sum_col=4,
group_titles={"opening": "Начальное сальдо", "turnover": "Обороты", "closing": "Исходящее сальдо"},
)
return [build_osv(first, base_tree(), "ОСВ 60"), build_osv(second, receivables_tree(), "ОСВ 62")]
def _s16() -> list[SheetSpec]:
"""Шапка без названия организации; период — только год; валюта с пояснением; метки группировок отдельными
строками («Счет» среди них); перечень «Детализация: …» без слова «субконто» и группировки вне ключевых."""
first = OsvSpec(
account="60",
period="2024",
currency_line="Валюта БУ, НУ: RUB (код 643)",
org_name=None,
labels_below=True,
inn_col=5,
first_sum_col=7,
)
second = OsvSpec(
account="62",
variant=2,
period="2024",
org_name=None,
detail_label="Детализация по субсчетам",
detail="Номенклатура, Статьи затрат, Нематериальные активы, Валюта",
inn_col=None,
first_sum_col=3,
)
return [build_osv(first, base_tree(), "ОСВ 60"), build_osv(second, receivables_tree(), "ОСВ 62")]
# --- Запись файлов ---
@ -569,6 +648,9 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
"s11_header_repeat.xlsx": (_s11, to_xlsx),
"s12_statement_and_osv.xlsx": (_s12, to_xlsx),
"s13_no_outline_indent.xlsb": (_s13, to_xlsb),
"s14_statement_forms.xlsx": (_s14, to_xlsx),
"s15_group_title_synonyms.xlsx": (_s15, to_xlsx),
"s16_header_synonyms.xlsx": (_s16, to_xlsx),
}
FILE_NAMES = tuple(FILES)
@ -587,7 +669,7 @@ def generate_all(directory: Path) -> list[Path]:
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m tests.synthetic.generate",
description="Синтетические ОСВ s01…s13 (xlsx и один xlsb) в указанный каталог.",
description="Синтетические ОСВ s01…s16 (xlsx и один xlsb) в указанный каталог.",
)
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
args = parser.parse_args(argv)

View File

@ -26,7 +26,7 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None:
assert left.read_bytes() == right.read_bytes(), left.name
def test_generated_set_is_s01_to_s13_with_one_xlsb(tmp_path: Path) -> None:
def test_generated_set_is_s01_to_s16_with_one_xlsb(tmp_path: Path) -> None:
names = [path.name for path in generate_all(tmp_path)]
assert names == list(FILE_NAMES)

View File

@ -19,6 +19,12 @@ from src.parser import ParseOutcome, get_parser
pytestmark = pytest.mark.fixtures
@pytest.fixture(autouse=True)
def _require_synthetic(synthetic_dir: Path) -> None:
"""T11.1: fixtures есть, а `osv/synthetic` нет/пуст (fixtures на main до слияния ветки) — skip, а не падение."""
SYNTHETIC_DIR = Path("osv") / "synthetic"
EXPECTED_NAMES = [
"s01_variant2_header.xlsx",
@ -34,8 +40,13 @@ EXPECTED_NAMES = [
"s11_header_repeat.xlsx",
"s12_statement_and_osv.xlsx",
"s13_no_outline_indent.xlsb",
"s14_statement_forms.xlsx",
"s15_group_title_synonyms.xlsx",
"s16_header_synonyms.xlsx",
]
XLSB_MISSING_REASON = "нет файла s13_no_outline_indent.xlsb (xlsb не получен; см. README fixtures)"
XLSB_NAME = "s13_no_outline_indent.xlsb"
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
ORG = 'ООО "Тестовая организация"'
VARIANT_2 = 2
XLSB_WARNING = "xlsb: outline level недоступен"
@ -57,6 +68,9 @@ class Expect:
documents: int = 1
variant: int = 1
data_type: str = "BU"
organization: str = ORG
# Вариант шапки по документам (если у листов файла он разный); пусто — `variant` у всех
variants: tuple[int, ...] = ()
EXPECTED: dict[str, Expect] = {
@ -80,6 +94,12 @@ EXPECTED: dict[str, Expect] = {
# «январь - сентябрь», данные «НУ»
"s12_statement_and_osv.xlsx": Expect("outline", 2025, "Q3", True, data_type="NU"),
"s13_no_outline_indent.xlsb": Expect("indent", 2025, "Q2", False),
# три листа форм по одному признаку + лист ОСВ
"s14_statement_forms.xlsx": Expect("outline", 2025, "Q1", True),
# названия групп сумм: «Входящее/Конечное», «Начальное/Исходящее сальдо»
"s15_group_title_synonyms.xlsx": Expect("outline", 2025, "Q1", True, documents=2),
# шапка без организации, период «2024»; лист 1 — вариант 1, лист 2 — вариант 2
"s16_header_synonyms.xlsx": Expect("outline", 2024, "Q4", True, documents=2, organization="", variants=(1, 2)),
}
@ -152,11 +172,11 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s13 (s13 — xlsb; допустима деградация: нет только его)."""
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s16 (s13 — xlsb; допустима деградация: нет только его)."""
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file())
assert present == [name for name in EXPECTED_NAMES if name in present]
missing = [name for name in EXPECTED_NAMES if name not in present]
assert missing in ([], [EXPECTED_NAMES[-1]]), f"нет файлов набора: {missing}"
assert missing in ([], [XLSB_NAME]), f"нет файлов набора: {missing}"
def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None:
@ -175,13 +195,14 @@ def test_header_status_and_hierarchy(synthetic_file: Path) -> None:
assert len(docs) == expect.documents
hierarchy_lines = [w for w in warnings_of(outcome) if f"hierarchy={expect.hierarchy}" in w]
assert len(hierarchy_lines) == expect.documents, warnings_of(outcome)
variants = expect.variants or (expect.variant,) * expect.documents
assert [doc.header.header_variant for doc in docs] == list(variants)
for doc in docs:
assert doc.header.header_variant == expect.variant
assert doc.header.period_year == expect.year
assert doc.header.period_quarter == expect.quarter
assert doc.header.is_cumulative is expect.cumulative
assert doc.header.data_type == expect.data_type
assert doc.header.organization_name == 'ООО "Тестовая организация"'
assert doc.header.organization_name == expect.organization
assert doc.rows[-1].group_type == "TOTAL"
if expect.status is ResultStatus.DONE:
assert doc.checks is not None
@ -300,7 +321,11 @@ def test_s06_two_osv_sheets_and_other_sheet(fixtures_dir: Path) -> None:
def test_s07_no_opening_and_collapsed_closing(fixtures_dir: Path) -> None:
"""«Без начала и со свёрнутым сальдо»: `opening_* = null`, отрицательное свёрнутое — в `closing_credit`, warning."""
"""«Без начала и со свёрнутым сальдо»: `opening_* = null`, отрицательное свёрнутое — в `closing_credit`, warning.
T11.2: смешанные знаки у детей (Альфа — кредитовое, Бета — дебетовое) намеренно проверяют правило R-21: свёрнутое
сальдо родителя сверяется по нетто (дебет минус кредит), а не по сумме модулей.
"""
outcome = parse_named(fixtures_dir, "s07_no_opening_collapsed_closing.xlsx")
doc = one_document(outcome)
@ -412,3 +437,49 @@ def test_s13_xlsb_equals_s02_except_locator_and_warnings(fixtures_dir: Path) ->
assert xlsb.metrics is not None
assert xlsx.metrics is not None
assert xlsb.metrics.rows == xlsx.metrics.rows
def test_s14_each_statement_form_is_recognised_by_its_own_marker(fixtures_dir: Path) -> None:
"""Три листа форм (код ОКУД 0710001, «Отчет о финансовых результатах», код 0710002) пропущены, ОСВ разобран."""
outcome = parse_named(fixtures_dir, "s14_statement_forms.xlsx")
(doc,) = documents(outcome)
assert doc.locator == "sheet:ОСВ 60"
skipped = [w for w in warnings_of(outcome) if "skipped: statement" in w]
assert [w.split(" skipped")[0] for w in skipped] == ["sheet=Форма 1", "sheet=ОФР", "sheet=Форма 2"]
assert outcome.status is ResultStatus.DONE
def test_s15_group_title_synonyms_find_all_six_sum_columns(fixtures_dir: Path) -> None:
"""«Входящее/Конечное сальдо» (лист 60), «Начальное/Исходящее сальдо» (лист 62): шесть колонок, суммы верны."""
outcome = parse_named(fixtures_dir, "s15_group_title_synonyms.xlsx")
first, second = documents(outcome)
assert (first.locator, second.locator) == ("sheet:ОСВ 60", "sheet:ОСВ 62")
alfa = by_text(first)[ALFA]
assert sums(alfa) == (None, D("250000.50"), D("180000.25"), D("420000.00"), None, D("490000.25"))
buyer = by_text(second)["ООО Покупатель-1"]
assert sums(buyer) == (D("500000.00"), None, D("320000.00"), D("410000.00"), D("410000.00"), None)
assert outcome.status is ResultStatus.DONE
def test_s16_header_without_organization_year_only_period_and_currency_note(fixtures_dir: Path) -> None:
"""Шапка без организации (предупреждение), период «2024» -> Q4, валюта «RUB (код 643)» -> RUB, метки группировок
отдельными строками («Счет» среди них), «Детализация: …» без слова «субконто» и группировки вне ключевых."""
outcome = parse_named(fixtures_dir, "s16_header_synonyms.xlsx")
first, second = documents(outcome)
assert first.header.organization_name == ""
assert any("организации в шапке не найдено" in w for w in warnings_of(outcome))
assert (first.header.period_text, first.header.period_year, first.header.period_quarter) == ("2024", 2024, "Q4")
assert first.header.currency == "RUB"
assert [group.type for group in first.group_schema] == ["ACCOUNT", "COUNTERPARTY"]
assert second.header.header_variant == VARIANT_2
assert [(group.type, group.name) for group in second.group_schema] == [
("ACCOUNT", "Субконто"),
("OTHER", "Номенклатура"),
("OTHER", "Статьи затрат"),
("OTHER", "Нематериальные активы"),
("OTHER", "Валюта"),
]
assert outcome.status is ResultStatus.DONE

View File

@ -27,11 +27,14 @@ EXCLUDED_DIRS = frozenset({"readers"})
# (а) адреса и буквы колонок: «E9», «A», «AB», «XFD1»
ADDRESS_LITERAL = re.compile(r"^[A-Z]{1,3}\d{0,7}$")
ADDRESS_MAX_LENGTH = 4
ADDRESS_MAX_LENGTH = 10 # «XFD1048576» — самый длинный адрес Excel; T3.1: прежний предел 4 пропускал «AB123», «E1000»
# (б) имя листа по умолчанию целиком (R-4): сообщения вроде «лист ОСВ без заголовка» разрешены
DEFAULT_SHEET_NAME = re.compile(r"(?i)^(лист|sheet)[\s_]*\d*$")
# (в) сравнение с номером строки/колонки
COORDINATE_NAMES = frozenset({"row", "row_number", "r", "col", "column"})
# (г) обращение к ячейке листа двумя числовыми константами: `sheet.cell(3, 4)`, `sheet.text_at(3, 4)` (T3.8)
CELL_ACCESSORS = frozenset({"cell", "text_at"})
CELL_ARGUMENTS = 2 # строка и колонка
@dataclass(frozen=True)
@ -114,6 +117,18 @@ def _is_int_constant(node: ast.AST) -> bool:
return isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool)
def _is_constant_cell_call(node: ast.Call) -> bool:
"""`cell(3, 4)` / `x.text_at(3, 4)` / `x.cell(row=3, col=4)`: строка и колонка заданы числовыми константами."""
func = node.func
name = func.id if isinstance(func, ast.Name) else func.attr if isinstance(func, ast.Attribute) else None
if name not in CELL_ACCESSORS:
return False
arguments = [*node.args, *(keyword.value for keyword in node.keywords)]
return len(arguments) >= CELL_ARGUMENTS and all(
_is_int_constant(argument) for argument in arguments[:CELL_ARGUMENTS]
)
def _violations_in_tree(path: Path, tree: ast.AST, allowed_strings: frozenset[str]) -> Iterator[Violation]:
exempt = _exempt_constants(tree)
for node in ast.walk(tree):
@ -132,6 +147,8 @@ def _violations_in_tree(path: Path, tree: ast.AST, allowed_strings: frozenset[st
for left, right in pairs
):
yield Violation(path, node.lineno, "сравнение номера строки/колонки с числовой константой")
elif isinstance(node, ast.Call) and _is_constant_cell_call(node):
yield Violation(path, node.lineno, "обращение к ячейке константами (строка, колонка)")
elif (
isinstance(node, ast.Subscript)
and not _is_literal_subscript(node) # Literal[1, 2] — значения типа контракта, не индекс ячейки
@ -209,7 +226,7 @@ def test_cell_address_literal_is_caught_with_file_and_line(tmp_path: Path) -> No
assert "'E9'" in violation.message
@pytest.mark.parametrize("literal", ["A", "AB", "AZ12", "XFD1"])
@pytest.mark.parametrize("literal", ["A", "AB", "AZ12", "XFD1", "AB123", "E1000", "ZZ99999", "XFD1048576"])
def test_column_letters_are_caught(tmp_path: Path, literal: str) -> None:
root = _tree(tmp_path, {"detect.py": f'letter = "{literal}"\n'})
@ -312,3 +329,54 @@ def test_int_literal_type_is_not_a_cell_subscript(tmp_path: Path) -> None:
root = _tree(tmp_path, {"detect.py": "from typing import Literal\nVariant = Literal[1, 2]\n"})
assert find_violations(root) == []
@pytest.mark.parametrize(
"call",
[
"sheet.cell(3, 4)",
"cell(3, 4)",
"self.sheet.text_at(3, 4)",
"sheet.cell(row=3, col=4)",
"sheet.cell(3, 4, extra)",
],
)
def test_cell_call_with_constant_row_and_column_is_caught(tmp_path: Path, call: str) -> None:
root = _tree(tmp_path, {"detect.py": f"value = {call}\n"})
(violation,) = find_violations(root)
assert violation.line == 1
assert "обращение к ячейке" in violation.message
@pytest.mark.parametrize(
"call",
[
"sheet.cell(row, col)",
"sheet.cell(3, col)",
"sheet.cell(row, 4)",
"sheet.text_at(item.row, 2)",
"sheet.cells(3, 4)",
"other(3, 4)",
],
)
def test_cell_call_with_variable_or_foreign_arguments_is_allowed(tmp_path: Path, call: str) -> None:
root = _tree(tmp_path, {"detect.py": f"value = {call}\n"})
assert find_violations(root) == []
def test_planted_file_with_every_kind_of_coordinate_is_caught_line_by_line(tmp_path: Path) -> None:
"""Намеренно подложенный файл: адрес «E9», буква «A», имя листа, `row == 5`, `cells[(3, 4)]`, `cell(3, 4)`."""
code = (
'address = "E9"\n'
'letter = "A"\n'
'name = "Лист1"\n'
"header = row == 5\n"
"value = cells[(3, 4)]\n"
"other = sheet.cell(3, 4)\n"
)
root = _tree(tmp_path, {"classify.py": code})
assert [violation.line for violation in find_violations(root)] == [1, 2, 3, 4, 5, 6]

View File

@ -320,16 +320,17 @@ def test_account_marker_ends_right_before_the_account_code(rules: Rules) -> None
[
("inn", "ИНН"),
("inn", "Контрагенты, ИНН"),
("kpp", "КПП"),
("code", "Код"),
("quantity", "Количество"),
("currency", "Валюта"),
],
)
def test_service_columns(rules: Rules, kind: str, text: str) -> None:
assert any(p.search(text) for p in rules.headers.service_columns[kind])
def test_service_columns_hold_only_what_the_code_reads(rules: Rules) -> None:
"""R-22: КПП, код, количество и валюта словаря не требуют — они идут в `extra` под своим заголовком."""
assert list(rules.headers.service_columns) == ["inn"]
@pytest.mark.parametrize("text", ["Итого", "ИТОГО", "Итого по счету"])
def test_total_row(rules: Rules, text: str) -> None:
assert any(p.search(text) for p in rules.headers.total_row)

View File

@ -9,6 +9,7 @@ from fixtures_helpers import (
osv_fixture_files,
resolve_fixtures_dir,
synthetic_files,
synthetic_skip_reason,
)
@ -57,3 +58,26 @@ def test_skip_reason_none_when_available(tmp_path: Path, monkeypatch: pytest.Mon
(tmp_path / "osv").mkdir()
monkeypatch.setenv(FIXTURES_ENV, str(tmp_path))
assert fixtures_skip_reason() is None
def test_synthetic_skip_reason_when_fixtures_exist_but_synthetic_is_missing_or_empty(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / "osv").mkdir()
monkeypatch.setenv(FIXTURES_ENV, str(tmp_path))
reason = synthetic_skip_reason()
assert reason is not None
assert "osv" in reason
assert "synthetic" in reason
(tmp_path / "osv" / "synthetic").mkdir()
(tmp_path / "osv" / "synthetic" / "README.md").write_bytes(b"")
assert synthetic_skip_reason() == reason
(tmp_path / "osv" / "synthetic" / "s01.xlsx").write_bytes(b"")
assert synthetic_skip_reason() is None
def test_synthetic_skip_reason_is_none_without_fixtures(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(FIXTURES_ENV, str(tmp_path / "нет"))
assert synthetic_skip_reason() is None # причину тогда даёт fixtures_skip_reason

View File

@ -0,0 +1,77 @@
"""Помощники golden-снимков: нормализация volatile-полей, путь снимка, формат записи (design D-10)."""
import json
from pathlib import Path
from golden_helpers import SNAPSHOT_DIR, golden_path, normalize_result, snapshot_text, unified_diff
def _result() -> dict:
return {
"schema_version": "1.1",
"job_id": "00000000-0000-0000-0000-000000000000",
"file_id": "00000000-0000-0000-0000-000000000000",
"service": {"name": "tabular-parser", "version": "0.1.0"},
"status": "done",
"metrics": {"duration_ms": 12, "rows": 7, "warnings": ["б", "а"]},
"documents": [{"z": 1, "a": 2}],
}
def test_normalize_removes_exactly_the_volatile_fields() -> None:
normalized = normalize_result(_result())
assert "job_id" not in normalized
assert "file_id" not in normalized
assert normalized["service"] == {"name": "tabular-parser"}
assert normalized["metrics"] == {"rows": 7, "warnings": ["б", "а"]}
assert normalized["documents"] == [{"z": 1, "a": 2}]
def test_normalize_keeps_key_and_warning_order_and_does_not_mutate_input() -> None:
source = _result()
normalized = normalize_result(source)
assert list(normalized) == ["schema_version", "service", "status", "metrics", "documents"]
assert list(normalized["documents"][0]) == ["z", "a"]
assert normalized["metrics"]["warnings"] == ["б", "а"]
assert source == _result()
def test_normalize_tolerates_missing_volatile_fields() -> None:
assert normalize_result({"status": "error"}) == {"status": "error"}
def test_golden_path_mirrors_osv_tree_without_extension(tmp_path: Path) -> None:
root = tmp_path
file = root / "osv" / "rb" / "2025-Q1" / "ОСВ сч 60.xlsx"
assert golden_path(root, file) == root / "golden" / SNAPSHOT_DIR / "rb" / "2025-Q1" / "ОСВ сч 60.json"
synthetic = root / "osv" / "synthetic" / "s01.xlsx"
assert golden_path(root, synthetic) == root / "golden" / SNAPSHOT_DIR / "synthetic" / "s01.json"
def test_golden_path_keeps_dots_inside_the_name(tmp_path: Path) -> None:
file = tmp_path / "osv" / "a" / "сч 62 за 3 квартал 2024 г..xls"
assert golden_path(tmp_path, file).name == "сч 62 за 3 квартал 2024 г..json"
def test_snapshot_text_is_utf8_json_with_indent_and_trailing_newline() -> None:
text = snapshot_text({"имя": "ОСВ", "n": ["1"]})
assert text.endswith("}\n")
assert "ОСВ" in text
assert json.loads(text) == {"имя": "ОСВ", "n": ["1"]}
assert text.startswith('{\n "имя"')
def test_unified_diff_names_files_and_shows_changed_lines() -> None:
diff = unified_diff("a\nb\n", "a\nc\n", name="снимок.json")
assert "--- снимок.json (в fixtures)" in diff
assert "+++ снимок.json (разбор сейчас)" in diff
assert "-b" in diff
assert "+c" in diff
assert unified_diff("a\n", "a\n", name="x") == ""

View File

@ -0,0 +1,108 @@
"""`sync_snapshots` и `scripts/golden_update.py`: запись, сравнение, diff, лишние снимки (на временном каталоге)."""
import sys
from pathlib import Path
import pytest
from golden_helpers import golden_path, render, snapshot_text, sync_snapshots
from src.parser import get_parser
from tests.synthetic.generate import generate_all
FILES = 2 # s01 и s02
API_ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture(scope="module")
def fixtures_root(tmp_path_factory: pytest.TempPathFactory) -> Path:
root = tmp_path_factory.mktemp("fixtures")
generate_all(root / "osv" / "synthetic")
return root
@pytest.fixture
def root(fixtures_root: Path, tmp_path: Path) -> Path:
"""Копия дерева `osv/` на каждый тест: снимки не пересекаются между тестами."""
copy = tmp_path / "fx"
(copy / "osv" / "synthetic").mkdir(parents=True)
for source in sorted((fixtures_root / "osv" / "synthetic").glob("s0[12]*")):
(copy / "osv" / "synthetic" / source.name).write_bytes(source.read_bytes())
return copy
def files_of(root: Path) -> list[Path]:
return sorted((root / "osv" / "synthetic").glob("s*"))
def test_first_run_creates_snapshots_equal_to_render(root: Path) -> None:
parser = get_parser()
report = sync_snapshots(root, files_of(root), parser, write=True)
assert len(report.created) == FILES
assert report.changed == [] and report.orphans == [] and report.unchanged == 0
for file in files_of(root):
assert golden_path(root, file).read_text(encoding="utf-8") == snapshot_text(render(parser, file))
assert (root / "golden" / "expected_osv_parse" / "synthetic" / "s01_variant2_header.json").is_file()
def test_second_run_reports_nothing_and_check_does_not_write(root: Path) -> None:
parser = get_parser()
sync_snapshots(root, files_of(root), parser, write=True)
again = sync_snapshots(root, files_of(root), parser, write=False)
assert again.unchanged == FILES
assert not again.differs
assert again.diffs == []
def test_changed_snapshot_gives_diff_and_check_leaves_it_untouched(root: Path) -> None:
parser = get_parser()
sync_snapshots(root, files_of(root), parser, write=True)
target = golden_path(root, files_of(root)[0])
original = target.read_text(encoding="utf-8")
target.write_text(original.replace('"done"', '"needs_check"', 1), encoding="utf-8")
checked = sync_snapshots(root, files_of(root), parser, write=False)
assert checked.changed == [target.relative_to(root)]
assert '- "status": "needs_check"' in checked.diffs[0]
assert '+ "status": "done"' in checked.diffs[0]
assert target.read_text(encoding="utf-8") != original
written = sync_snapshots(root, files_of(root), parser, write=True)
assert written.changed == [target.relative_to(root)]
assert target.read_text(encoding="utf-8") == original
def test_orphan_snapshot_is_reported_and_removed_on_write(root: Path) -> None:
parser = get_parser()
sync_snapshots(root, files_of(root), parser, write=True)
orphan = root / "golden" / "expected_osv_parse" / "synthetic" / "s99_gone.json"
orphan.write_text("{}\n", encoding="utf-8")
checked = sync_snapshots(root, files_of(root), parser, write=False)
assert checked.orphans == [orphan.relative_to(root)]
assert orphan.exists()
sync_snapshots(root, files_of(root), parser, write=True)
assert not orphan.exists()
def test_script_check_exit_codes(
root: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setenv("ARM_KA_FIXTURES", str(root))
monkeypatch.syspath_prepend(str(API_ROOT / "scripts"))
sys.modules.pop("golden_update", None)
import golden_update # noqa: PLC0415 - модуль-скрипт
assert golden_update.main(["--check"]) == 1
assert golden_update.main([]) == 0
assert golden_update.main(["--check"]) == 0
monkeypatch.setenv("ARM_KA_FIXTURES", str(root / "нет"))
capsys.readouterr()
assert golden_update.main([]) == golden_update.EXIT_NO_FIXTURES
assert "каталог фикстур не найден" in capsys.readouterr().err

View File

@ -0,0 +1,75 @@
"""Механизм покрытия словарей (R-22) без каталога fixtures: обход записей, счётчик срабатываний, сообщение о мёртвых."""
import shutil
from pathlib import Path
import pytest
from rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered
from src.parser.rules import DEFAULT_RULES_DIR, Pattern, load_rules
def test_every_pattern_of_every_dictionary_is_enumerated_with_file_and_key() -> None:
rules = load_rules()
entries = list(iter_patterns(rules))
assert {file_name for file_name, _, _ in entries} == set(DICTIONARIES.values())
keys = {(file_name, path) for file_name, path, _ in entries}
assert ("osv_headers.yaml", "sheet_markers[0]") in keys
assert ("osv_headers.yaml", "groups.opening[0]") in keys
assert ("osv_groups.yaml", "mapping[0][0]") in keys
assert ("periods.yaml", "rules[0].pattern") in keys
assert ("periods.yaml", "year") in keys
assert ("data_types.yaml", "currency_aliases.RUB[0]") in keys
# R-22: перечень кодов ISO — значения, не синонимы
assert not any("currency_codes" in path for _, path, _ in entries)
assert all(isinstance(pattern, Pattern) for _, _, pattern in entries)
def test_counter_counts_matches_of_search_and_search_normalized_only(monkeypatch: pytest.MonkeyPatch) -> None:
rules = load_rules()
counter = MatchCounter()
counter.install(monkeypatch)
total = rules.headers.total_row[0]
inn = rules.headers.service_columns["inn"][0]
assert total.search("Итого") is not None
assert inn.search_normalized("инн") is not None
assert inn.search_normalized("нет такого") is None
dead = uncovered(rules, counter)
assert not any("total_row[0]" in line or "service_columns.inn[0]" in line for line in dead)
assert any("sheet_markers[0]" in line for line in dead)
def test_message_lists_file_key_and_pattern_of_a_dead_entry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
rules_dir = tmp_path / "rules"
shutil.copytree(DEFAULT_RULES_DIR, rules_dir)
headers = rules_dir / "osv_headers.yaml"
headers.write_text(
headers.read_text(encoding="utf-8").replace(
"total_row:\n - '^итого\\b'", "total_row:\n - '^итого\\b'\n - '^никогда не встречается$'"
),
encoding="utf-8",
)
rules = load_rules(rules_dir)
counter = MatchCounter()
counter.install(monkeypatch)
assert rules.headers.total_row[0].search("Итого по счету") is not None
assert rules.headers.total_row[1].search("Итого по счету") is None
dead = uncovered(rules, counter)
assert "osv_headers.yaml: total_row[1]: '^никогда не встречается$'" in dead
assert not any("total_row[0]" in line for line in dead)
def test_counter_does_not_change_matching_results(monkeypatch: pytest.MonkeyPatch) -> None:
pattern = Pattern.compile(r"\bбу\b")
before = [pattern.search("данные БУ"), pattern.search("нет")]
MatchCounter().install(monkeypatch)
after = [pattern.search("данные БУ"), pattern.search("нет")]
assert [m.group(0) if m else None for m in after] == [m.group(0) if m else None for m in before]

View File

@ -422,9 +422,9 @@ def render(parser, file: Path) -> dict # как CLI: RecognitionResult
# test_rules_coverage.py: прогоняет парсер по всем фикстурам+синтетике с Rules, обёрнутыми в счётчик срабатываний Pattern; каждая запись каждого словаря ≥ 1; сообщение — список несработавших
```
- [ ] **Step 1:** тесты: `test_golden.py` (параметры `osv_file` и `synthetic_file`): снимок существует (иначе падение «нет снимка: make golden-update») и равен нормализованному результату (diff в сообщении); линтер — проверить, что намеренно подложенный файл с `"E9"` ловится (тест линтера на временном дереве); покрытие словарей.
- [ ] **Step 2:** реализация; первичная запись снимков `make golden-update`; **ручная сверка** трёх файлов в отчёте задачи: `rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` (Итого 371 372 027.47 / 2 034 858 970.41 / 2 148 560 264.30 / 485 073 321.36 — opening_credit, turnover_debit, turnover_credit, closing_credit), `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` (Итого 67 604 324.76 / 139 931 692.60 / 113 996 722.90 / 93 539 294.46 — opening_debit, turnover_debit, turnover_credit, closing_debit), `rb/2024-Q3/сч 583_3 кв 2024.xlsx` (корень `58.03`); несработавшие записи словарей — удалить или добавить синтетику (новый файл `s14_*` допустим, с обновлением Task 11 таблицы).
- [ ] **Step 3:** коммит в fixtures: `golden/expected_osv_parse: снимки парсера для 66 ОСВ и синтетики`; коммит здесь: `test: golden-снимки, линтер координат, покрытие словарей; make golden-update`.
- [x] **Step 1:** тесты: `test_golden.py` (параметры `osv_file` и `synthetic_file`): снимок существует (иначе падение «нет снимка: make golden-update») и равен нормализованному результату (diff в сообщении); линтер — проверить, что намеренно подложенный файл с `"E9"` ловится (тест линтера на временном дереве); покрытие словарей.
- [x] **Step 2:** реализация; первичная запись снимков `make golden-update`; **ручная сверка** трёх файлов в отчёте задачи: `rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` (Итого 371 372 027.47 / 2 034 858 970.41 / 2 148 560 264.30 / 485 073 321.36 — opening_credit, turnover_debit, turnover_credit, closing_credit), `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` (Итого 67 604 324.76 / 139 931 692.60 / 113 996 722.90 / 93 539 294.46 — opening_debit, turnover_debit, turnover_credit, closing_debit), `rb/2024-Q3/сч 583_3 кв 2024.xlsx` (корень `58.03`); несработавшие записи словарей — удалить или добавить синтетику (новый файл `s14_*` допустим, с обновлением Task 11 таблицы).
- [x] **Step 3:** коммит в fixtures: `golden/expected_osv_parse: снимки парсера для 66 ОСВ и синтетики`; коммит здесь: `test: golden-снимки, линтер координат, покрытие словарей; make golden-update`.
---

View File

@ -58,11 +58,11 @@
шапки; без outline (отступы); без outline и отступов; Вал./Кол.; текстовые числа и скобки; два листа ОСВ + посторонний;
без «начала» + свёрнутое сальдо; КПП-колонка; название субсчёта в строке; испорченная сумма; плюс один xlsb, сконвертированный
вручную из синтетического xlsx и закоммиченный с пометкой в README fixtures. Тесты сценариев спеки на этих файлах.
- [ ] 4.2 Golden: `tests/golden_helpers.py` (нормализация результата без volatile-полей), тест сравнения для всех
- [x] 4.2 Golden: `tests/golden_helpers.py` (нормализация результата без volatile-полей), тест сравнения для всех
`fixtures/osv/**` включая синтетику, `make golden-update` (перезапись + diff), первичная запись снимков в
`../fixtures/golden/expected_osv_parse/` после ручной сверки «Итого» трёх файлов (РБ 60 1 кв 2025, Счастливочная 62 3 кв
2024 xls, 58.03) — сверка описывается в отчёте задачи.
- [ ] 4.3 Линтер координат `tests/unit/test_no_coordinates.py` (AST по `src/parser/**` кроме `grid.py`, `readers/`) и тест
- [x] 4.3 Линтер координат `tests/unit/test_no_coordinates.py` (AST по `src/parser/**` кроме `grid.py`, `readers/`) и тест
покрытия словарей (каждая запись сработала хотя бы раз на фикстурах+синтетике; список несработавших — в сообщении).
## 5. Завершение