Andrey Epifancev 43ee753d74 refactor(parser): BookParser — оркестратор книги; OsvParser — конвейер листа ОСВ; заглушка форм до конвейера
Листы-продолжения форм (STATEMENT_CONTINUATION) идут в конвейер форм, а не в разбор ОСВ (R-5).

Refs #14

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 15:49:27 +04:00

186 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"] == "1.2"
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": "1.2",\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"