Срез 0 [4.3]: эмулятор ИС Сканирование (src/scan_emulator), unit-тесты, отметка задачи

process_once(root, fixtures, delay): новые <uuid>_<имя> -> ответ из фикстуры (точное имя, затем префикс,
file_uid подставляется) либо INVALID_DOCUMENT, атомарная запись ответа, затем переименование в .done.
Цикл python -m src.scan_emulator с корректной остановкой по SIGINT/SIGTERM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 03:10:36 +04:00
parent 97ce49eed2
commit b489f6e0b3
6 changed files with 662 additions and 3 deletions

View File

@ -45,6 +45,6 @@ check-full: check test-integration
up:
docker compose -f $(COMPOSE_FILE) --project-name $(COMPOSE_PROJECT) up -d --wait postgres nats minio
# Эмулятор ИС Сканирование: модуль появится в задаче 9
# Эмулятор ИС Сканирование (параметры — SCAN_HOTFOLDER_PATH, SCAN_FIXTURES_DIR, SCAN_EMULATOR_DELAY из api/.env)
emulator:
cd api && $(BIN)/python -m src.scan_emulator

View File

@ -0,0 +1,220 @@
"""Эмулятор ИС Сканирование (Content AI) для разработки и тестов (D-6, R-11, spec scan-emulator).
Воспроизводит протокол горячих папок: для нового файла `<uuid>_<name>.<ext>` во входящем каталоге через задержку кладёт
`<output>/<uuid>/<uuid>.json` с эталонным ответом из `SCAN_FIXTURES_DIR` и переименовывает входящий файл в `….done`.
Эмулятор читает собственные переменные окружения (`EmulatorSettings`), а не настройки шлюза: в его контейнере нет
переменных MinIO/NATS. С каталогами работает через `LocalHotFolder`, тем самым тестируя и его.
"""
import json
import math
import os
import re
import threading
import time
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from dotenv import dotenv_values
from loguru import logger
from src.gateway.hotfolder import LocalHotFolder
__all__ = ["EmulatorSettings", "process_once", "run"]
# `<uuid>_<name>`: канонический UUID (36 символов) и непустое имя после `_`
_INBOX_NAME = re.compile(
r"^(?P<uuid>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})_(?P<name>.+)$"
)
_DONE_SUFFIX = ".done"
_ERROR_CODE = "INVALID_DOCUMENT"
def _read_float(env: Mapping[str, str], name: str, default: float, *, positive: bool) -> float:
raw = env.get(name)
if raw is None or raw.strip() == "":
return default
try:
value = float(raw)
except ValueError:
raise ValueError(f"{name}: ожидается число, получено {raw!r}") from None
if not math.isfinite(value) or value < 0 or (positive and value == 0):
raise ValueError(
f"{name}: ожидается {'положительное' if positive else 'неотрицательное'} число, получено {raw!r}"
)
return value
@dataclass(frozen=True)
class EmulatorSettings:
"""Настройки эмулятора из переменных окружения (имена совпадают с `.env.example`)."""
hotfolder_path: Path
input_dir: str = "Input"
output_dir: str = "Output"
fixtures_dir: Path = Path("../../fixtures/ocr")
delay: float = 2.0
poll_interval: float = 1.0
@classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> "EmulatorSettings":
"""Разбор окружения. Без аргумента: `os.environ` поверх файла `ENV_FILE` (по умолчанию `.env`, если он есть).
`SCAN_HOTFOLDER_PATH` обязателен (`ValueError`); `SCAN_INPUT_DIR`=Input, `SCAN_OUTPUT_DIR`=Output,
`SCAN_FIXTURES_DIR`=../../fixtures/ocr, `SCAN_EMULATOR_DELAY`=2 (с, >= 0),
`SCAN_EMULATOR_POLL_SECONDS`=1 (с, > 0).
"""
if environ is None:
env_file = os.environ.get("ENV_FILE", ".env")
file_values = {k: v for k, v in dotenv_values(env_file).items() if v is not None}
env: Mapping[str, str] = {**file_values, **os.environ}
else:
env = environ
path = env.get("SCAN_HOTFOLDER_PATH", "").strip()
if not path:
raise ValueError("SCAN_HOTFOLDER_PATH не задан: укажите корень горячей папки (каталог с Input/ и Output/)")
return cls(
hotfolder_path=Path(path),
input_dir=env.get("SCAN_INPUT_DIR") or "Input",
output_dir=env.get("SCAN_OUTPUT_DIR") or "Output",
fixtures_dir=Path(env.get("SCAN_FIXTURES_DIR") or "../../fixtures/ocr"),
delay=_read_float(env, "SCAN_EMULATOR_DELAY", 2.0, positive=False),
poll_interval=_read_float(env, "SCAN_EMULATOR_POLL_SECONDS", 1.0, positive=True),
)
def _error_answer(job_id: str, message: str) -> dict[str, Any]:
return {"file_uid": job_id, "error": {"code": _ERROR_CODE, "message": message}, "data_ocr": []}
def _find_fixture(fixtures: Path, stem: str) -> Path | None:
"""`<stem>.json`, затем `<stem>*.json` (первый по алфавиту). Имя сравнивается буквально, без glob."""
try:
names = sorted(p.name for p in fixtures.iterdir() if p.name.lower().endswith(".json") and p.is_file())
except OSError:
return None
if f"{stem}.json" in names:
return fixtures / f"{stem}.json"
for name in names:
if name.startswith(stem):
return fixtures / name
return None
def _build_answer(job_id: str, name: str, fixtures: Path) -> dict[str, Any]:
"""Ответ ИС на файл `name` (без префикса uuid): фикстура с подставленным `file_uid` либо ошибка Content AI."""
fixture = _find_fixture(fixtures, Path(name).stem)
if fixture is None:
return _error_answer(job_id, f"{name}: нет эталонного ответа в фикстурах")
try:
data = json.loads(fixture.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("корень не объект JSON")
except (OSError, ValueError) as exc: # json.JSONDecodeError и UnicodeDecodeError — подклассы ValueError
logger.error("Эмулятор: фикстура {} повреждена ({}); для {} отдаём {}", fixture, exc, name, _ERROR_CODE)
return _error_answer(job_id, f"{name}: эталонный ответ {fixture.name} повреждён и не разобран")
return {"file_uid": job_id, **{key: value for key, value in data.items() if key != "file_uid"}}
def _write_answer(hotfolder: LocalHotFolder, job_id: str, answer: dict[str, Any]) -> None:
"""Атомарная запись ответа: `.part` в том же каталоге, затем `os.replace`."""
target = hotfolder.answer_path(job_id)
target.parent.mkdir(parents=True, exist_ok=True)
part = target.parent / f".{target.name}.part"
try:
part.write_text(json.dumps(answer, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(part, target)
except BaseException:
part.unlink(missing_ok=True)
raise
def process_once(
root: Path | str,
fixtures: Path | str,
delay: float,
*,
input_dir: str = "Input",
output_dir: str = "Output",
) -> list[str]:
"""Один проход по входящему каталогу; возвращает uuid обработанных файлов.
Берёт новые `<uuid>_<name>.<ext>` (не dot-файлы, не `*.done`, не каталоги), при наличии работы один раз за проход
ждёт `delay` секунд, для каждого файла пишет `<output>/<uuid>/<uuid>.json` и только затем переименовывает входящий
файл в `….done`. Имена не по шаблону `<uuid>_` игнорируются (одно предупреждение за проход). Нет входящего
каталога — пустой результат.
"""
hotfolder = LocalHotFolder(root, input_dir, output_dir)
fixtures_dir = Path(fixtures)
try:
entries = sorted(hotfolder.input_dir.iterdir(), key=lambda p: p.name)
except FileNotFoundError:
return []
pending: list[tuple[Path, str, str]] = []
foreign: list[str] = []
for entry in entries:
if entry.name.startswith(".") or entry.name.endswith(_DONE_SUFFIX) or not entry.is_file():
continue
match = _INBOX_NAME.match(entry.name)
if match is None:
foreign.append(entry.name)
continue
pending.append((entry, match["uuid"], match["name"]))
if foreign:
logger.warning("Эмулятор: файлы не по шаблону <uuid>_<имя> пропущены: {}", ", ".join(foreign))
if not pending:
return []
if delay > 0:
time.sleep(delay)
processed: list[str] = []
for entry, job_id, name in pending:
try:
_write_answer(hotfolder, job_id, _build_answer(job_id, name, fixtures_dir))
os.replace(entry, entry.with_name(entry.name + _DONE_SUFFIX))
except FileNotFoundError:
logger.warning("Эмулятор: файл {} исчез во время обработки", entry.name)
continue
processed.append(job_id)
logger.info("Эмулятор: {} -> {}", entry.name, hotfolder.answer_path(job_id))
return processed
def _count_fixtures(fixtures: Path) -> int:
return sum(1 for p in fixtures.iterdir() if p.name.lower().endswith(".json") and p.is_file())
def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: int | None = None) -> None:
"""Цикл эмулятора: `process_once` каждые `poll_interval` секунд, пока не взведён `stop` (или не выполнено
`max_iterations` проходов — для тестов). Сбой прохода логируется и не останавливает цикл."""
hotfolder = LocalHotFolder(settings.hotfolder_path, settings.input_dir, settings.output_dir)
logger.info(
"Эмулятор ИС Сканирование: корень {}, входящий каталог {}, исходящий каталог {}, задержка {} с, опрос {} с",
hotfolder.root,
hotfolder.input_dir,
hotfolder.output_dir,
settings.delay,
settings.poll_interval,
)
if settings.fixtures_dir.is_dir():
logger.info("Фикстуры: {}, фикстур: {}", settings.fixtures_dir, _count_fixtures(settings.fixtures_dir))
else:
logger.warning(
"Каталог фикстур {} не найден: на все файлы будет отвечать {}", settings.fixtures_dir, _ERROR_CODE
)
iterations = 0
while not stop.is_set() and (max_iterations is None or iterations < max_iterations):
try:
process_once(
settings.hotfolder_path,
settings.fixtures_dir,
settings.delay,
input_dir=settings.input_dir,
output_dir=settings.output_dir,
)
except Exception: # noqa: BLE001 — эмулятор живёт, пока его не остановят
logger.exception("Эмулятор: сбой прохода по входящему каталогу")
iterations += 1
stop.wait(settings.poll_interval)
logger.info("Эмулятор остановлен")

View File

@ -0,0 +1,46 @@
"""Точка входа эмулятора: `python -m src.scan_emulator` (make emulator)."""
import signal
import sys
import threading
from types import FrameType
from loguru import logger
from src.scan_emulator import EmulatorSettings, run
def main(stop: threading.Event | None = None) -> int:
"""Запуск цикла эмулятора; код возврата процесса. SIGINT/SIGTERM — штатная остановка (0).
`stop` — для тестов: событие остановки вместо сигналов. Нет `SCAN_HOTFOLDER_PATH` или корня горячей папки — ошибка
и ненулевой код (эмулятор не создаёт корень сам: его создаёт и монтирует окружение).
"""
try:
settings = EmulatorSettings.from_env()
except ValueError as exc:
logger.error("Эмулятор не запущен: {}", exc)
return 2
if not settings.hotfolder_path.is_dir():
logger.error(
"Эмулятор не запущен: корень горячей папки {} не существует или не каталог", settings.hotfolder_path
)
return 2
event = stop if stop is not None else threading.Event()
def _on_signal(signum: int, _frame: FrameType | None) -> None:
logger.info("Получен сигнал {}, останавливаюсь", signal.Signals(signum).name)
event.set()
previous = {sig: signal.signal(sig, _on_signal) for sig in (signal.SIGINT, signal.SIGTERM)}
try:
run(settings, event)
finally:
for sig, handler in previous.items():
signal.signal(sig, handler)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,393 @@
"""Эмулятор ИС Сканирование (spec scan-emulator, D-6, R-11, slice-0-skeleton).
Сценарии spec «Точное имя», «Префикс», «Нет пары» — на копиях реальных `../fixtures/ocr/*.json` (маркер `fixtures`);
остальное — на синтетических фикстурах в `tmp_path`, без внешних репозиториев и без Docker.
"""
import json
import shutil
import threading
import uuid
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from loguru import logger
from src.gateway.hotfolder import LocalHotFolder, inbox_name
from src.scan_emulator import EmulatorSettings, process_once, run
from src.scan_emulator.__main__ import main
API_DIR = Path(__file__).resolve().parents[2]
# `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как у теста идентичности контракта
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
def _uid() -> str:
return str(uuid.uuid4())
def _hotfolder(tmp_path: Path) -> LocalHotFolder:
root = tmp_path / "hotfolder"
root.mkdir()
return LocalHotFolder(root)
def _fixtures_dir(tmp_path: Path, files: dict[str, Any] | None = None) -> Path:
path = tmp_path / "fixtures"
path.mkdir(exist_ok=True)
for name, content in (files or {}).items():
text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
(path / name).write_text(text, encoding="utf-8")
return path
def _drop(hf: LocalHotFolder, name: str, data: bytes = b"%PDF") -> str:
"""Положить `<uuid>_<name>` во входящий каталог, вернуть uuid."""
job_id = _uid()
hf.put(inbox_name(job_id, name), data)
return job_id
def _answer(hf: LocalHotFolder, job_id: str) -> dict[str, Any]:
result: dict[str, Any] = json.loads(hf.answer_path(job_id).read_text(encoding="utf-8"))
return result
@pytest.fixture
def log_messages() -> Iterator[list[str]]:
messages: list[str] = []
handler_id = logger.add(lambda message: messages.append(str(message)), level="DEBUG", format="{level}|{message}")
yield messages
logger.remove(handler_id)
@pytest.fixture
def real_fixtures(tmp_path: Path) -> Path:
if not REAL_FIXTURES.is_dir():
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
target = tmp_path / "real-fixtures"
target.mkdir()
for source in REAL_FIXTURES.glob("*.json"):
shutil.copy(source, target / source.name)
return target
@pytest.mark.fixtures
class TestRealFixtures:
@pytest.mark.parametrize("name", ["aep_5.pdf", "aep_5.PDF"])
def test_exact_name(self, tmp_path: Path, real_fixtures: Path, name: str) -> None:
hf = _hotfolder(tmp_path)
job_id = _drop(hf, name)
assert process_once(hf.root, real_fixtures, 0) == [job_id]
expected = json.loads((real_fixtures / "aep_5.json").read_text(encoding="utf-8"))
expected["file_uid"] = job_id
assert _answer(hf, job_id) == expected
assert (hf.input_dir / f"{job_id}_{name}.done").is_file()
assert not (hf.input_dir / f"{job_id}_{name}").exists()
def test_prefix(self, tmp_path: Path, real_fixtures: Path) -> None:
hf = _hotfolder(tmp_path)
job_id = _drop(hf, "aep_8.pdf")
process_once(hf.root, real_fixtures, 0)
expected = json.loads((real_fixtures / "aep_8_form4.json").read_text(encoding="utf-8"))
expected["file_uid"] = job_id
assert _answer(hf, job_id) == expected
assert (hf.input_dir / f"{job_id}_aep_8.pdf.done").is_file()
def test_no_pair(self, tmp_path: Path, real_fixtures: Path) -> None:
hf = _hotfolder(tmp_path)
job_id = _drop(hf, "unknown.pdf")
process_once(hf.root, real_fixtures, 0)
assert _answer(hf, job_id) == {
"file_uid": job_id,
"error": {"code": "INVALID_DOCUMENT", "message": "unknown.pdf: нет эталонного ответа в фикстурах"},
"data_ocr": [],
}
assert (hf.input_dir / f"{job_id}_unknown.pdf.done").is_file()
class TestFixtureLookup:
def test_exact_beats_prefix(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"file_uid": "x", "v": "exact"}, "a0.json": {"v": "prefix"}})
job_id = _drop(hf, "a.pdf")
process_once(hf.root, fixtures, 0)
assert _answer(hf, job_id) == {"file_uid": job_id, "v": "exact"}
def test_prefix_first_alphabetical(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a_zz.json": {"v": "zz"}, "a_b.json": {"v": "b"}, "a_c.json": {"v": "c"}})
job_id = _drop(hf, "a.pdf")
# `a_*` не совпадает с префиксом `a` по имени без расширения? совпадает: `a*.json`
process_once(hf.root, fixtures, 0)
assert _answer(hf, job_id)["v"] == "b"
def test_file_uid_added_when_absent_and_replaced_when_present(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"data_ocr": [1]}, "b.json": {"file_uid": "old", "data_ocr": []}})
first, second = _drop(hf, "a.pdf"), _drop(hf, "b.pdf")
process_once(hf.root, fixtures, 0)
assert _answer(hf, first) == {"file_uid": first, "data_ocr": [1]}
assert _answer(hf, second) == {"file_uid": second, "data_ocr": []}
def test_name_without_extension(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"scan.json": {"v": 1}})
job_id = _drop(hf, "scan")
process_once(hf.root, fixtures, 0)
assert _answer(hf, job_id) == {"file_uid": job_id, "v": 1}
def test_name_with_glob_characters_is_literal(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}})
job_id = _drop(hf, "[a]*.pdf")
process_once(hf.root, fixtures, 0)
assert _answer(hf, job_id)["error"]["code"] == "INVALID_DOCUMENT"
def test_non_ascii_not_escaped(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"text": "Баланс"}})
job_id = _drop(hf, "a.pdf")
process_once(hf.root, fixtures, 0)
assert "Баланс" in hf.answer_path(job_id).read_text(encoding="utf-8")
def test_missing_fixtures_dir_gives_error_answer(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
job_id = _drop(hf, "a.pdf")
process_once(hf.root, tmp_path / "нет-такого", 0)
assert _answer(hf, job_id)["error"]["code"] == "INVALID_DOCUMENT"
@pytest.mark.parametrize("content", ["{не json", "[1, 2]", ""])
def test_broken_fixture_gives_error_answer(self, tmp_path: Path, log_messages: list[str], content: str) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": content})
job_id = _drop(hf, "a.pdf")
assert process_once(hf.root, fixtures, 0) == [job_id]
answer = _answer(hf, job_id)
assert answer["file_uid"] == job_id
assert answer["data_ocr"] == []
assert answer["error"]["code"] == "INVALID_DOCUMENT"
assert "a.pdf" in answer["error"]["message"]
assert "a.json" in answer["error"]["message"]
assert any(m.startswith("ERROR|") and "a.json" in m for m in log_messages)
assert (hf.input_dir / f"{job_id}_a.pdf.done").is_file()
class TestProcessOnce:
def test_returns_processed_uuids(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}})
ids = {_drop(hf, "a.pdf"), _drop(hf, "a.pdf")}
assert set(process_once(hf.root, fixtures, 0)) == ids
def test_nothing_to_do(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
assert process_once(hf.root, _fixtures_dir(tmp_path), 0) == []
def test_missing_input_dir_is_not_an_error(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
assert not hf.input_dir.exists()
assert process_once(hf.root, _fixtures_dir(tmp_path), 0) == []
def test_second_pass_does_not_reprocess(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}})
job_id = _drop(hf, "a.pdf")
assert process_once(hf.root, fixtures, 0) == [job_id]
mtime = hf.answer_path(job_id).stat().st_mtime_ns
assert process_once(hf.root, fixtures, 0) == []
assert hf.answer_path(job_id).stat().st_mtime_ns == mtime
def test_ignores_done_dotfiles_and_non_uuid(self, tmp_path: Path, log_messages: list[str]) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}})
hf.input_dir.mkdir()
job_id = _uid()
ignored = [
f"{job_id}_a.pdf.done",
f".{job_id}_a.pdf.part",
"a.pdf",
"not-a-uuid_a.pdf",
f"{job_id}a.pdf",
f"{job_id}_",
f"{job_id[:-1]}_a.pdf",
]
for name in ignored:
(hf.input_dir / name).write_bytes(b"x")
(hf.input_dir / f"{_uid()}_dir.pdf").mkdir()
assert process_once(hf.root, fixtures, 0) == []
assert sorted(p.name for p in hf.input_dir.iterdir() if p.is_file()) == sorted(ignored)
assert not hf.output_dir.exists()
warnings = [m for m in log_messages if m.startswith("WARNING|")]
assert len(warnings) == 1
assert "not-a-uuid_a.pdf" in warnings[0]
def test_delay_once_per_pass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}})
sleeps: list[float] = []
monkeypatch.setattr("src.scan_emulator.time.sleep", sleeps.append)
assert process_once(hf.root, fixtures, 2) == []
assert sleeps == [] # нет работы — нет задержки
_drop(hf, "a.pdf")
_drop(hf, "a.pdf")
process_once(hf.root, fixtures, 2)
assert sleeps == [2]
def test_answer_exists_before_done_and_is_atomic(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}})
job_id = _drop(hf, "a.pdf")
seen: dict[str, bool] = {}
original_replace = __import__("os").replace
def spy(src: Path, dst: Path) -> None:
if str(dst).endswith(".done"):
seen["answer_before_done"] = hf.answer_path(job_id).is_file()
original_replace(src, dst)
monkeypatch.setattr("src.scan_emulator.os.replace", spy)
process_once(hf.root, fixtures, 0)
assert seen == {"answer_before_done": True}
assert [p.name for p in hf.answer_path(job_id).parent.iterdir()] == [f"{job_id}.json"]
def test_custom_dir_names(self, tmp_path: Path) -> None:
root = tmp_path / "hf"
root.mkdir()
hf = LocalHotFolder(root, "In", "Out")
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}})
job_id = _drop(hf, "a.pdf")
assert process_once(root, fixtures, 0, input_dir="In", output_dir="Out") == [job_id]
assert hf.answer_path(job_id).is_file()
def test_interop_put_emulator_poll(self, tmp_path: Path) -> None:
"""Сквозной «put -> эмулятор -> poll» без Docker (D-9)."""
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"report.json": {"file_uid": "x", "data_ocr": [{"k": "v"}]}})
job_id = _uid()
assert hf.poll(job_id) is None
hf.put(inbox_name(job_id, "report.pdf"), b"%PDF-1.4")
assert hf.poll(job_id) is None
process_once(hf.root, fixtures, 0)
raw = hf.poll(job_id)
assert raw is not None
assert json.loads(raw) == {"file_uid": job_id, "data_ocr": [{"k": "v"}]}
class TestSettings:
def test_defaults(self, tmp_path: Path) -> None:
settings = EmulatorSettings.from_env({"SCAN_HOTFOLDER_PATH": str(tmp_path)})
assert settings.hotfolder_path == tmp_path
assert (settings.input_dir, settings.output_dir) == ("Input", "Output")
assert settings.delay == 2
assert settings.poll_interval == 1
assert settings.fixtures_dir == Path("../../fixtures/ocr")
def test_overrides(self, tmp_path: Path) -> None:
settings = EmulatorSettings.from_env(
{
"SCAN_HOTFOLDER_PATH": str(tmp_path),
"SCAN_INPUT_DIR": "In",
"SCAN_OUTPUT_DIR": "Out",
"SCAN_FIXTURES_DIR": "fx",
"SCAN_EMULATOR_DELAY": "0.5",
"SCAN_EMULATOR_POLL_SECONDS": "0.1",
}
)
assert (settings.input_dir, settings.output_dir) == ("In", "Out")
assert settings.fixtures_dir == Path("fx")
assert (settings.delay, settings.poll_interval) == (0.5, 0.1)
def test_path_is_required(self) -> None:
with pytest.raises(ValueError, match="SCAN_HOTFOLDER_PATH"):
EmulatorSettings.from_env({})
@pytest.mark.parametrize(
("name", "value"),
[("SCAN_EMULATOR_DELAY", "-1"), ("SCAN_EMULATOR_DELAY", "abc"), ("SCAN_EMULATOR_POLL_SECONDS", "0")],
)
def test_invalid_numbers(self, tmp_path: Path, name: str, value: str) -> None:
with pytest.raises(ValueError, match=name):
EmulatorSettings.from_env({"SCAN_HOTFOLDER_PATH": str(tmp_path), name: value})
def _settings(tmp_path: Path, fixtures: Path, *, poll: float = 0.01) -> EmulatorSettings:
return EmulatorSettings(
hotfolder_path=tmp_path / "hotfolder",
input_dir="Input",
output_dir="Output",
fixtures_dir=fixtures,
delay=0,
poll_interval=poll,
)
class TestRun:
def test_startup_log_and_processing(self, tmp_path: Path, log_messages: list[str]) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}, "b.json": {"v": 2}})
(fixtures / "readme.txt").write_text("не фикстура")
job_id = _drop(hf, "a.pdf")
run(_settings(tmp_path, fixtures), threading.Event(), max_iterations=2)
assert hf.answer_path(job_id).is_file()
startup = "\n".join(log_messages)
assert str(hf.root) in startup
assert str(hf.input_dir) in startup
assert str(hf.output_dir) in startup
assert str(fixtures) in startup
assert any("фикстур: 2" in m for m in log_messages)
def test_missing_fixtures_dir_warns_and_continues(self, tmp_path: Path, log_messages: list[str]) -> None:
hf = _hotfolder(tmp_path)
missing = tmp_path / "нет"
job_id = _drop(hf, "a.pdf")
run(_settings(tmp_path, missing), threading.Event(), max_iterations=1)
assert any(m.startswith("WARNING|") and str(missing) in m for m in log_messages)
assert _answer(hf, job_id)["error"]["code"] == "INVALID_DOCUMENT"
def test_stop_event_ends_loop_without_waiting(self, tmp_path: Path) -> None:
_hotfolder(tmp_path)
stop = threading.Event()
stop.set()
run(_settings(tmp_path, _fixtures_dir(tmp_path), poll=60), stop) # вернётся сразу, а не через 60 с
def test_loop_survives_pass_error(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_messages: list[str]
) -> None:
_hotfolder(tmp_path)
calls: list[int] = []
def boom(*args: object, **kwargs: object) -> list[str]:
calls.append(1)
raise OSError("сбой")
monkeypatch.setattr("src.scan_emulator.process_once", boom)
run(_settings(tmp_path, _fixtures_dir(tmp_path)), threading.Event(), max_iterations=2)
assert len(calls) == 2
assert any(m.startswith("ERROR|") for m in log_messages)
class TestMain:
def test_no_hotfolder_path_exits_nonzero(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.delenv("SCAN_HOTFOLDER_PATH", raising=False)
monkeypatch.setenv("ENV_FILE", str(tmp_path / "нет.env"))
assert main() != 0
def test_hotfolder_root_missing_exits_nonzero(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(tmp_path / "нет-корня"))
assert main() != 0
def test_clean_exit_on_stop(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}})
monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(hf.root))
monkeypatch.setenv("SCAN_FIXTURES_DIR", str(fixtures))
monkeypatch.setenv("SCAN_EMULATOR_DELAY", "0")
monkeypatch.setenv("SCAN_EMULATOR_POLL_SECONDS", "0.01")
job_id = _drop(hf, "a.pdf")
stop = threading.Event()
threading.Timer(0.3, stop.set).start()
assert main(stop) == 0
assert hf.answer_path(job_id).is_file()

