- копия схем и примеров контракта v2.0 (запрос остаётся v1.0), модели result/common/message/request; ResultWarning/FileWarning, статус и предупреждения у документов, строки forms v2.0, service.statements_version - statements/pipeline.py: кандидаты адаптера -> normalize_candidate -> to_contract / warning_to_contract - BookParser: статус книги через envelope_status, SHEET_OTHER / FILE_TYPE_MISMATCH / XLS_NO_CODEPAGE как предупреждения файла, OSV_* и OSV_XLSB_NO_OUTLINE в документах ОСВ, тексты иерархии по спеке - воркер, app и CLI получают statements_version из require_statements_version() - старые модели документа формы вынесены в statements/legacy_models.py до удаления старого конвейера (задача 9) - OpenSpec: MODIFIED «Парсер — заменяемый интерфейс» в дельте tabular-recognition-service Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
186 lines
6.7 KiB
Python
186 lines
6.7 KiB
Python
"""CLI разбора файла `python -m src.parser.cli` (Task 10; spec tabular-recognition-service, README «Запуск на файле»).
|
||
|
||
Коды возврата: 0 — `done`, 1 — `needs_check`, 2 — `error`, 3 — файл не найден / не читается / ошибка использования.
|
||
Синтетические книги собираются openpyxl в памяти (`test_book_parser.xlsx`) и пишутся в `tmp_path`; реальный файл —
|
||
запуск как модуля через subprocess (маркер `fixtures`).
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from result_contract import schema_errors
|
||
from test_book_parser import mini_osv, xlsx
|
||
|
||
from src.contract import RecognitionResult
|
||
from src.parser import cli
|
||
|
||
API_DIR = Path(__file__).resolve().parents[3]
|
||
REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
|
||
SPOILED = (60, None, 20, 10, 71, None)
|
||
EXIT_DONE, EXIT_NEEDS_CHECK, EXIT_ERROR, EXIT_USAGE = 0, 1, 2, 3
|
||
|
||
|
||
def _write(tmp_path: Path, name: str, data: bytes) -> Path:
|
||
path = tmp_path / name
|
||
path.write_bytes(data)
|
||
return path
|
||
|
||
|
||
def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> tuple[int, str, str]:
|
||
code = cli.main(list(argv))
|
||
captured = capsys.readouterr()
|
||
return code, captured.out, captured.err
|
||
|
||
|
||
def test_done_book_returns_0_and_prints_result_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
|
||
|
||
code, out, _err = _run(capsys, str(path))
|
||
|
||
assert code == EXIT_DONE
|
||
payload: dict[str, Any] = json.loads(out)
|
||
assert payload["status"] == "done"
|
||
assert payload["schema_version"] == "2.0"
|
||
assert schema_errors(payload) == []
|
||
assert RecognitionResult.model_validate(payload).to_wire() == payload
|
||
assert payload["documents"][0]["header"]["account_code_raw"] == "60"
|
||
assert "ОСВ 60" in out # не ASCII-экранирование
|
||
assert out.endswith("\n")
|
||
|
||
|
||
def test_output_is_indented_and_keeps_model_field_order(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
|
||
|
||
_code, out, _err = _run(capsys, str(path))
|
||
|
||
assert out.startswith('{\n "schema_version": "2.0",\n')
|
||
keys = list(json.loads(out))
|
||
assert keys != sorted(keys) # sort_keys не используется: порядок полей модели значим для golden
|
||
|
||
|
||
def test_money_is_printed_as_strings(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
|
||
|
||
_code, out, _err = _run(capsys, str(path))
|
||
|
||
row = json.loads(out)["documents"][0]["rows"][1]
|
||
assert row["opening_debit"] == "60.00"
|
||
|
||
|
||
def test_needs_check_book_returns_1(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||
path = _write(tmp_path, "spoiled.xlsx", xlsx({"ОСВ 60": mini_osv(SPOILED)}))
|
||
|
||
code, out, _err = _run(capsys, str(path))
|
||
|
||
assert code == EXIT_NEEDS_CHECK
|
||
payload = json.loads(out)
|
||
assert payload["status"] == "needs_check"
|
||
assert schema_errors(payload) == []
|
||
|
||
|
||
def test_error_outcome_returns_2_with_error_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||
path = _write(tmp_path, "broken.xlsx", b"PK\x03\x04 broken zip")
|
||
|
||
code, out, err = _run(capsys, str(path))
|
||
|
||
assert code == EXIT_ERROR
|
||
payload = json.loads(out)
|
||
assert payload["status"] == "error"
|
||
assert payload["error"]["code"] == "UNREADABLE"
|
||
assert payload["error"]["message"]
|
||
assert "UNREADABLE" in err
|
||
|
||
|
||
def test_missing_file_returns_3(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||
code, out, err = _run(capsys, str(tmp_path / "nope.xlsx"))
|
||
|
||
assert code == EXIT_USAGE
|
||
assert out == ""
|
||
assert "nope.xlsx" in err
|
||
|
||
|
||
def test_directory_instead_of_file_returns_3(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||
code, out, _err = _run(capsys, str(tmp_path))
|
||
|
||
assert code == EXIT_USAGE
|
||
assert out == ""
|
||
|
||
|
||
def test_unknown_extension_without_file_type_returns_3(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||
path = _write(tmp_path, "osv.dat", xlsx({"ОСВ 60": mini_osv()}))
|
||
|
||
code, out, err = _run(capsys, str(path))
|
||
|
||
assert code == EXIT_USAGE
|
||
assert out == ""
|
||
assert "--file-type" in err
|
||
|
||
|
||
def test_explicit_file_type_overrides_extension(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||
path = _write(tmp_path, "osv.dat", xlsx({"ОСВ 60": mini_osv()}))
|
||
|
||
code, out, _err = _run(capsys, str(path), "--file-type", "XLSX")
|
||
|
||
assert code == EXIT_DONE
|
||
assert json.loads(out)["status"] == "done"
|
||
|
||
|
||
def test_extension_is_case_insensitive(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||
path = _write(tmp_path, "OSV.XLSX", xlsx({"ОСВ 60": mini_osv()}))
|
||
|
||
code, _out, _err = _run(capsys, str(path))
|
||
|
||
assert code == EXIT_DONE
|
||
|
||
|
||
def test_invalid_file_type_value_returns_3_not_argparse_2(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
|
||
|
||
code, out, err = _run(capsys, str(path), "--file-type", "PDF")
|
||
|
||
assert code == EXIT_USAGE
|
||
assert out == ""
|
||
assert "PDF" in err
|
||
|
||
|
||
def test_no_arguments_returns_3(capsys: pytest.CaptureFixture[str]) -> None:
|
||
code, out, _err = _run(capsys)
|
||
|
||
assert code == EXIT_USAGE
|
||
assert out == ""
|
||
|
||
|
||
def test_output_is_deterministic_except_duration(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
|
||
|
||
first = json.loads(_run(capsys, str(path))[1])
|
||
second = json.loads(_run(capsys, str(path))[1])
|
||
|
||
for payload in (first, second):
|
||
payload["metrics"].pop("duration_ms")
|
||
assert first == second
|
||
|
||
|
||
@pytest.mark.fixtures
|
||
def test_module_run_on_real_file_prints_valid_json(fixtures_dir: Path) -> None:
|
||
"""`python -m src.parser.cli <файл>` из `api/`: stdout — JSON результата по схеме, код возврата 0."""
|
||
completed = subprocess.run(
|
||
[sys.executable, "-m", "src.parser.cli", str(fixtures_dir / REAL_OSV_66)],
|
||
cwd=API_DIR,
|
||
env={**os.environ, "ENV_FILE": ".env.test"}, # импорт пакета `src` читает настройки сервиса
|
||
capture_output=True,
|
||
check=False,
|
||
timeout=120,
|
||
)
|
||
|
||
assert completed.returncode == EXIT_DONE, completed.stderr.decode("utf-8", "replace")
|
||
payload = json.loads(completed.stdout.decode("utf-8"))
|
||
assert payload["status"] == "done"
|
||
assert schema_errors(payload) == []
|
||
assert payload["documents"][0]["header"]["account_code_raw"] == "66"
|