checks.py: тождество сальдо по каждой строке, суммы родителя = Σ детей того же data_type (CURRENCY/QUANTITY исключены; обороты — по колонкам, сальдо — по колонкам или по нетто при свёрнутом сальдо родителя), «Итого» = корневой счёт (R-13: нет корня — «Итого» пусто/0; R-20: нет «Итого» — false и предупреждение). osv.py: OsvParser — лимит размера, чтение по сигнатуре, листы по содержимому (формы ББ/ОФР и прочие — пропуск с предупреждением), карта исходов D-6, кооперативный дедлайн, metrics (rows без TOTAL, warnings с «sheet=<имя> hierarchy=<стратегия>» первой записью по листу). get_parser(settings, rules_dir=…) → OsvParser (словари грузятся при создании: битый словарь — падение старта); PARSE_MAX_BYTES / PARSE_TIMEOUT_SECONDS в Settings и .env.*; app.py передаёт настройки. test_null_parser.py → test_parser_factory.py. Тесты: юнит-карта статусов, «Испорченная сумма», «Пустая ОСВ», два листа ОСВ, новый синоним без релиза кода (R-8); property-тест на 66 реальных файлах (все done, < 5 с, схема v1.1). test_app и интеграционный round-trip ждут ошибку реального парсера вместо NOT_IMPLEMENTED. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
85 lines
3.6 KiB
Python
85 lines
3.6 KiB
Python
"""Фабрика парсера и заглушка (design D-3, D-7, D-8; spec «Парсер — заменяемый интерфейс», «Словари — данные»).
|
||
|
||
`get_parser()` по умолчанию отдаёт `OsvParser` со словарями `api/rules/` и лимитами из настроек; битый словарь —
|
||
падение при создании (старт сервиса), а не при первом файле. `NullParser` остаётся для тестов воркера:
|
||
`NOT_IMPLEMENTED` для любого типа файла, входные байты не читаются.
|
||
"""
|
||
|
||
import shutil
|
||
from collections.abc import Callable
|
||
from pathlib import Path
|
||
from typing import Any, get_args
|
||
|
||
import pytest
|
||
|
||
from src.contract import ErrorCode, FileType, MessageError, ResultStatus
|
||
from src.core.config import Settings
|
||
from src.parser import NullParser, ParseOutcome, Parser, get_parser
|
||
from src.parser.osv import OsvParser
|
||
from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE
|
||
|
||
DEFAULT_MAX_BYTES = 50 * 1024 * 1024
|
||
DEFAULT_TIMEOUT_SECONDS = 120
|
||
|
||
|
||
class UntouchableBytes(bytes):
|
||
"""Байты, любое обращение к содержимому которых — ошибка теста."""
|
||
|
||
def _fail(self, *_args: Any, **_kwargs: Any) -> Any:
|
||
raise AssertionError("NullParser прикоснулся к входным байтам")
|
||
|
||
__iter__ = __len__ = __getitem__ = __bytes__ = __contains__ = __eq__ = __hash__ = _fail # type: ignore[assignment]
|
||
|
||
|
||
@pytest.mark.parametrize("file_type", get_args(FileType))
|
||
def test_null_parser_returns_not_implemented_without_touching_input(file_type: FileType) -> None:
|
||
data = UntouchableBytes(b"content")
|
||
|
||
outcome = NullParser().parse(data, file_type)
|
||
|
||
assert outcome.status is ResultStatus.ERROR
|
||
assert outcome.error == MessageError(
|
||
code=ErrorCode.NOT_IMPLEMENTED,
|
||
message="разбор XLS/XLSX/XLSB реализуется в Срезе 2",
|
||
)
|
||
assert outcome.documents == ()
|
||
assert outcome.metrics is None
|
||
|
||
|
||
def test_get_parser_returns_osv_parser_with_default_limits() -> None:
|
||
parser = get_parser()
|
||
|
||
assert isinstance(parser, OsvParser)
|
||
assert isinstance(parser, Parser)
|
||
assert (parser.max_bytes, parser.timeout_seconds) == (DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT_SECONDS)
|
||
|
||
|
||
def test_get_parser_takes_limits_from_settings(minimal_settings: Callable[..., Settings]) -> None:
|
||
settings = minimal_settings("PARSE_MAX_BYTES=1024\nPARSE_TIMEOUT_SECONDS=7\n")
|
||
|
||
parser = get_parser(settings)
|
||
|
||
assert isinstance(parser, OsvParser)
|
||
assert (parser.max_bytes, parser.timeout_seconds) == (1024, 7) # noqa: PLR2004 - значения из окружения теста
|
||
|
||
|
||
def test_get_parser_fails_on_broken_dictionary(tmp_path: Path) -> None:
|
||
"""Spec «Словари — данные»: ошибка словаря — падение старта с указанием файла и записи."""
|
||
broken = tmp_path / "rules"
|
||
shutil.copytree(DEFAULT_RULES_DIR, broken)
|
||
headers = broken / HEADERS_FILE
|
||
headers.write_text(headers.read_text(encoding="utf-8").replace("total_row:", "total_rows:"), encoding="utf-8")
|
||
|
||
with pytest.raises(RuntimeError, match=r"osv_headers\.yaml.*total_row"):
|
||
get_parser(rules_dir=broken)
|
||
|
||
|
||
def test_outcome_error_requires_error_payload() -> None:
|
||
with pytest.raises(ValueError, match="error"):
|
||
ParseOutcome(status=ResultStatus.ERROR)
|
||
|
||
|
||
def test_outcome_success_requires_documents() -> None:
|
||
with pytest.raises(ValueError, match="documents"):
|
||
ParseOutcome(status=ResultStatus.DONE)
|