- Исправлена критическая ошибка: NullNormalizer считал ответ ошибкой при `error is not None`,
а у реального успеха ИС error = {"code": "", "message": ""} — aep_3.json и aep_6.json давали
UNKNOWN_ERROR. Успех файла теперь — error.code == "" (отсутствующий/null/не-объектный error
и error без кода ошибкой не считаются); регрессия — на реальных фикстурах (маркер fixtures).
- src/gateway/merge.py: merge_answers(job_id, files) -> MergedResponse
{"job_id", "files": [{"name", "response", "parse_error"}]} по возрастанию имени; не JSON —
parse_error файла; dump_merged — UTF-8 (с ASCII-экранированием при одиночном суррогате).
- Воркер: опрос отдаёт готовый набор → склейка → raw/{job_id}.json (application/json) до
нормализации → Normalizer.normalize(склейка). Перечитывание «не JSON» убрано (R-24).
- NullNormalizer: все файлы ошибочны — код/сообщение первого по имени (parse_error и
не-объект — INVALID_FORMAT, код вне контракта — UNKNOWN_ERROR с исходным кодом); есть
успешный — NOT_IMPLEMENTED; ошибочные файлы — строки `<имя>: <код ИС>: <сообщение>`.
- NormalizeOutcome.warnings (R-25) → metrics.warnings конверта и файла результата; пустые
опускаются; не больше 100 строк по 1000 символов, лишние — сводной строкой (R-30).
- Тесты: test_merge.py, test_normalizer.py переписан под склейку, test_worker.py — склейка в
raw, ошибка одного файла, parse_error в warnings, все ошибочны, реальные aep_3+aep_3_ofr и
aep_6, settle и таймаут ненастоявшегося набора; SUCCESS_ANSWER — в форме реального успеха.
Интеграционный test_roundtrip.py минимально переведён на формат склейки (прогон — Task 5).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
102 lines
4.5 KiB
Python
102 lines
4.5 KiB
Python
"""Склейка набора файлов ответа ИС в один ответ (spec image-recognition-gateway «Сырой ответ…», D-4, R-24).
|
||
|
||
`merge_answers(job_id, files)` разбирает каждый файл `json.loads` независимо: не JSON — `response = null` и
|
||
`parse_error`, задание не падает; файлы — по возрастанию имени. `dump_merged` — байты объекта `raw/{job_id}.json`.
|
||
"""
|
||
|
||
import json
|
||
import shutil
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from src.gateway.hotfolder import AnswerFile
|
||
from src.gateway.merge import dump_merged, merge_answers
|
||
|
||
API_DIR = Path(__file__).resolve().parents[2]
|
||
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
|
||
JOB = "3f1c2b7a-0000-4000-8000-000000000001"
|
||
|
||
|
||
def test_structure_for_one_file() -> None:
|
||
merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", b'{"file_uid": "x", "error": {"code": "", "message": ""}}')])
|
||
assert merged == {
|
||
"job_id": JOB,
|
||
"files": [
|
||
{
|
||
"name": f"{JOB}.json",
|
||
"response": {"file_uid": "x", "error": {"code": "", "message": ""}},
|
||
"parse_error": None,
|
||
}
|
||
],
|
||
}
|
||
|
||
|
||
def test_files_are_ordered_by_name() -> None:
|
||
files = [
|
||
AnswerFile(f"{JOB}_ofr.json", b'{"n": 2}'),
|
||
AnswerFile("0.json", b'{"n": 0}'),
|
||
AnswerFile(f"{JOB}.json", b'{"n": 1}'),
|
||
]
|
||
merged = merge_answers(JOB, files)
|
||
assert [f["name"] for f in merged["files"]] == ["0.json", f"{JOB}.json", f"{JOB}_ofr.json"]
|
||
assert [f["response"] for f in merged["files"]] == [{"n": 0}, {"n": 1}, {"n": 2}]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"data",
|
||
[b"", b"not json", b'{"a": ', b"\xff\xfe<html>", b"[" * 100_000],
|
||
ids=["empty", "text", "truncated", "bad_utf8", "deep"],
|
||
)
|
||
def test_invalid_json_becomes_parse_error_and_others_are_parsed(data: bytes) -> None:
|
||
files = [AnswerFile(f"{JOB}.json", b'{"ok": true}'), AnswerFile(f"{JOB}_ofr.json", data)]
|
||
merged = merge_answers(JOB, files)
|
||
first, second = merged["files"]
|
||
assert first == {"name": f"{JOB}.json", "response": {"ok": True}, "parse_error": None}
|
||
assert second["name"] == f"{JOB}_ofr.json"
|
||
assert second["response"] is None
|
||
assert isinstance(second["parse_error"], str)
|
||
assert second["parse_error"]
|
||
|
||
|
||
@pytest.mark.parametrize("data", [b"[]", b'"text"', b"42", b"null"])
|
||
def test_valid_json_of_any_root_is_kept_as_is(data: bytes) -> None:
|
||
"""Корень не объект — это забота нормализатора (`INVALID_FORMAT` файла), склейка хранит разобранное как есть."""
|
||
[entry] = merge_answers(JOB, [AnswerFile(f"{JOB}.json", data)])["files"]
|
||
assert entry["response"] == json.loads(data)
|
||
assert entry["parse_error"] is None
|
||
|
||
|
||
def test_empty_set_gives_empty_files() -> None:
|
||
assert merge_answers(JOB, []) == {"job_id": JOB, "files": []}
|
||
|
||
|
||
def test_dump_is_utf8_json_that_round_trips() -> None:
|
||
merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", '{"m": "Не удалось"}'.encode())])
|
||
data = dump_merged(merged)
|
||
assert "Не удалось".encode() in data # кириллица читаема в S3, не \\uXXXX
|
||
assert json.loads(data) == merged
|
||
|
||
|
||
def test_dump_with_lone_surrogate_is_valid_utf8() -> None:
|
||
"""`\\ud800` в ответе ИС разбирается в одиночный суррогат: объект всё равно записывается валидным UTF-8."""
|
||
merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", b'{"m": "bad \\ud800 tail"}')])
|
||
data = dump_merged(merged)
|
||
data.decode("utf-8")
|
||
assert json.loads(data) == merged
|
||
|
||
|
||
@pytest.mark.fixtures
|
||
def test_real_fixtures_are_merged(tmp_path: Path) -> None:
|
||
if not REAL_FIXTURES.is_dir():
|
||
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
|
||
for name in ("aep_3.json", "aep_3_ofr.json"):
|
||
shutil.copy(REAL_FIXTURES / name, tmp_path / name)
|
||
files = [AnswerFile(p.name, p.read_bytes()) for p in sorted(tmp_path.iterdir())]
|
||
merged = merge_answers(JOB, files)
|
||
assert [f["name"] for f in merged["files"]] == ["aep_3.json", "aep_3_ofr.json"]
|
||
assert all(f["parse_error"] is None for f in merged["files"])
|
||
assert merged["files"][0]["response"]["error"] == {"code": "", "message": ""}
|
||
assert merged["files"][1]["response"]["error"]["code"] == "INVALID_DOCUMENT"
|
||
assert json.loads(dump_merged(merged)) == merged
|