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:
parent
a12a603657
commit
bb56226177
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user