Срез 2a: парсер ОСВ из XLS/XLSX/XLSB — динамическое обнаружение структуры (§1), контракт v1.1, golden на 66 реальных файлах #6
15
Makefile
15
Makefile
@ -3,7 +3,7 @@ VENV := api/.venv
|
|||||||
BIN := $(CURDIR)/$(VENV)/bin
|
BIN := $(CURDIR)/$(VENV)/bin
|
||||||
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
|
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_* вне банка недоступны — пропускаем их
|
# Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их
|
||||||
venv:
|
venv:
|
||||||
@ -22,6 +22,19 @@ test:
|
|||||||
test-integration:
|
test-integration:
|
||||||
cd api && $(BIN)/pytest -m 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
|
check: lint test
|
||||||
|
|
||||||
# Сборка образа из корня репозитория
|
# Сборка образа из корня репозитория
|
||||||
|
|||||||
25
README.md
25
README.md
@ -83,12 +83,37 @@ openspec/ требования и дельта текущего ср
|
|||||||
| `make lint` | `ruff check .`, `ruff format --check .`, `mypy src` |
|
| `make lint` | `ruff check .`, `ruff format --check .`, `mypy src` |
|
||||||
| `make test` | `pytest -m "not integration"` — без Docker |
|
| `make test` | `pytest -m "not integration"` — без Docker |
|
||||||
| `make test-integration` | `pytest -m integration` — против NATS и MinIO из compose |
|
| `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` | `make lint` + `make test` (включая идентичность копии контракта с `../docs/contracts`) |
|
||||||
| `make check-full` | `make check` + `make test-integration` + `docker build`; запускать перед PR |
|
| `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 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` на реальные запросы других производителей).
|
Интеграционные тесты (`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
|
## Запуск локально против compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -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` — для всего прочего) |
|
| `osv_groups.yaml` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
|
||||||
| `periods.yaml` | текст периода -> `period_quarter` и `is_cumulative` (§1.5); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
|
| `periods.yaml` | текст периода -> `period_quarter` и `is_cumulative` (§1.5); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
|
||||||
| `data_types.yaml` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.»; коды валют ISO 4217; синонимы валюты (`руб.` -> `RUB`) |
|
| `data_types.yaml` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.»; коды валют ISO 4217; синонимы валюты (`руб.` -> `RUB`) |
|
||||||
@ -32,6 +32,19 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
|
|||||||
- Ошибка в словаре (нет обязательного ключа, неизвестный ключ, неверный регэксп или тип) — `RuntimeError` при
|
- Ошибка в словаре (нет обязательного ключа, неизвестный ключ, неверный регэксп или тип) — `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` не включён: он нужен, чтобы показать, что новый
|
Синоним «нач. остаток» / «начальный остаток» для группы `opening` не включён: он нужен, чтобы показать, что новый
|
||||||
|
|||||||
@ -38,18 +38,12 @@ debit:
|
|||||||
credit:
|
credit:
|
||||||
- '^(кредит|кт|к т)\b'
|
- '^(кредит|кт|к т)\b'
|
||||||
|
|
||||||
# Служебные колонки таблицы по тексту заголовка: ИНН, КПП, код, количество, валюта. Ключ — имя колонки в `extra`
|
# Служебные колонки таблицы по тексту заголовка. Код знает только колонку ИНН; прочие колонки таблицы (КПП, код,
|
||||||
|
# количество, валюта и т. п.) попадают в `rows[].extra` под текстом своего заголовка и словаря не требуют.
|
||||||
|
# (Записи kpp/code/quantity/currency удалены: код их не читал, тест покрытия словарей R-22 их не находил.)
|
||||||
service_columns:
|
service_columns:
|
||||||
inn:
|
inn:
|
||||||
- '\bинн\b'
|
- '\bинн\b'
|
||||||
kpp:
|
|
||||||
- '\bкпп\b'
|
|
||||||
code:
|
|
||||||
- '^код\b'
|
|
||||||
quantity:
|
|
||||||
- '^(количество|кол)\b'
|
|
||||||
currency:
|
|
||||||
- '^валюта\b'
|
|
||||||
|
|
||||||
# Итоговая строка таблицы (начало текста первой колонки)
|
# Итоговая строка таблицы (начало текста первой колонки)
|
||||||
total_row:
|
total_row:
|
||||||
|
|||||||
65
api/scripts/golden_update.py
Normal file
65
api/scripts/golden_update.py
Normal 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())
|
||||||
@ -5,7 +5,13 @@ from collections.abc import Callable
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
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
|
from src.core.config import Settings
|
||||||
|
|
||||||
@ -51,6 +57,15 @@ def minimal_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Callabl
|
|||||||
return build
|
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")
|
@pytest.fixture(scope="session")
|
||||||
def fixtures_dir() -> Path:
|
def fixtures_dir() -> Path:
|
||||||
"""Каталог `../fixtures`; тест пропускается с причиной, если каталога нет."""
|
"""Каталог `../fixtures`; тест пропускается с причиной, если каталога нет."""
|
||||||
@ -62,11 +77,14 @@ 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` (реальные ОСВ) и `synthetic_file` (синтетика); без каталога — один параметр со skip."""
|
||||||
sources = {"osv_file": osv_fixture_files, "synthetic_file": synthetic_files}
|
sources = {
|
||||||
for argname, listing in sources.items():
|
"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:
|
if argname not in metafunc.fixturenames:
|
||||||
continue
|
continue
|
||||||
reason = fixtures_skip_reason()
|
reason = skip_reason()
|
||||||
if reason is not None:
|
if reason is not None:
|
||||||
metafunc.parametrize(
|
metafunc.parametrize(
|
||||||
argname, [pytest.param(None, marks=pytest.mark.skip(reason=reason))], ids=["нет-фикстур"]
|
argname, [pytest.param(None, marks=pytest.mark.skip(reason=reason))], ids=["нет-фикстур"]
|
||||||
|
|||||||
63
api/tests/fixtures/test_golden.py
vendored
Normal file
63
api/tests/fixtures/test_golden.py
vendored
Normal 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) == []
|
||||||
4
api/tests/fixtures/test_header_real.py
vendored
4
api/tests/fixtures/test_header_real.py
vendored
@ -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_quarter is not None
|
||||||
assert doc.period_year is not None
|
assert doc.period_year is not None
|
||||||
assert doc.is_cumulative is not None
|
assert doc.is_cumulative is not None
|
||||||
assert doc.data_type == "BU"
|
assert doc.data_type is not None # T3.9: значение («BU» и др.) не фиксируем — файлы не спецификация
|
||||||
assert doc.currency == "RUB"
|
assert doc.currency is not None
|
||||||
assert doc.header_variant == variant == 1
|
assert doc.header_variant == variant == 1
|
||||||
assert doc.warnings == ()
|
assert doc.warnings == ()
|
||||||
assert groups[0].type == "ACCOUNT"
|
assert groups[0].type == "ACCOUNT"
|
||||||
|
|||||||
63
api/tests/fixtures/test_rules_coverage.py
vendored
Normal file
63
api/tests/fixtures/test_rules_coverage.py
vendored
Normal 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)
|
||||||
5
api/tests/fixtures/test_rules_real.py
vendored
5
api/tests/fixtures/test_rules_real.py
vendored
@ -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
|
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"]))
|
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"]))
|
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
|
||||||
|
|||||||
@ -24,6 +24,16 @@ def fixtures_skip_reason() -> str | None:
|
|||||||
return f"каталог фикстур не найден: {root} (задайте {FIXTURES_ENV}=<путь к репозиторию fixtures>)"
|
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]:
|
def osv_fixture_files(root: Path) -> list[Path]:
|
||||||
"""Все `*.xls/*.xlsx/*.xlsb` под `root/osv`, кроме каталога synthetic; отсортированы."""
|
"""Все `*.xls/*.xlsx/*.xlsb` под `root/osv`, кроме каталога synthetic; отсортированы."""
|
||||||
osv = root / "osv"
|
osv = root / "osv"
|
||||||
|
|||||||
147
api/tests/golden_helpers.py
Normal file
147
api/tests/golden_helpers.py
Normal 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
|
||||||
77
api/tests/rules_coverage.py
Normal file
77
api/tests/rules_coverage.py
Normal 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
|
||||||
|
]
|
||||||
@ -1,8 +1,9 @@
|
|||||||
"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`.
|
"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`.
|
||||||
|
|
||||||
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл (см. `osv/synthetic/README.md` в репозитории
|
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
|
||||||
fixtures). Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты —
|
ОСВ (R-22); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl +
|
||||||
метки времени книги и zip постоянны, порядок записи фиксирован.
|
`xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок
|
||||||
|
записи фиксирован.
|
||||||
|
|
||||||
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
||||||
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
|
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
|
||||||
@ -196,6 +197,10 @@ class OsvSpec:
|
|||||||
bold_accounts: bool = False
|
bold_accounts: bool = False
|
||||||
repeat_header_after: int | None = None # после стольких строк данных повторяется заголовок (печать по страницам)
|
repeat_header_after: int | None = None # после стольких строк данных повторяется заголовок (печать по страницам)
|
||||||
detail: str = "Контрагенты, Договоры"
|
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 = {
|
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:
|
def _header_rows(sheet: SheetSpec, spec: OsvSpec, top: int, label: str, sub_label: str) -> int:
|
||||||
"""Блок заголовка таблицы с `top`: строка групп и строка Дебет/Кредит; возвращает номер следующей строки."""
|
"""Блок заголовка таблицы с `top`: строка групп и строка Дебет/Кредит; возвращает номер следующей строки.
|
||||||
sheet.put(top, 1, label)
|
|
||||||
sheet.put(top + 1, 1, sub_label)
|
`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:
|
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:
|
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):
|
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)
|
sheet.merge(top, col, top, col + width - 1)
|
||||||
if width == PAIR_WIDTH:
|
if width == PAIR_WIDTH:
|
||||||
sheet.put(top + 1, col, DEBIT)
|
sheet.put(top + 1, col, DEBIT)
|
||||||
sheet.put(top + 1, col + 1, CREDIT)
|
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:
|
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(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account} за {spec.period}")
|
||||||
sheet.put(4, 1, spec.data_type_line)
|
sheet.put(4, 1, spec.data_type_line)
|
||||||
sheet.put(7, 1, "Параметры:")
|
sheet.put(7, 1, "Параметры:")
|
||||||
@ -248,10 +262,11 @@ def _header_v1(sheet: SheetSpec, spec: OsvSpec) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _header_v2(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(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account}")
|
||||||
sheet.put(3, 1, f"Период: {spec.period}")
|
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)
|
sheet.put(5, 1, spec.data_type_line)
|
||||||
top = 7
|
top = 7
|
||||||
nxt = _header_rows(sheet, spec, top, "Субконто", "")
|
nxt = _header_rows(sheet, spec, top, "Субконто", "")
|
||||||
@ -366,6 +381,16 @@ def statement_sheet(name: str = "Бухгалтерский баланс") -> Sh
|
|||||||
return sheet
|
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]:
|
def _s07() -> list[SheetSpec]:
|
||||||
|
# Смешанные знаки у детей (кредитовое у Альфы, дебетовое у Беты) намеренно проверяют правило R-21: свёрнутое сальдо
|
||||||
|
# родителя сверяется по нетто, а не по сумме модулей
|
||||||
first = leaf("ООО Альфа", pair(None, None), pair("100000.00", "350000.50"), inn="7701234567")
|
first = leaf("ООО Альфа", pair(None, None), pair("100000.00", "350000.50"), inn="7701234567")
|
||||||
second = leaf("ООО Бета", pair(None, None), pair("60000.00", "10000.00"), inn="7707654321")
|
second = leaf("ООО Бета", pair(None, None), pair("60000.00", "10000.00"), inn="7707654321")
|
||||||
third = leaf("ООО Гамма", pair(None, None), pair("20000.00", "20000.00"), inn="5001234567")
|
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())]
|
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),
|
"s11_header_repeat.xlsx": (_s11, to_xlsx),
|
||||||
"s12_statement_and_osv.xlsx": (_s12, to_xlsx),
|
"s12_statement_and_osv.xlsx": (_s12, to_xlsx),
|
||||||
"s13_no_outline_indent.xlsb": (_s13, to_xlsb),
|
"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)
|
FILE_NAMES = tuple(FILES)
|
||||||
|
|
||||||
@ -587,7 +669,7 @@ def generate_all(directory: Path) -> list[Path]:
|
|||||||
def main(argv: Sequence[str] | None = None) -> int:
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="python -m tests.synthetic.generate",
|
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")
|
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|||||||
@ -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
|
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)]
|
names = [path.name for path in generate_all(tmp_path)]
|
||||||
|
|
||||||
assert names == list(FILE_NAMES)
|
assert names == list(FILE_NAMES)
|
||||||
|
|||||||
@ -19,6 +19,12 @@ from src.parser import ParseOutcome, get_parser
|
|||||||
|
|
||||||
pytestmark = pytest.mark.fixtures
|
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"
|
SYNTHETIC_DIR = Path("osv") / "synthetic"
|
||||||
EXPECTED_NAMES = [
|
EXPECTED_NAMES = [
|
||||||
"s01_variant2_header.xlsx",
|
"s01_variant2_header.xlsx",
|
||||||
@ -34,8 +40,13 @@ EXPECTED_NAMES = [
|
|||||||
"s11_header_repeat.xlsx",
|
"s11_header_repeat.xlsx",
|
||||||
"s12_statement_and_osv.xlsx",
|
"s12_statement_and_osv.xlsx",
|
||||||
"s13_no_outline_indent.xlsb",
|
"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
|
VARIANT_2 = 2
|
||||||
XLSB_WARNING = "xlsb: outline level недоступен"
|
XLSB_WARNING = "xlsb: outline level недоступен"
|
||||||
|
|
||||||
@ -57,6 +68,9 @@ class Expect:
|
|||||||
documents: int = 1
|
documents: int = 1
|
||||||
variant: int = 1
|
variant: int = 1
|
||||||
data_type: str = "BU"
|
data_type: str = "BU"
|
||||||
|
organization: str = ORG
|
||||||
|
# Вариант шапки по документам (если у листов файла он разный); пусто — `variant` у всех
|
||||||
|
variants: tuple[int, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
EXPECTED: dict[str, Expect] = {
|
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"),
|
"s12_statement_and_osv.xlsx": Expect("outline", 2025, "Q3", True, data_type="NU"),
|
||||||
"s13_no_outline_indent.xlsb": Expect("indent", 2025, "Q2", False),
|
"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:
|
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())
|
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]
|
assert present == [name for name in EXPECTED_NAMES if name in present]
|
||||||
missing = [name for name in EXPECTED_NAMES if name not 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:
|
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
|
assert len(docs) == expect.documents
|
||||||
hierarchy_lines = [w for w in warnings_of(outcome) if f"hierarchy={expect.hierarchy}" in w]
|
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)
|
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:
|
for doc in docs:
|
||||||
assert doc.header.header_variant == expect.variant
|
|
||||||
assert doc.header.period_year == expect.year
|
assert doc.header.period_year == expect.year
|
||||||
assert doc.header.period_quarter == expect.quarter
|
assert doc.header.period_quarter == expect.quarter
|
||||||
assert doc.header.is_cumulative is expect.cumulative
|
assert doc.header.is_cumulative is expect.cumulative
|
||||||
assert doc.header.data_type == expect.data_type
|
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"
|
assert doc.rows[-1].group_type == "TOTAL"
|
||||||
if expect.status is ResultStatus.DONE:
|
if expect.status is ResultStatus.DONE:
|
||||||
assert doc.checks is not None
|
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:
|
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")
|
outcome = parse_named(fixtures_dir, "s07_no_opening_collapsed_closing.xlsx")
|
||||||
doc = one_document(outcome)
|
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 xlsb.metrics is not None
|
||||||
assert xlsx.metrics is not None
|
assert xlsx.metrics is not None
|
||||||
assert xlsb.metrics.rows == xlsx.metrics.rows
|
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
|
||||||
|
|||||||
@ -27,11 +27,14 @@ EXCLUDED_DIRS = frozenset({"readers"})
|
|||||||
|
|
||||||
# (а) адреса и буквы колонок: «E9», «A», «AB», «XFD1»
|
# (а) адреса и буквы колонок: «E9», «A», «AB», «XFD1»
|
||||||
ADDRESS_LITERAL = re.compile(r"^[A-Z]{1,3}\d{0,7}$")
|
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): сообщения вроде «лист ОСВ без заголовка» разрешены
|
# (б) имя листа по умолчанию целиком (R-4): сообщения вроде «лист ОСВ без заголовка» разрешены
|
||||||
DEFAULT_SHEET_NAME = re.compile(r"(?i)^(лист|sheet)[\s_]*\d*$")
|
DEFAULT_SHEET_NAME = re.compile(r"(?i)^(лист|sheet)[\s_]*\d*$")
|
||||||
# (в) сравнение с номером строки/колонки
|
# (в) сравнение с номером строки/колонки
|
||||||
COORDINATE_NAMES = frozenset({"row", "row_number", "r", "col", "column"})
|
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)
|
@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)
|
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]:
|
def _violations_in_tree(path: Path, tree: ast.AST, allowed_strings: frozenset[str]) -> Iterator[Violation]:
|
||||||
exempt = _exempt_constants(tree)
|
exempt = _exempt_constants(tree)
|
||||||
for node in ast.walk(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
|
for left, right in pairs
|
||||||
):
|
):
|
||||||
yield Violation(path, node.lineno, "сравнение номера строки/колонки с числовой константой")
|
yield Violation(path, node.lineno, "сравнение номера строки/колонки с числовой константой")
|
||||||
|
elif isinstance(node, ast.Call) and _is_constant_cell_call(node):
|
||||||
|
yield Violation(path, node.lineno, "обращение к ячейке константами (строка, колонка)")
|
||||||
elif (
|
elif (
|
||||||
isinstance(node, ast.Subscript)
|
isinstance(node, ast.Subscript)
|
||||||
and not _is_literal_subscript(node) # Literal[1, 2] — значения типа контракта, не индекс ячейки
|
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
|
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:
|
def test_column_letters_are_caught(tmp_path: Path, literal: str) -> None:
|
||||||
root = _tree(tmp_path, {"detect.py": f'letter = "{literal}"\n'})
|
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"})
|
root = _tree(tmp_path, {"detect.py": "from typing import Literal\nVariant = Literal[1, 2]\n"})
|
||||||
|
|
||||||
assert find_violations(root) == []
|
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]
|
||||||
|
|||||||
@ -320,16 +320,17 @@ def test_account_marker_ends_right_before_the_account_code(rules: Rules) -> None
|
|||||||
[
|
[
|
||||||
("inn", "ИНН"),
|
("inn", "ИНН"),
|
||||||
("inn", "Контрагенты, ИНН"),
|
("inn", "Контрагенты, ИНН"),
|
||||||
("kpp", "КПП"),
|
|
||||||
("code", "Код"),
|
|
||||||
("quantity", "Количество"),
|
|
||||||
("currency", "Валюта"),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_service_columns(rules: Rules, kind: str, text: str) -> None:
|
def test_service_columns(rules: Rules, kind: str, text: str) -> None:
|
||||||
assert any(p.search(text) for p in rules.headers.service_columns[kind])
|
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", ["Итого", "ИТОГО", "Итого по счету"])
|
@pytest.mark.parametrize("text", ["Итого", "ИТОГО", "Итого по счету"])
|
||||||
def test_total_row(rules: Rules, text: str) -> None:
|
def test_total_row(rules: Rules, text: str) -> None:
|
||||||
assert any(p.search(text) for p in rules.headers.total_row)
|
assert any(p.search(text) for p in rules.headers.total_row)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from fixtures_helpers import (
|
|||||||
osv_fixture_files,
|
osv_fixture_files,
|
||||||
resolve_fixtures_dir,
|
resolve_fixtures_dir,
|
||||||
synthetic_files,
|
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()
|
(tmp_path / "osv").mkdir()
|
||||||
monkeypatch.setenv(FIXTURES_ENV, str(tmp_path))
|
monkeypatch.setenv(FIXTURES_ENV, str(tmp_path))
|
||||||
assert fixtures_skip_reason() is None
|
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
|
||||||
|
|||||||
77
api/tests/unit/test_golden_helpers.py
Normal file
77
api/tests/unit/test_golden_helpers.py
Normal 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") == ""
|
||||||
108
api/tests/unit/test_golden_sync.py
Normal file
108
api/tests/unit/test_golden_sync.py
Normal 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
|
||||||
75
api/tests/unit/test_rules_coverage_helpers.py
Normal file
75
api/tests/unit/test_rules_coverage_helpers.py
Normal 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]
|
||||||
@ -422,9 +422,9 @@ def render(parser, file: Path) -> dict # как CLI: RecognitionResult
|
|||||||
# test_rules_coverage.py: прогоняет парсер по всем фикстурам+синтетике с Rules, обёрнутыми в счётчик срабатываний Pattern; каждая запись каждого словаря ≥ 1; сообщение — список несработавших
|
# test_rules_coverage.py: прогоняет парсер по всем фикстурам+синтетике с Rules, обёрнутыми в счётчик срабатываний Pattern; каждая запись каждого словаря ≥ 1; сообщение — список несработавших
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 1:** тесты: `test_golden.py` (параметры `osv_file` и `synthetic_file`): снимок существует (иначе падение «нет снимка: make golden-update») и равен нормализованному результату (diff в сообщении); линтер — проверить, что намеренно подложенный файл с `"E9"` ловится (тест линтера на временном дереве); покрытие словарей.
|
- [x] **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 таблицы).
|
- [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 таблицы).
|
||||||
- [ ] **Step 3:** коммит в fixtures: `golden/expected_osv_parse: снимки парсера для 66 ОСВ и синтетики`; коммит здесь: `test: golden-снимки, линтер координат, покрытие словарей; make golden-update`.
|
- [x] **Step 3:** коммит в fixtures: `golden/expected_osv_parse: снимки парсера для 66 ОСВ и синтетики`; коммит здесь: `test: golden-снимки, линтер координат, покрытие словарей; make golden-update`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -58,11 +58,11 @@
|
|||||||
шапки; без outline (отступы); без outline и отступов; Вал./Кол.; текстовые числа и скобки; два листа ОСВ + посторонний;
|
шапки; без outline (отступы); без outline и отступов; Вал./Кол.; текстовые числа и скобки; два листа ОСВ + посторонний;
|
||||||
без «начала» + свёрнутое сальдо; КПП-колонка; название субсчёта в строке; испорченная сумма; плюс один xlsb, сконвертированный
|
без «начала» + свёрнутое сальдо; КПП-колонка; название субсчёта в строке; испорченная сумма; плюс один xlsb, сконвертированный
|
||||||
вручную из синтетического xlsx и закоммиченный с пометкой в README fixtures. Тесты сценариев спеки на этих файлах.
|
вручную из синтетического 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/osv/**` включая синтетику, `make golden-update` (перезапись + diff), первичная запись снимков в
|
||||||
`../fixtures/golden/expected_osv_parse/` после ручной сверки «Итого» трёх файлов (РБ 60 1 кв 2025, Счастливочная 62 3 кв
|
`../fixtures/golden/expected_osv_parse/` после ручной сверки «Итого» трёх файлов (РБ 60 1 кв 2025, Счастливочная 62 3 кв
|
||||||
2024 xls, 58.03) — сверка описывается в отчёте задачи.
|
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. Завершение
|
## 5. Завершение
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user