View File

@ -52,7 +52,7 @@
### Task 9: [4.3] `src/scan_emulator/__init__.py` + `__main__.py`
- [ ] `src/scan_emulator/__init__.py` + `__main__.py`: функция `process_once(root, fixtures, delay)` и цикл; поиск фикстуры по имени/префиксу, подстановка `file_uid`, ошибка `INVALID_DOCUMENT`, переименование в `.done`; `tests/unit/test_emulator.py` — сценарии spec «Точное имя», «Префикс», «Нет пары» на `tmp_path` с копиями `../fixtures/ocr/*.json` (маркер `fixtures`, skip без фикстур); проверка: зелёный
- [x] `src/scan_emulator/__init__.py` + `__main__.py`: функция `process_once(root, fixtures, delay)` и цикл; поиск фикстуры по имени/префиксу, подстановка `file_uid`, ошибка `INVALID_DOCUMENT`, переименование в `.done`; `tests/unit/test_emulator.py` — сценарии spec «Точное имя», «Префикс», «Нет пары» на `tmp_path` с копиями `../fixtures/ocr/*.json` (маркер `fixtures`, skip без фикстур); проверка: зелёный
### Task 10: [5.1] `src/normalizer/__init__.py`

View File

@ -37,7 +37,7 @@
на `tmp_path`; проверка: put пишет `<uuid>_<name>`, poll возвращает `None` до появления ответа и байты после
- [x] 4.2 `src/gateway/smb.py`: `SmbHotFolder` поверх `smbclient` (UNC `\\host\path\Input`), те же методы; unit-тест с
подменённым `smbclient` (вызовы `open_file`, `listdir`); проверка: тест зелёный, в режиме `local` модуль не импортируется
- [ ] 4.3 `src/scan_emulator/__init__.py` + `__main__.py`: функция `process_once(root, fixtures, delay)` и цикл; поиск
- [x] 4.3 `src/scan_emulator/__init__.py` + `__main__.py`: функция `process_once(root, fixtures, delay)` и цикл; поиск
фикстуры по имени/префиксу, подстановка `file_uid`, ошибка `INVALID_DOCUMENT`, переименование в `.done`;
`tests/unit/test_emulator.py` — сценарии spec «Точное имя», «Префикс», «Нет пары» на `tmp_path` с копиями
`../fixtures/ocr/*.json` (маркер `fixtures`, skip без фикстур); проверка: зелёный