Refs #14 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
212 lines
8.3 KiB
Python
212 lines
8.3 KiB
Python
"""Сценарии спеки `statement-parsing` на синтетических файлах (Срез 2b, Task 13).
|
||
|
||
Ожидания выведены из сценариев спеки и из содержимого генератора, а не из текущего вывода парсера:
|
||
файл — вход, оракулом он не служит. Без каталога fixtures тесты пропускаются (маркер `fixtures`).
|
||
"""
|
||
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from typing import cast
|
||
|
||
import pytest
|
||
from result_contract import assert_valid_result
|
||
|
||
from src.contract import FileType, ResultStatus, StatementDocument
|
||
from src.contract.result import StatementLine
|
||
from src.parser import ParseOutcome, get_parser
|
||
|
||
pytestmark = pytest.mark.fixtures
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _require_synthetic(fixtures_dir: Path) -> None:
|
||
"""T13.1: fixtures есть, а `statements-xlsx/synthetic` нет/пуст — skip, а не падение."""
|
||
|
||
|
||
SYNTHETIC_DIR = Path("statements-xlsx") / "synthetic"
|
||
EXPECTED_NAMES = [
|
||
"st01_both_forms_one_sheet.xlsx",
|
||
"st02_csv_like.xlsx",
|
||
"st03_no_codes.xlsx",
|
||
"st04_mixed_book.xlsx",
|
||
"st05_continuation_only.xlsx",
|
||
]
|
||
|
||
ORG = 'ООО "Тестовая организация"'
|
||
D = Decimal
|
||
EXPECTED_DOCS_COUNT = 2
|
||
EXPECTED_STATEMENT_COUNT = 1
|
||
EXPECTED_LOCATORS_COUNT = 1
|
||
EXPECTED_LINES_COUNT = 0
|
||
|
||
|
||
def synthetic_path(fixtures_dir: Path, name: str) -> Path:
|
||
return fixtures_dir / SYNTHETIC_DIR / name
|
||
|
||
|
||
def parse_path(path: Path) -> ParseOutcome:
|
||
return get_parser().parse(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
|
||
|
||
|
||
def parse_named(fixtures_dir: Path, name: str) -> ParseOutcome:
|
||
path = synthetic_path(fixtures_dir, name)
|
||
if not path.is_file():
|
||
pytest.fail(f"нет файла {name}: запустите генератор (см. README fixtures)")
|
||
return parse_path(path)
|
||
|
||
|
||
def statement_documents(outcome: ParseOutcome) -> list[StatementDocument]:
|
||
"""Документы форм результата."""
|
||
assert outcome.error is None, outcome.error
|
||
return [doc for doc in outcome.documents if isinstance(doc, StatementDocument)]
|
||
|
||
|
||
def one_statement(outcome: ParseOutcome) -> StatementDocument:
|
||
(doc,) = statement_documents(outcome)
|
||
return doc
|
||
|
||
|
||
def warnings_of(outcome: ParseOutcome) -> list[str]:
|
||
assert outcome.metrics is not None
|
||
assert outcome.metrics.warnings is not None
|
||
return outcome.metrics.warnings
|
||
|
||
|
||
def by_code(doc: StatementDocument) -> dict[str, StatementLine]:
|
||
"""Строки по коду показателя."""
|
||
lines = {line.line_code: line for line in doc.lines}
|
||
return lines
|
||
|
||
|
||
def get_line_value(line: StatementLine, column_index: int = 0) -> D | None:
|
||
"""Получить значение из строки по индексу колонки."""
|
||
for value in line.values:
|
||
if value.column_index == column_index:
|
||
return value.amount
|
||
return None
|
||
|
||
|
||
# --- Состав набора и общие ожидания ---
|
||
|
||
|
||
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
|
||
"""В `fixtures/statements-xlsx/synthetic/` лежат файлы набора st01…st05."""
|
||
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("st*") if path.is_file())
|
||
assert present == EXPECTED_NAMES
|
||
|
||
|
||
def test_every_synthetic_file_is_listed(statement_synthetic_file: Path) -> None:
|
||
"""Каждый файл каталога — один из ожидаемых."""
|
||
assert statement_synthetic_file.name in EXPECTED_NAMES
|
||
|
||
|
||
# --- Сценарии спеки ---
|
||
|
||
|
||
def test_st01_both_forms_one_sheet(fixtures_dir: Path) -> None:
|
||
"""st01: ББ и ОФР на одном листе; два документа с одним `locator`."""
|
||
outcome = parse_named(fixtures_dir, "st01_both_forms_one_sheet.xlsx")
|
||
|
||
stmt_docs = statement_documents(outcome)
|
||
assert len(stmt_docs) == EXPECTED_DOCS_COUNT, (
|
||
f"ожидается {EXPECTED_DOCS_COUNT} документа, получено {len(stmt_docs)}"
|
||
)
|
||
|
||
# Оба документа имеют один locator
|
||
locators = {doc.locator for doc in stmt_docs}
|
||
assert len(locators) == EXPECTED_LOCATORS_COUNT, (
|
||
f"ожидается {EXPECTED_LOCATORS_COUNT} уникальный locator, получено {len(locators)}"
|
||
)
|
||
|
||
# Проверяем типы документов
|
||
doc_types = {doc.document_type for doc in stmt_docs}
|
||
assert "BALANCE" in doc_types and "INCOME" in doc_types, f"ожидаются типы BALANCE и INCOME, получены {doc_types}"
|
||
|
||
|
||
def test_st02_csv_like_format(fixtures_dir: Path) -> None:
|
||
"""st02: CSV-подобный лист; организация и ИНН найдены, units None с warning."""
|
||
outcome = parse_named(fixtures_dir, "st02_csv_like.xlsx")
|
||
|
||
doc = one_statement(outcome)
|
||
|
||
# Проверяем организацию
|
||
assert doc.header.organization_name == 'ООО "Синтетика"'
|
||
assert doc.header.inn == "7700000001"
|
||
|
||
# Проверяем units = None с warning
|
||
assert doc.header.units is None
|
||
warnings = warnings_of(outcome)
|
||
assert any("unit" in w.lower() or "измер" in w.lower() for w in warnings), (
|
||
f"ожидается warning о единицах, получены: {warnings}"
|
||
)
|
||
|
||
# Проверяем что документ содержит данные
|
||
assert len(doc.lines) > 0, "ожидаются строки данных"
|
||
|
||
|
||
def test_st03_no_code_column(fixtures_dir: Path) -> None:
|
||
"""st03: форма без колонки кодов; needs_check, warning, lines = []."""
|
||
outcome = parse_named(fixtures_dir, "st03_no_codes.xlsx")
|
||
|
||
doc = one_statement(outcome)
|
||
|
||
# Статус needs_check
|
||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||
|
||
# Нет найденных строк
|
||
assert len(doc.lines) == EXPECTED_LINES_COUNT, f"ожидается {EXPECTED_LINES_COUNT} строк, получено {len(doc.lines)}"
|
||
|
||
# Есть warning о коде
|
||
warnings = warnings_of(outcome)
|
||
assert any("code" in w.lower() or "код" in w.lower() for w in warnings), (
|
||
f"ожидается warning о коде, получены: {warnings}"
|
||
)
|
||
|
||
|
||
def test_st04_mixed_book_osv_and_statement(fixtures_dir: Path) -> None:
|
||
"""st04: книга с листом ОСВ и листом баланса; OsvDocument + StatementDocument, статус done."""
|
||
outcome = parse_named(fixtures_dir, "st04_mixed_book.xlsx")
|
||
|
||
stmt_docs = statement_documents(outcome)
|
||
assert len(stmt_docs) == EXPECTED_STATEMENT_COUNT, (
|
||
f"ожидается {EXPECTED_STATEMENT_COUNT} StatementDocument, получено {len(stmt_docs)}"
|
||
)
|
||
|
||
# Проверяем что есть также OsvDocument (проверяя общее количество документов)
|
||
assert len(outcome.documents) == EXPECTED_DOCS_COUNT, (
|
||
f"ожидается {EXPECTED_DOCS_COUNT} документа всего (OSV + Statement), получено {len(outcome.documents)}"
|
||
)
|
||
|
||
# Статус done
|
||
assert outcome.status is ResultStatus.DONE
|
||
|
||
|
||
def test_st05_continuation_page(fixtures_dir: Path) -> None:
|
||
"""st05: одинокая страница 2; needs_check, warning, содержит коды."""
|
||
outcome = parse_named(fixtures_dir, "st05_continuation_only.xlsx")
|
||
|
||
doc = one_statement(outcome)
|
||
|
||
# Статус needs_check
|
||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||
|
||
# Есть warning о continuation
|
||
warnings = warnings_of(outcome)
|
||
assert any(
|
||
"continuation" in w.lower() or "страниц" in w.lower() or "страница" in w.lower() or "page" in w.lower()
|
||
for w in warnings
|
||
), f"ожидается warning о continuation, получены: {warnings}"
|
||
|
||
# Проверяем что есть коды
|
||
codes = {line.line_code for line in doc.lines if line.line_code}
|
||
assert len(codes) > 0, "ожидаются коды, получены пустое множество"
|
||
|
||
|
||
# --- Валидация контракта ---
|
||
|
||
|
||
def test_all_synthetic_results_are_valid(statement_synthetic_file: Path) -> None:
|
||
"""Все результаты валидны по контракту (общая проверка для всех синтетических файлов)."""
|
||
outcome = parse_path(statement_synthetic_file)
|
||
assert_valid_result(outcome)
|