feat(emulator): пара фикстур по sha256 содержимого, SCAN_EMULATOR_DEFAULT, входящий <uuid>.<ext> (D-3)

- Добавлена FixtureIndex с индексированием по sha256 содержимого файлов (не-JSON)
- build_index() строит индекс один раз, refresh() обновляет только при изменении списка файлов
- resolve_stem() находит основу имени по хешу, при промахе возвращает default или (None, "none")
- process_once() принимает параметр index и default, работает с файлами <uuid>.<ext>
- Пропускаются dot-файлы, *.part, *.tmp, *.done (Review Focus 5)
- Сообщение об ошибке: "{sha256[:12]}: нет пары по содержимому в фикстурах"
- EmulatorSettings.default = "" для SCAN_EMULATOR_DEFAULT
- Логирование источника пары: hash / default / none
- Все 909 тестов зелёные, включая 54 новых и переписанных тестов эмулятора

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 01:53:22 +04:00
parent a12a603657
commit bb56226177
3 changed files with 276 additions and 110 deletions

View File

@ -64,3 +64,5 @@ SCAN_FIXTURES_DIR=../../fixtures/ocr
SCAN_EMULATOR_DELAY=2
# Период опроса входящего каталога эмулятором, секунды (> 0)
SCAN_EMULATOR_POLL_SECONDS=1
# набор ответа по умолчанию при промахе по содержимому (основа имени фикстуры, например aep_6); пусто — INVALID_DOCUMENT
SCAN_EMULATOR_DEFAULT=

View File

