Files
Video-Tree-TRM5/tests/unit/test_ocr_adapter.py
T
iomgaa 86b19d1e07 feat(adapters): OCRProvider Protocol + MonkeyOCRClient 异步适配器
- app/ports.py: 新增 OCRProvider Protocol(runtime_checkable,与
  EmbeddingProvider 同级),定义 async transcribe_frames 端口
- adapters/ocr.py: 从 TRM4 core/tree/ocr.py 保真迁移 MonkeyOCRClient
  - assert → ValueError(P5 防御性校验)
  - 公开方法改 async(asyncio.to_thread 包装同步 HTTP)
  - 内部逻辑不变:多端点轮询、线程安全 Session、单帧降级、行去重
- tests/unit/test_ocr_adapter.py: 17 个测试覆盖 Protocol 合规、
  构造校验、健康检查、转录、降级、去重、轮询

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 05:47:55 -04:00

303 lines
11 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.
"""MonkeyOCRClient 适配器单元测试。
覆盖范围:Protocol 合规性、单帧转录、失败降级、健康检查、
多端点轮询、行去重过滤。使用 responses 库 mock HTTP。
"""
from __future__ import annotations
import asyncio
from pathlib import Path # noqa: TC003 — pytest tmp_path fixture 类型注解需运行时
import pytest
import requests
import responses
from adapters.ocr import MonkeyOCRClient
from app.ports import OCRProvider
# ---------------------------------------------------------------------------
# Protocol 合规性
# ---------------------------------------------------------------------------
class TestOCRProviderProtocol:
"""MonkeyOCRClient 必须满足 OCRProvider Protocol。"""
def test_is_runtime_checkable_instance(self) -> None:
"""MonkeyOCRClient 实例应通过 isinstance(_, OCRProvider) 检查。"""
client = MonkeyOCRClient(urls=["http://localhost:7866"])
assert isinstance(client, OCRProvider)
def test_has_transcribe_frames(self) -> None:
"""MonkeyOCRClient 必须暴露 transcribe_frames 方法。"""
assert hasattr(MonkeyOCRClient, "transcribe_frames")
# ---------------------------------------------------------------------------
# 构造函数校验
# ---------------------------------------------------------------------------
class TestConstructor:
"""构造函数参数校验。"""
def test_empty_urls_raises_value_error(self) -> None:
"""空端点列表应抛 ValueErrorP5:不用 assert)。"""
with pytest.raises(ValueError, match="端点列表不能为空"):
MonkeyOCRClient(urls=[])
def test_trailing_slash_stripped(self) -> None:
"""URL 尾部斜杠应被去除。"""
client = MonkeyOCRClient(urls=["http://host:7866/"])
assert client._urls == ["http://host:7866"]
# ---------------------------------------------------------------------------
# 健康检查
# ---------------------------------------------------------------------------
class TestCheckHealth:
"""check_health 预检所有端点。"""
@responses.activate
def test_healthy_endpoints(self) -> None:
"""所有端点返回 200 → 无异常。"""
url = "http://10.0.0.1:7866"
responses.add(responses.GET, f"{url}/health", status=200)
client = MonkeyOCRClient(urls=[url])
asyncio.get_event_loop().run_until_complete(client.check_health())
@responses.activate
def test_unhealthy_endpoint_raises(self) -> None:
"""端点返回 500 → RuntimeError。"""
url = "http://10.0.0.1:7866"
responses.add(responses.GET, f"{url}/health", status=500)
client = MonkeyOCRClient(urls=[url])
with pytest.raises(RuntimeError, match="健康检查失败"):
asyncio.get_event_loop().run_until_complete(client.check_health())
@responses.activate
def test_unreachable_endpoint_raises(self) -> None:
"""端点连接失败 → RuntimeError。"""
url = "http://10.0.0.1:7866"
responses.add(
responses.GET,
f"{url}/health",
body=requests.ConnectionError("refused"),
)
client = MonkeyOCRClient(urls=[url])
with pytest.raises(RuntimeError, match="端点不可达"):
asyncio.get_event_loop().run_until_complete(client.check_health())
@responses.activate
def test_multiple_endpoints_all_checked(self) -> None:
"""多端点时全部预检,任一失败即报错。"""
url_a = "http://10.0.0.1:7866"
url_b = "http://10.0.0.2:7866"
responses.add(responses.GET, f"{url_a}/health", status=200)
responses.add(responses.GET, f"{url_b}/health", status=503)
client = MonkeyOCRClient(urls=[url_a, url_b])
with pytest.raises(RuntimeError, match="健康检查失败"):
asyncio.get_event_loop().run_until_complete(client.check_health())
# ---------------------------------------------------------------------------
# 单帧转录
# ---------------------------------------------------------------------------
class TestTranscribeFrames:
"""transcribe_frames 核心逻辑。"""
@responses.activate
def test_single_frame(self, tmp_path: Path) -> None:
"""单帧正常转录 → '帧1: line_a | line_b' 格式。"""
url = "http://ocr:7866"
responses.add(
responses.POST,
f"{url}/ocr/text",
json={"content": "Hello World\nOCR Test"},
status=200,
)
frame = tmp_path / "frame_001.jpg"
frame.write_bytes(b"\xff\xd8\xff\xe0fake")
client = MonkeyOCRClient(urls=[url])
result = asyncio.get_event_loop().run_until_complete(client.transcribe_frames([frame]))
assert result == "帧1: Hello World | OCR Test"
@responses.activate
def test_multiple_frames(self, tmp_path: Path) -> None:
"""多帧转录 → 每帧一行,帧号递增。"""
url = "http://ocr:7866"
responses.add(
responses.POST,
f"{url}/ocr/text",
json={"content": "Line A"},
status=200,
)
responses.add(
responses.POST,
f"{url}/ocr/text",
json={"content": "Line B"},
status=200,
)
frames = []
for i in range(2):
f = tmp_path / f"frame_{i}.jpg"
f.write_bytes(b"\xff\xd8data")
frames.append(f)
client = MonkeyOCRClient(urls=[url])
result = asyncio.get_event_loop().run_until_complete(client.transcribe_frames(frames))
assert "帧1: Line A" in result
assert "帧2: Line B" in result
@responses.activate
def test_empty_frames_returns_empty(self) -> None:
"""空帧列表 → 空串。"""
client = MonkeyOCRClient(urls=["http://ocr:7866"])
result = asyncio.get_event_loop().run_until_complete(client.transcribe_frames([]))
assert result == ""
# ---------------------------------------------------------------------------
# 失败降级
# ---------------------------------------------------------------------------
class TestFailureDegradation:
"""单帧失败跳过,不影响其余帧。"""
@responses.activate
def test_single_frame_failure_returns_empty(self, tmp_path: Path) -> None:
"""唯一帧请求失败 → 返回空串(不抛异常)。"""
url = "http://ocr:7866"
responses.add(responses.POST, f"{url}/ocr/text", status=500)
frame = tmp_path / "frame.jpg"
frame.write_bytes(b"\xff\xd8data")
client = MonkeyOCRClient(urls=[url])
result = asyncio.get_event_loop().run_until_complete(client.transcribe_frames([frame]))
assert result == ""
@responses.activate
def test_partial_failure_skips_bad_frame(self, tmp_path: Path) -> None:
"""第一帧失败、第二帧成功 → 仅输出第二帧。"""
url = "http://ocr:7866"
responses.add(responses.POST, f"{url}/ocr/text", status=500)
responses.add(
responses.POST,
f"{url}/ocr/text",
json={"content": "Good"},
status=200,
)
frames = []
for i in range(2):
f = tmp_path / f"frame_{i}.jpg"
f.write_bytes(b"\xff\xd8data")
frames.append(f)
client = MonkeyOCRClient(urls=[url])
result = asyncio.get_event_loop().run_until_complete(client.transcribe_frames(frames))
# 帧1 失败被跳过,帧2 成功但输出为 "帧2: Good"
assert "帧1" not in result
assert "帧2: Good" in result
# ---------------------------------------------------------------------------
# 行去重过滤
# ---------------------------------------------------------------------------
class TestLineDedup:
"""帧内行级去重与短行过滤。"""
@responses.activate
def test_duplicate_lines_removed(self, tmp_path: Path) -> None:
"""帧内重复行只保留首次出现。"""
url = "http://ocr:7866"
responses.add(
responses.POST,
f"{url}/ocr/text",
json={"content": "重复行\n重复行\n不同行"},
status=200,
)
frame = tmp_path / "frame.jpg"
frame.write_bytes(b"\xff\xd8data")
client = MonkeyOCRClient(urls=[url])
result = asyncio.get_event_loop().run_until_complete(client.transcribe_frames([frame]))
assert result == "帧1: 重复行 | 不同行"
@responses.activate
def test_single_char_lines_filtered(self, tmp_path: Path) -> None:
"""单字符行被过滤(长度 <= 1)。"""
url = "http://ocr:7866"
responses.add(
responses.POST,
f"{url}/ocr/text",
json={"content": "A\nAB\n.\nCD"},
status=200,
)
frame = tmp_path / "frame.jpg"
frame.write_bytes(b"\xff\xd8data")
client = MonkeyOCRClient(urls=[url])
result = asyncio.get_event_loop().run_until_complete(client.transcribe_frames([frame]))
# "A" 和 "." 被过滤(长度 <= 1),保留 "AB" 和 "CD"
assert result == "帧1: AB | CD"
@responses.activate
def test_empty_content_skipped(self, tmp_path: Path) -> None:
"""OCR 返回空 content → 该帧跳过。"""
url = "http://ocr:7866"
responses.add(
responses.POST,
f"{url}/ocr/text",
json={"content": ""},
status=200,
)
frame = tmp_path / "frame.jpg"
frame.write_bytes(b"\xff\xd8data")
client = MonkeyOCRClient(urls=[url])
result = asyncio.get_event_loop().run_until_complete(client.transcribe_frames([frame]))
assert result == ""
# ---------------------------------------------------------------------------
# 多端点轮询
# ---------------------------------------------------------------------------
class TestRoundRobin:
"""多端点轮询行为。"""
@responses.activate
def test_round_robin_alternation(self, tmp_path: Path) -> None:
"""两端点交替使用。"""
url_a = "http://host-a:7866"
url_b = "http://host-b:7866"
# 为两个端点各注册响应
responses.add(
responses.POST,
f"{url_a}/ocr/text",
json={"content": "From A"},
status=200,
)
responses.add(
responses.POST,
f"{url_b}/ocr/text",
json={"content": "From B"},
status=200,
)
frames = []
for i in range(2):
f = tmp_path / f"frame_{i}.jpg"
f.write_bytes(b"\xff\xd8data")
frames.append(f)
client = MonkeyOCRClient(urls=[url_a, url_b])
result = asyncio.get_event_loop().run_until_complete(client.transcribe_frames(frames))
assert "帧1: From A" in result
assert "帧2: From B" in result
# 验证两个端点都被调用
called_urls = [c.request.url for c in responses.calls]
assert any(url_a in u for u in called_urls)
assert any(url_b in u for u in called_urls)