"""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"