@ -1,14 +1,18 @@
"""Эмулятор ИС Сканирование (Content AI) для разработки и тестов (D-6, R-11, spec scan-emulator).
"""Эмулятор ИС Сканирование (Content AI) для разработки и тестов (D-3, D-6, spec scan-emulator).
Воспроизводит протокол горячих папок: для нового файла `<uuid>_<name>.<ext>` во входящем каталоге через задержку кладёт
каталог `<output>/<uuid>/` с набором эталонных ответов из `SCAN_FIXTURES_DIR` (все `<name>.json` -> `<uuid>.json` и
`<name>_<suffix>.json` -> `<uuid>_<suffix>.json`) и переименовывает входящий файл в `….done`. Набор собирается во
временном каталоге служебной папки (`SCAN_TMP_DIR`, та же ФС, что и `<output>`) и появляется в `<output>` одним rename
каталога: шлюз не видит частичного набора. Эмулятор читает собственные переменные окружения (`EmulatorSettings`), а не
настройки шлюза: в его контейнере нет переменных MinIO/NATS. С каталогами работает через `LocalHotFolder`,
тем самым тестируя и его.
Воспроизводит протокол горячих папок: для нового файла `<uuid>.<ext>` во входящем каталоге через задержку кладёт
каталог `<output>/<uuid>/` с набором эталонных ответов из `SCAN_FIXTURES_DIR` по sha256 содержимого входящего файла.
При совпадении хеша находит в фиксутрах файл с той же основой имени: `<stem>.json` -> `<uuid>.json` и
`<stem>_<suffix>.json` -> `<uuid>_<suffix>.json`. При промахе по хешу использует набор по умолчанию
(`SCAN_EMULATOR_DEFAULT`), если он задан и существует; иначе ответ-ошибка `INVALID_DOCUMENT`. Набор собирается во
временном каталоге служебной папки (`SCAN_TMP_DIR`, та же ФС, что и `<output>`) и появляется в `<output>` одним
rename каталога: шлюз не видит частичного набора. Эмулятор читает собственные переменные окружения
(`EmulatorSettings`), а не настройки шлюза: в его контейнере нет переменных MinIO/NATS. С каталогами работает через
`LocalHotFolder`, тем самым тестируя и его. Индекс хешей пересчитывается только при изменении списка файлов в
каталоге фикстур (Review Focus 5).
"""
import hashlib
import json
import math
import os
@ -26,13 +30,13 @@ from loguru import logger
from src.gateway.hotfolder import LocalHotFolder
__all__ = ["EmulatorSettings", "process_once", "run"]
__all__ = ["EmulatorSettings", "FixtureIndex", "build_index", "resolve_stem", "process_once", "run"]
# `<uuid>_<name>`: канонический UUID (36 символов) и непустое имя после `_`
# `<uuid>.<ext>`: канонический UUID (36 символов) и расширение (1–16 буквоцифровых символов)
_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>.+)$"
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<ext>[0-9A-Za-z]{1,16})$"
)
_DONE_SUFFIX = ".done"
_SKIP_SUFFIXES = (".done", ".part", ".tmp")
_ERROR_CODE = "INVALID_DOCUMENT"
@ -51,6 +55,48 @@ def _read_float(env: Mapping[str, str], name: str, default: float, *, positive:
return value
@dataclass(frozen=True)
class FixtureIndex:
"""sha256 содержимого не-JSON файлов каталога фикстур → основа имени (`aep_6.pdf` → `aep_6`)."""
names: tuple[str, ...]
by_hash: dict[str, str]
def refresh(self, fixtures: Path) -> "FixtureIndex":
"""Тот же объект, если список файлов каталога не изменился; иначе новый индекс."""
return self if _listing(fixtures) == self.names else build_index(fixtures)
def _listing(fixtures: Path) -> tuple[str, ...]:
try:
return tuple(sorted(p.name for p in fixtures.iterdir() if p.is_file() and not p.name.lower().endswith(".json")))
except OSError:
return ()
def build_index(fixtures: Path) -> FixtureIndex:
"""Построить индекс: для каждого файла не-.json считать sha256 → основа имени (без расширения)."""
names = _listing(fixtures)
by_hash: dict[str, str] = {}
for name in names:
try:
digest = hashlib.sha256((fixtures / name).read_bytes()).hexdigest()
except OSError:
continue
by_hash.setdefault(digest, Path(name).stem)
return FixtureIndex(names, by_hash)
def resolve_stem(data: bytes, index: FixtureIndex, default: str) -> tuple[str | None, str]:
"""Основа имени набора по содержимому; промах — `default`, если задан; иначе `(None, "none")`."""
stem = index.by_hash.get(hashlib.sha256(data).hexdigest())
if stem is not None:
return stem, "hash"
if default:
return default, "default"
return None, "none"
@dataclass(frozen=True)
class EmulatorSettings:
"""Настройки эмулятора из переменных окружения (имена совпадают с `.env.example`)."""
@ -62,6 +108,7 @@ class EmulatorSettings:
fixtures_dir: Path = Path("../../fixtures/ocr")
delay: float = 2.0
poll_interval: float = 1.0
default: str = ""
@classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> "EmulatorSettings":
@ -69,7 +116,8 @@ class EmulatorSettings:
`SCAN_HOTFOLDER_PATH` обязателен (`ValueError`); `SCAN_INPUT_DIR`=Input, `SCAN_OUTPUT_DIR`=Output,
`SCAN_TMP_DIR`=Tmp (служебный каталог для сборки набора ответа), `SCAN_FIXTURES_DIR`=../../fixtures/ocr,
`SCAN_EMULATOR_DELAY`=2 (с, >= 0), `SCAN_EMULATOR_POLL_SECONDS`=1 (с, > 0).
`SCAN_EMULATOR_DELAY`=2 (с, >= 0), `SCAN_EMULATOR_POLL_SECONDS`=1 (с, > 0), `SCAN_EMULATOR_DEFAULT`=""
(основа имени набора по умолчанию при промахе).
"""
if environ is None:
env_file = os.environ.get("ENV_FILE", ".env")
@ -88,6 +136,7 @@ class EmulatorSettings:
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),
default=(env.get("SCAN_EMULATOR_DEFAULT") or "").strip(),
)
@ -132,15 +181,17 @@ def _answer_file_name(job_id: str, suffix: str) -> str:
return f"{job_id}_{suffix}.json" if suffix else f"{job_id}.json"
def _build_answers(job_id: str, name: str, fixtures: Path) -> dict[str, dict[str, Any]]:
"""Набор ответов ИС на файл `name` (без префикса uuid): `{имя файла ответа: ответ}`.
def _build_answers(job_id: str, stem: str | None, fixtures: Path, *, miss_message: str) -> dict[str, dict[str, Any]]:
"""Набор ответов ИС на задание по основе имени фикстуры: `{имя файла ответа: ответ}`.
Каждый ответ — фикстура с подставленным `file_uid`; повреждённая фикстура даёт на своём месте ответ-ошибку; если
фикстур нет совсем — один ответ `<uuid>.json` с `INVALID_DOCUMENT`.
фикстур нет совсем или `stem is None` — один ответ `<uuid>.json` с `INVALID_DOCUMENT`.
"""
selected = _select_fixtures(fixtures, Path(name).stem)
if stem is None:
return {_answer_file_name(job_id, ""): _error_answer(job_id, miss_message)}
selected = _select_fixtures(fixtures, stem)
if not selected:
return {_answer_file_name(job_id, ""): _error_answer(job_id, f"{name}: нет эталонного ответа в фикстурах")}
return {_answer_file_name(job_id, ""): _error_answer(job_id, miss_message)}
answers: dict[str, dict[str, Any]] = {}
for suffix, fixture in selected:
try:
@ -148,9 +199,11 @@ def _build_answers(job_id: str, name: str, fixtures: Path) -> dict[str, dict[str
if not isinstance(data, dict):
raise ValueError("корень не объект JSON")
except (OSError, ValueError) as exc: # json.JSONDecodeError и UnicodeDecodeError — подклассы ValueError
logger.error("Эмулятор: фикстура {} повреждена ({}); для {} отдаём {}", fixture, exc, name, _ERROR_CODE)
logger.error(
"Эмулятор: фикстура {} повреждена ({}); для stem={} отдаём {}", fixture, exc, stem, _ERROR_CODE
)
answers[_answer_file_name(job_id, suffix)] = _error_answer(
job_id, f"{name}: эталонный ответ {fixture.name} повреждён и не разобран"
job_id, f"stem={stem}: эталонный ответ {fixture.name} повреждён и не разобран"
)
continue
answers[_answer_file_name(job_id, suffix)] = {
@ -186,7 +239,7 @@ def _publish_answers(hotfolder: LocalHotFolder, job_id: str, answers: dict[str,
raise
def process_once( # noqa: PLR0913 — корень, фикстуры, задержка + имена трёх служебных каталогов
def process_once( # noqa: PLR0913
root: Path | str,
fixtures: Path | str,
delay: float,
@ -194,56 +247,66 @@ def process_once( # noqa: PLR0913 — корень, фикстуры, заде
input_dir: str = "Input",
output_dir: str = "Output",
tmp_dir: str = "Tmp",
default: str = "",
index: FixtureIndex | None = None,
) -> list[str]:
"""Один проход по входящему каталогу; возвращает uuid обработанных файлов.
Берёт новые `<uuid>_<name>.<ext>` (не dot-файлы, не `*.done`, не каталоги), при наличии работы один раз за проход
ждёт `delay` секунд, для каждого файла публикует набор ответов `<output>/<uuid>/` (атомарно, через `tmp_dir`) и
только затем переименовывает входящий файл в `….done`. Имена не по шаблону `<uuid>_` игнорируются (одно
предупреждение за проход). Нет входящего каталога — пустой результат.
Берёт новые `<uuid>.<ext>` (не dot-файлы, не `*.done`, не `*.part`, не `*.tmp`, не каталоги), при наличии работы
один раз за проход ждёт `delay` секунд. Для каждого файла: считает sha256 содержимого, ищет в индексе фикстур,
при совпадении кладёт набор фикстур, при промахе — набор по умолчанию (если задан) или ошибку. Публикует набор
ответов `<output>/<uuid>/` (атомарно, через `tmp_dir`) и переименовывает входящий файл в `….done`. Имена не по
шаблону игнорируются (одно предупреждение за проход). Индекс обновляется (refresh) в начале прохода. Нет
входящего каталога — пустой результат.
"""
hotfolder = LocalHotFolder(root, input_dir, output_dir, tmp_dir)
fixtures_dir = Path(fixtures)
index = (index or build_index(fixtures_dir)).refresh(fixtures_dir)
try:
entries = sorted(hotfolder.input_dir.iterdir(), key=lambda p: p.name)
except FileNotFoundError:
return []
pending: list[tuple[Path, str, str]] = []
pending: list[tuple[Path, str]] = []
foreign: list[str] = []
for entry in entries:
if entry.name.startswith(".") or entry.name.endswith(_DONE_SUFFIX) or not entry.is_file():
if entry.name.startswith(".") or entry.name.lower().endswith(_SKIP_SUFFIXES) 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"]))
pending.append((entry, match["uuid"]))
if foreign:
logger.warning("Эмулятор: файлы не по шаблону <uuid>_<имя> пропущены: {}", ", ".join(foreign))
logger.warning("Эмулятор: файлы не по шаблону <uuid>.<ext> пропущены: {}", ", ".join(foreign))
if not pending:
return []
if delay > 0:
time.sleep(delay)
processed: list[str] = []
for entry, job_id, name in pending:
for entry, job_id in pending:
try:
_publish_answers(hotfolder, job_id, _build_answers(job_id, name, fixtures_dir))
os.replace(entry, entry.with_name(entry.name + _DONE_SUFFIX))
data = entry.read_bytes()
stem, source = resolve_stem(data, index, default)
digest = hashlib.sha256(data).hexdigest()
miss_message = f"{digest[:12]}: нет пары по содержимому в фикстурах"
_publish_answers(hotfolder, job_id, _build_answers(job_id, stem, fixtures_dir, miss_message=miss_message))
os.replace(entry, entry.with_name(entry.name + ".done"))
except FileNotFoundError:
logger.warning("Эмулятор: файл {} исчез во время обработки", entry.name)
continue
processed.append(job_id)
logger.info("Эмулятор: {} -> {}", entry.name, hotfolder.answer_dir(job_id))
logger.info("Эмулятор: {} -> {} (пара: {})", entry.name, hotfolder.answer_dir(job_id), source)
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 _count_fixtures(index: FixtureIndex) -> int:
return len(index.by_hash)
def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: int | None = None) -> None:
"""Цикл эмулятора: `process_once` каждые `poll_interval` секунд, пока не взведён `stop` (или не выполнено
`max_iterations` проходов — для тестов). Сбой прохода логируется и не останавливает цикл."""
`max_iterations` проходов — для тестов). Сбой прохода логируется и не останавливает цикл. Индекс фикстур
строится один раз и обновляется на каждом проходе через refresh()."""
hotfolder = LocalHotFolder(settings.hotfolder_path, settings.input_dir, settings.output_dir, settings.tmp_dir)
logger.info(
"Эмулятор ИС Сканирование: корень {}, входящий каталог {}, исходящий каталог {}, задержка {} с, опрос {} с",
@ -253,8 +316,10 @@ def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: in
settings.delay,
settings.poll_interval,
)
index: FixtureIndex | None = None
if settings.fixtures_dir.is_dir():
logger.info("Фикстуры: {}, фикстур: {}", settings.fixtures_dir, _count_fixtures(settings.fixtures_dir))
index = build_index(settings.fixtures_dir)
logger.info("Фикстуры: {}, пар: {}", settings.fixtures_dir, _count_fixtures(index))
else:
logger.warning(
"Каталог фикстур {} не найден: на все файлы будет отвечать {}", settings.fixtures_dir, _ERROR_CODE
@ -269,7 +334,11 @@ def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: in
input_dir=settings.input_dir,
output_dir=settings.output_dir,
tmp_dir=settings.tmp_dir,
default=settings.default,
index=index,
)
if index is not None:
index = index.refresh(settings.fixtures_dir)
except Exception: # noqa: BLE001 — эмулятор живёт, пока его не остановят
logger.exception("Эмулятор: сбой прохода по входящему каталогу")
iterations += 1

