Срез 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
|
||||
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
|
||||
|
||||
# Сборка образа из корня репозитория
|
||||
|
||||
25
README.md
25
README.md
@ -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
|
||||
|
||||
@ -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` не включён: он нужен, чтобы показать, что новый
|
||||
|
||||
@ -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:
|
||||
|
||||
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
|
||||
|
||||
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
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_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"
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@ -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
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/`.
|
||||
|
||||
Файлы 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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
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; сообщение — список несработавших
|
||||
```
|
||||
|
||||
- [ ] **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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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. Завершение
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user