diff --git a/api/.env.example b/api/.env.example index 8605667..00c367d 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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= diff --git a/api/src/scan_emulator/__init__.py b/api/src/scan_emulator/__init__.py index e1fadfe..f90b1ab 100644 --- a/api/src/scan_emulator/__init__.py +++ b/api/src/scan_emulator/__init__.py @@ -1,14 +1,18 @@ -"""Эмулятор ИС Сканирование (Content AI) для разработки и тестов (D-6, R-11, spec scan-emulator). +"""Эмулятор ИС Сканирование (Content AI) для разработки и тестов (D-3, D-6, spec scan-emulator). -Воспроизводит протокол горячих папок: для нового файла `_.` во входящем каталоге через задержку кладёт -каталог `//` с набором эталонных ответов из `SCAN_FIXTURES_DIR` (все `.json` -> `.json` и -`_.json` -> `_.json`) и переименовывает входящий файл в `….done`. Набор собирается во -временном каталоге служебной папки (`SCAN_TMP_DIR`, та же ФС, что и ``) и появляется в `` одним rename -каталога: шлюз не видит частичного набора. Эмулятор читает собственные переменные окружения (`EmulatorSettings`), а не -настройки шлюза: в его контейнере нет переменных MinIO/NATS. С каталогами работает через `LocalHotFolder`, -тем самым тестируя и его. +Воспроизводит протокол горячих папок: для нового файла `.` во входящем каталоге через задержку кладёт +каталог `//` с набором эталонных ответов из `SCAN_FIXTURES_DIR` по sha256 содержимого входящего файла. +При совпадении хеша находит в фиксутрах файл с той же основой имени: `.json` -> `.json` и +`_.json` -> `_.json`. При промахе по хешу использует набор по умолчанию +(`SCAN_EMULATOR_DEFAULT`), если он задан и существует; иначе ответ-ошибка `INVALID_DOCUMENT`. Набор собирается во +временном каталоге служебной папки (`SCAN_TMP_DIR`, та же ФС, что и ``) и появляется в `` одним +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 (36 символов) и непустое имя после `_` +# `.`: канонический UUID (36 символов) и расширение (1–16 буквоцифровых символов) _INBOX_NAME = re.compile( - r"^(?P[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.+)$" + r"^(?P[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[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`; повреждённая фикстура даёт на своём месте ответ-ошибку; если - фикстур нет совсем — один ответ `.json` с `INVALID_DOCUMENT`. + фикстур нет совсем или `stem is None` — один ответ `.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 обработанных файлов. - Берёт новые `_.` (не dot-файлы, не `*.done`, не каталоги), при наличии работы один раз за проход - ждёт `delay` секунд, для каждого файла публикует набор ответов `//` (атомарно, через `tmp_dir`) и - только затем переименовывает входящий файл в `….done`. Имена не по шаблону `_` игнорируются (одно - предупреждение за проход). Нет входящего каталога — пустой результат. + Берёт новые `.` (не dot-файлы, не `*.done`, не `*.part`, не `*.tmp`, не каталоги), при наличии работы + один раз за проход ждёт `delay` секунд. Для каждого файла: считает sha256 содержимого, ищет в индексе фикстур, + при совпадении кладёт набор фикстур, при промахе — набор по умолчанию (если задан) или ошибку. Публикует набор + ответов `//` (атомарно, через `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("Эмулятор: файлы не по шаблону _<имя> пропущены: {}", ", ".join(foreign)) + logger.warning("Эмулятор: файлы не по шаблону . пропущены: {}", ", ".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 diff --git a/api/tests/unit/test_emulator.py b/api/tests/unit/test_emulator.py index 73a5e9f..d95ad96 100644 --- a/api/tests/unit/test_emulator.py +++ b/api/tests/unit/test_emulator.py @@ -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.""" +def _drop(hf: LocalHotFolder, name: str, data: bytes | None = None) -> str: + """Положить файл во входящий каталог в формате `.`, вернуть uuid. + + По умолчанию содержимое берётся из одноимённой фикстуры в фиксутрах (если она существует); + иначе используется переданное data или b"%PDF ". + """ 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` как `_form4.json` (раньше — `.json`, правило `*`).""" + """`aep_8.pdf` -> `aep_8_form4.json` как `_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: """В набор входят `.json` и `_*.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*`: правило `.json` и `_*.json`, а не `*`.""" + """`aep_3` не забирает `aep_30*`: правило `.json` и `_*.json`, а не `*`.""" 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