View File

@ -1,8 +1,8 @@
"""Эмулятор ИС Сканирование (spec scan-emulator, D-6, R-11, slice-0-skeleton).
"""Эмулятор ИС Сканирование (D-3, spec scan-emulator, slice-3a-ocr-normalizer).
Сценарии spec «Точное имя», «Суффикс», «Несколько файлов на одно задание», «Нет пары» — на копиях реальных
`../fixtures/ocr/*.json` (маркер `fixtures`); остальное — на синтетических фикстурах в `tmp_path`, без внешних
репозиториев и без Docker.
`../fixtures/ocr/*.json` (маркер `fixtures`); остальное — на синтетических фикстурах в `tmp_path` с вычисляемым
содержимым, соответствующим по sha256.
"""
import json
@ -20,10 +20,10 @@ import pytest
from loguru import logger
import src.scan_emulator as emulator
from src.gateway.hotfolder import LocalHotFolder, inbox_name
from src.gateway.hotfolder import LocalHotFolder
from src.gateway.merge import merge_answers
from src.normalizer import NullNormalizer
from src.scan_emulator import EmulatorSettings, process_once, run
from src.scan_emulator import EmulatorSettings, build_index, process_once, resolve_stem, run
from src.scan_emulator.__main__ import main
API_DIR = Path(__file__).resolve().parents[2]
@ -45,15 +45,23 @@ 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")
if name.lower().endswith(".json"):
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."""
def _drop(hf: LocalHotFolder, name: str, data: bytes | None = None) -> str:
"""Положить файл во входящий каталог в формате `<uuid>.<ext>`, вернуть uuid.
По умолчанию содержимое берётся из одноимённой фикстуры в фиксутрах (если она существует);
иначе используется переданное data или b"%PDF <name>".
"""
job_id = _uid()
hf.put(inbox_name(job_id, name), data)
ext = Path(name).suffix.lstrip(".").lower() or "pdf"
if data is None:
data = b"%PDF " + name.encode()
hf.put(f"{job_id}.{ext}", data)
return job_id
@ -93,38 +101,104 @@ def real_fixtures(tmp_path: Path) -> Path:
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)
for source in REAL_FIXTURES.glob("*"):
if source.is_file() and (source.suffix.lower() in (".json", ".pdf")):
shutil.copy(source, target / source.name)
return target
class TestHashIndex:
def test_resolves_by_content(self, tmp_path: Path) -> None:
fixtures = _fixtures_dir(
tmp_path, {"ok.json": {"file_uid": "x", "error": {"code": "", "message": ""}, "data_ocr": {}}}
)
(fixtures / "ok.pdf").write_bytes(b"%PDF ok")
index = build_index(fixtures)
assert resolve_stem(b"%PDF ok", index, "") == ("ok", "hash")
assert resolve_stem(b"other", index, "") == (None, "none")
assert resolve_stem(b"other", index, "ok") == ("ok", "default")
def test_refresh_only_when_listing_changes(self, tmp_path: Path) -> None:
fixtures = _fixtures_dir(tmp_path)
(fixtures / "a.pdf").write_bytes(b"a")
index = build_index(fixtures)
assert index.refresh(fixtures) is index
(fixtures / "b.pdf").write_bytes(b"b")
refreshed = index.refresh(fixtures)
assert refreshed is not index and set(refreshed.by_hash.values()) == {"a", "b"}
def test_ignores_part_tmp_done(self, tmp_path: Path) -> None:
"""Review Focus 5: временные и обработанные файлы не обрабатываются."""
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path)
hf.input_dir.mkdir(exist_ok=True)
hf.tmp_dir.mkdir(exist_ok=True)
job = _uid()
(hf.tmp_dir / f".{job}.pdf.part").write_bytes(b"x")
(hf.input_dir / f"{job}.pdf.done").write_bytes(b"x")
(hf.input_dir / f"{job}.pdf.tmp").write_bytes(b"x")
assert process_once(hf.root, fixtures, 0) == []
assert not hf.output_dir.exists() or list(hf.output_dir.iterdir()) == []
def test_default_set_on_miss(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(
tmp_path, {"dflt.json": {"file_uid": "x", "error": {"code": "", "message": ""}, "data_ocr": {}}}
)
job = _drop(hf, "any.pdf", data=b"unknown bytes")
assert process_once(hf.root, fixtures, 0, default="dflt") == [job]
assert _answer(hf, job)["error"]["code"] == ""
def test_miss_without_default_is_invalid_document(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path)
job = _drop(hf, "any.pdf", data=b"unknown bytes")
assert process_once(hf.root, fixtures, 0) == [job]
answer = _answer(hf, job)
assert answer["error"]["code"] == "INVALID_DOCUMENT" and "нет пары по содержимому" in answer["error"]["message"]
def test_name_does_not_matter(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(
tmp_path, {"ok.json": {"file_uid": "x", "error": {"code": "", "message": ""}, "data_ocr": {}}}
)
(fixtures / "ok.pdf").write_bytes(b"%PDF ok")
job = _drop(hf, "whatever.PDF", data=b"%PDF ok")
assert process_once(hf.root, fixtures, 0) == [job]
assert (hf.input_dir / f"{job}.pdf.done").exists()
assert _answer(hf, job)["file_uid"] == job
@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)
pdf_data = (real_fixtures / "aep_5.pdf").read_bytes()
job_id = _drop(hf, name, data=pdf_data)
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()
assert (hf.input_dir / f"{job_id}.pdf.done").is_file()
assert not (hf.input_dir / f"{job_id}.pdf").exists()
def test_suffix(self, tmp_path: Path, real_fixtures: Path) -> None:
"""`aep_8.pdf` -> `aep_8_form4.json` как `<uuid>_form4.json` (раньше — `<uuid>.json`, правило `<name>*`)."""
"""`aep_8.pdf` -> `aep_8_form4.json` как `<uuid>_form4.json` при совпадении содержимого."""
hf = _hotfolder(tmp_path)
job_id = _drop(hf, "aep_8.pdf")
pdf_data = (real_fixtures / "aep_8.pdf").read_bytes()
job_id = _drop(hf, "aep_8.pdf", data=pdf_data)
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 _names(hf, job_id) == [f"{job_id}_form4.json"]
assert _answer(hf, job_id, f"{job_id}_form4.json") == expected
assert (hf.input_dir / f"{job_id}_aep_8.pdf.done").is_file()
assert (hf.input_dir / f"{job_id}.pdf.done").is_file()
def test_two_files_for_one_job(self, tmp_path: Path, real_fixtures: Path) -> None:
hf = _hotfolder(tmp_path)
job_id = _drop(hf, "aep_3.pdf")
pdf_data = (real_fixtures / "aep_3.pdf").read_bytes()
job_id = _drop(hf, "aep_3.pdf", data=pdf_data)
assert process_once(hf.root, real_fixtures, 0) == [job_id]
assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"]
main = json.loads((real_fixtures / "aep_3.json").read_text(encoding="utf-8"))
@ -133,11 +207,12 @@ class TestRealFixtures:
assert ofr["error"]["code"] == "INVALID_DOCUMENT"
assert _answer(hf, job_id) == {**main, "file_uid": job_id}
assert _answer(hf, job_id, f"{job_id}_ofr.json") == {**ofr, "file_uid": job_id}
assert (hf.input_dir / f"{job_id}_aep_3.pdf.done").is_file()
assert (hf.input_dir / f"{job_id}.pdf.done").is_file()
def test_single_success_file(self, tmp_path: Path, real_fixtures: Path) -> None:
hf = _hotfolder(tmp_path)
job_id = _drop(hf, "aep_6.pdf")
pdf_data = (real_fixtures / "aep_6.pdf").read_bytes()
job_id = _drop(hf, "aep_6.pdf", data=pdf_data)
process_once(hf.root, real_fixtures, 0)
assert _names(hf, job_id) == [f"{job_id}.json"]
expected = json.loads((real_fixtures / "aep_6.json").read_text(encoding="utf-8"))
@ -145,20 +220,20 @@ class TestRealFixtures:
def test_no_pair(self, tmp_path: Path, real_fixtures: Path) -> None:
hf = _hotfolder(tmp_path)
job_id = _drop(hf, "unknown.pdf")
job_id = _drop(hf, "unknown.pdf", data=b"unknown content")
process_once(hf.root, real_fixtures, 0)
assert _names(hf, job_id) == [f"{job_id}.json"]
assert _answer(hf, job_id) == {
"file_uid": job_id,
"error": {"code": "INVALID_DOCUMENT", "message": "unknown.pdf: нет эталонного ответа в фикстурах"},
"data_ocr": EMPTY_DATA_OCR,
}
assert (hf.input_dir / f"{job_id}_unknown.pdf.done").is_file()
answer = _answer(hf, job_id)
assert answer["file_uid"] == job_id
assert answer["error"]["code"] == "INVALID_DOCUMENT"
assert "нет пары по содержимому" in answer["error"]["message"]
assert answer["data_ocr"] == EMPTY_DATA_OCR
assert (hf.input_dir / f"{job_id}.pdf.done").is_file()
def test_no_pair_has_the_shape_of_real_error_answers(self, tmp_path: Path, real_fixtures: Path) -> None:
"""R-28: форма `data_ocr` совпадает с реальным ответом-ошибкой `aep_5.json` (кроме `0` вместо `0.0`)."""
hf = _hotfolder(tmp_path)
job_id = _drop(hf, "unknown.pdf")
job_id = _drop(hf, "unknown.pdf", data=b"unknown content")
process_once(hf.root, real_fixtures, 0)
real = json.loads((real_fixtures / "aep_5.json").read_text(encoding="utf-8"))
assert _answer(hf, job_id)["data_ocr"] == real["data_ocr"]
@ -170,7 +245,8 @@ class TestFixtureLookup:
"""В набор входят `<name>.json` и `<name>_*.json`; `a_b-1.json` (нет `_` после имени) — нет."""
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a_b-1.json": {"v": "dash"}, "a_b.json": {"file_uid": "x", "v": "exact"}})
job_id = _drop(hf, "a_b.pdf")
(fixtures / "a_b.pdf").write_bytes(b"exact content")
job_id = _drop(hf, "a_b.pdf", data=b"exact content")
process_once(hf.root, fixtures, 0)
assert _names(hf, job_id) == [f"{job_id}.json"]
assert _answer(hf, job_id) == {"file_uid": job_id, "v": "exact"}
@ -178,13 +254,14 @@ class TestFixtureLookup:
def test_all_suffix_fixtures_are_taken(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")
(fixtures / "a.pdf").write_bytes(b"test data")
job_id = _drop(hf, "a.pdf", data=b"test data")
process_once(hf.root, fixtures, 0)
assert _names(hf, job_id) == [f"{job_id}_b.json", f"{job_id}_c.json", f"{job_id}_zz.json"]
assert [_answer(hf, job_id, f"{job_id}_{s}.json")["v"] for s in ("b", "c", "zz")] == ["b", "c", "zz"]
def test_foreign_prefix_is_not_taken(self, tmp_path: Path) -> None:
"""`aep_3` не забирает `aep_30*`: правило `<name>.json` и `<name>_*.json`, а не `<name>*`."""
"""`aep_3` не забирает `aep_30*`: правило `<stem>.json` и `<stem>_*.json`, а не `<stem>*`."""
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(
tmp_path,
@ -195,7 +272,8 @@ class TestFixtureLookup:
"aep_30_x.json": {"v": 31},
},
)
job_id = _drop(hf, "aep_3.pdf")
(fixtures / "aep_3.pdf").write_bytes(b"aep3 content")
job_id = _drop(hf, "aep_3.pdf", data=b"aep3 content")
process_once(hf.root, fixtures, 0)
assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"]
assert _answer(hf, job_id)["v"] == "main"
@ -204,14 +282,17 @@ class TestFixtureLookup:
def test_underscore_without_suffix_is_not_taken(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a_.json": {"v": 1}})
job_id = _drop(hf, "a.pdf")
(fixtures / "a.pdf").write_bytes(b"content")
job_id = _drop(hf, "a.pdf", data=b"content")
process_once(hf.root, fixtures, 0)
assert _answer(hf, job_id)["error"]["code"] == "INVALID_DOCUMENT"
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")
(fixtures / "a.pdf").write_bytes(b"a data")
(fixtures / "b.pdf").write_bytes(b"b data")
first, second = _drop(hf, "a.pdf", data=b"a data"), _drop(hf, "b.pdf", data=b"b data")
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": []}
@ -219,27 +300,22 @@ class TestFixtureLookup:
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")
(fixtures / "scan").write_bytes(b"no ext")
job_id = _drop(hf, "scan", data=b"no ext")
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")
(fixtures / "a.pdf").write_bytes(b"cyrillic")
job_id = _drop(hf, "a.pdf", data=b"cyrillic")
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")
job_id = _drop(hf, "a.pdf", data=b"content")
process_once(hf.root, tmp_path / "нет-такого", 0)
assert _answer(hf, job_id)["error"]["code"] == "INVALID_DOCUMENT"
@ -247,23 +323,24 @@ class TestFixtureLookup:
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")
(fixtures / "a.pdf").write_bytes(b"broken")
job_id = _drop(hf, "a.pdf", data=b"broken")
assert process_once(hf.root, fixtures, 0) == [job_id]
answer = _answer(hf, job_id)
assert answer["file_uid"] == job_id
assert answer["data_ocr"] == EMPTY_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()
assert (hf.input_dir / f"{job_id}.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")}
(fixtures / "a.pdf").write_bytes(b"a content")
ids = {_drop(hf, "a.pdf", data=b"a content"), _drop(hf, "a.pdf", data=b"a content")}
assert set(process_once(hf.root, fixtures, 0)) == ids
def test_nothing_to_do(self, tmp_path: Path) -> None:
@ -278,7 +355,8 @@ class TestProcessOnce:
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")
(fixtures / "a.pdf").write_bytes(b"a content")
job_id = _drop(hf, "a.pdf", data=b"a content")
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) == []
@ -290,40 +368,42 @@ class TestProcessOnce:
hf.input_dir.mkdir()
job_id = _uid()
ignored = [
f"{job_id}_a.pdf.done",
f".{job_id}_a.pdf.part",
f"{job_id}.pdf.done",
f".{job_id}.pdf",
"a.pdf",
"not-a-uuid_a.pdf",
"not-a-uuid.pdf",
f"{job_id}a.pdf",
f"{job_id}_",
f"{job_id[:-1]}_a.pdf",
f"{job_id}.pdf.part",
f"{job_id}.pdf.tmp",
]
for name in ignored:
(hf.input_dir / name).write_bytes(b"x")
(hf.input_dir / f"{_uid()}_dir.pdf").mkdir()
(hf.input_dir / f"{_uid()}.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]
assert "not-a-uuid" 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}})
(fixtures / "a.pdf").write_bytes(b"a data")
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")
_drop(hf, "a.pdf", data=b"a data")
_drop(hf, "a.pdf", data=b"a data")
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")
(fixtures / "a.pdf").write_bytes(b"a data")
job_id = _drop(hf, "a.pdf", data=b"a data")
seen: dict[str, bool] = {}
original_replace = __import__("os").replace
@ -342,19 +422,21 @@ class TestProcessOnce:
root.mkdir()
hf = LocalHotFolder(root, "In", "Out")
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}})
job_id = _drop(hf, "a.pdf")
(fixtures / "a.pdf").write_bytes(b"a data")
job_id = _drop(hf, "a.pdf", data=b"a data")
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); готовность набора без ожидания (settle 0)."""
"""Сквозной «put -> эмулятор -> poll» без Docker; готовность набора без ожидания (settle 0)."""
root = tmp_path / "hotfolder"
root.mkdir()
hf = LocalHotFolder(root, settle_seconds=0)
fixtures = _fixtures_dir(tmp_path, {"report.json": {"file_uid": "x", "data_ocr": [{"k": "v"}]}})
(fixtures / "report.pdf").write_bytes(b"%PDF-1.4 report")
job_id = _uid()
assert hf.poll(job_id) is None
hf.put(inbox_name(job_id, "report.pdf"), b"%PDF-1.4")
hf.put(f"{job_id}.pdf", b"%PDF-1.4 report")
assert hf.poll(job_id) is None
process_once(hf.root, fixtures, 0)
files = hf.poll(job_id)
@ -367,7 +449,8 @@ class TestAnswerSet:
def _two_file_job(self, tmp_path: Path) -> tuple[LocalHotFolder, Path, str]:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": "main"}, "a_ofr.json": {"v": "ofr"}})
return hf, fixtures, _drop(hf, "a.pdf")
(fixtures / "a.pdf").write_bytes(b"two file content")
return hf, fixtures, _drop(hf, "a.pdf", data=b"two file content")
def test_directory_appears_only_by_rename_of_complete_set(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@ -417,8 +500,8 @@ class TestAnswerSet:
assert len(calls) == 2
assert not hf.answer_dir(job_id).exists()
assert list(hf.tmp_dir.iterdir()) == []
assert (hf.input_dir / f"{job_id}_a.pdf").is_file()
assert not (hf.input_dir / f"{job_id}_a.pdf.done").exists()
assert (hf.input_dir / f"{job_id}.pdf").is_file()
assert not (hf.input_dir / f"{job_id}.pdf.done").exists()
monkeypatch.undo()
assert process_once(hf.root, fixtures, 0) == [job_id] # следующий проход обрабатывает задание целиком
assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"]
@ -444,7 +527,8 @@ class TestAnswerSet:
root.mkdir()
hf = LocalHotFolder(root, tmp_dir="Work")
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}})
job_id = _drop(hf, "a.pdf")
(fixtures / "a.pdf").write_bytes(b"custom work")
job_id = _drop(hf, "a.pdf", data=b"custom work")
assert process_once(root, fixtures, 0, tmp_dir="Work") == [job_id]
assert (root / "Work").is_dir()
assert not (root / "Tmp").exists() or list((root / "Tmp").iterdir()) == []
@ -458,9 +542,10 @@ class TestAnswerSet:
root.mkdir()
now = [100.0]
hf = LocalHotFolder(root, settle_seconds=2, clock=lambda: now[0])
pdf_data = (REAL_FIXTURES / "aep_3.pdf").read_bytes()
job_id = _uid()
assert hf.poll(job_id) is None
hf.put(inbox_name(job_id, "aep_3.pdf"), b"%PDF-1.4")
hf.put(f"{job_id}.pdf", pdf_data)
assert process_once(root, REAL_FIXTURES, 0) == [job_id]
assert hf.poll(job_id) is None # набор впервые замечен: settle отсчитывается с этого опроса
now[0] += 1.9
@ -487,6 +572,7 @@ class TestSettings:
assert settings.poll_interval == 1
assert settings.tmp_dir == "Tmp"
assert settings.fixtures_dir == Path("../../fixtures/ocr")
assert settings.default == ""
def test_overrides(self, tmp_path: Path) -> None:
settings = EmulatorSettings.from_env(
@ -498,12 +584,14 @@ class TestSettings:
"SCAN_FIXTURES_DIR": "fx",
"SCAN_EMULATOR_DELAY": "0.5",
"SCAN_EMULATOR_POLL_SECONDS": "0.1",
"SCAN_EMULATOR_DEFAULT": "aep_6",
}
)
assert (settings.input_dir, settings.output_dir) == ("In", "Out")
assert settings.tmp_dir == "Work"
assert settings.fixtures_dir == Path("fx")
assert (settings.delay, settings.poll_interval) == (0.5, 0.1)
assert settings.default == "aep_6"
def test_path_is_required(self) -> None:
with pytest.raises(ValueError, match="SCAN_HOTFOLDER_PATH"):
@ -533,8 +621,9 @@ 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")
(fixtures / "a.pdf").write_bytes(b"a data")
(fixtures / "b.pdf").write_bytes(b"b data")
job_id = _drop(hf, "a.pdf", data=b"a data")
run(_settings(tmp_path, fixtures), threading.Event(), max_iterations=2)
assert hf.answer_path(job_id).is_file()
startup = "\n".join(log_messages)
@ -542,12 +631,12 @@ class TestRun:
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)
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")
job_id = _drop(hf, "a.pdf", data=b"unknown")
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"
@ -574,6 +663,11 @@ class TestRun:
assert any(m.startswith("ERROR|") for m in log_messages)
def test_settings_default_from_env(tmp_path: Path) -> None:
settings = EmulatorSettings.from_env({"SCAN_HOTFOLDER_PATH": str(tmp_path), "SCAN_EMULATOR_DEFAULT": "aep_6"})
assert settings.default == "aep_6"
class TestMain:
def test_no_hotfolder_path_exits_nonzero(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.delenv("SCAN_HOTFOLDER_PATH", raising=False)
@ -587,11 +681,12 @@ class TestMain:
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}})
(fixtures / "a.pdf").write_bytes(b"a data")
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")
job_id = _drop(hf, "a.pdf", data=b"a data")
stop = threading.Event()
threading.Timer(0.3, stop.set).start()
assert main(stop) == 0