arm-scan-gateway/api/tests/unit/test_merge.py
Andrey Epifancev 973cf2717f Срез 0 [7.2]: склейка ответа ИС, сырой raw/ = склейка, NullNormalizer по файлам, metrics.warnings
- Исправлена критическая ошибка: 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>
2026-10-04 14:27:07 +04:00

102 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Склейка набора файлов ответа ИС в один ответ (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