Files
vrsub/tests/nodes/test_llm/test_translate.py
T
cat-shark 8a715a8064 test: 按模块重写测试代码,删除旧平铺结构
按"测试规则"重写 tests/:一个模块一个目录、用例按数据→过程→验证三段书写、
不保留全局 conftest.py、测试过程只调用真实生产代码。

结构(73 个文件、30 个模块目录、477 用例):
- tests/nodes/  15 个模块目录(srt/whisper/ass/ffmpeg/frame_extract/vlm/
  subtitle_ocr/llm/llm_filter/subtitle_cleanup/subtitle_correction/
  proper_nouns/adaptive_pool/vad_profiler/echo);
- tests/app/    11 个模块目录(db/scheduler/batch/maintenance/registry/seed/
  storage/config/logging/main/routers 三组 API);
- tests/sdk/test_models、tests/web/test_crop、tests/shared(公共设施)。

测试数据随模块目录入库(tests/**/data/),删除根级 testdata/;.gitignore
的 data/ 改为 /data/,否则会连带忽略 tests/**/data/ 导致测试数据无法入库。

顺带发现并修复三个真实缺陷:
- nodes/srt.py:相邻条目缺少空行时把下一条时间轴吞进正文(静默错位),
  改为正文行遇时间戳行即报错;
- src/wov_app/scheduler.py:_file_size 只捕获 OSError,含 \x00 的产物 URI
  抛 ValueError 导致任务误判失败,改为同时捕获;
- nodes/subtitle_correction.py:生产代码依赖测试包解析 SRT,
  改用生产模块 nodes/srt.py。

真实模型/服务集成测试按外部状态跳过:新增 tests/shared/gpu_memory.py
(运行时探测显存、CUDA OOM 转跳过)与 tests/shared/llm_service.py
(无 Key / 余额 / 限流转跳过)。全量 477 passed。
2026-09-13 15:40:56 +08:00

474 lines
17 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.
"""nodes/llm.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/llm.py`LLM 翻译节点:分批请求 + 按 ID 回填),可独立调用。
网络属于允许 mock 的 I/O 边界:单元用例注入假 HTTP 响应验证请求体、ID 校验、
重试与回填;集成用例调用真实 LLM 验证真实字幕翻译质量。
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from pathlib import Path
import pytest
from nodes.llm import (
CHUNK_SIZE,
MAX_BATCH_RETRIES,
_parse_translations,
_system_prompt,
invoke,
translate_lines,
)
from wov_sdk.models import InvokeRequest
# 模块专用数据目录(真实字幕产物缺失时相关用例跳过)。
DATA_DIR = Path(__file__).resolve().parent / "data"
class _FakeHTTPResponse:
"""假的 HTTP 响应:返回预置 JSON 体(供 urlopen mock 使用)。"""
def __init__(self, payload: dict) -> None:
self._data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
def read(self) -> bytes:
return self._data
def __enter__(self):
return self
def __exit__(self, *exc) -> None:
return None
def _llm_reply(translations: list[tuple[int, str]], total_tokens: int = 42) -> _FakeHTTPResponse:
"""构造 OpenAI 兼容接口的响应体(content 为 {id,text} JSON 数组)。"""
content = json.dumps(
[{"id": i, "text": t} for i, t in translations], ensure_ascii=False
)
return _FakeHTTPResponse({
"choices": [{"message": {"content": content}}],
"usage": {"total_tokens": total_tokens},
})
def _capture_urlopen(calls: list[dict], responses: list[_FakeHTTPResponse]):
"""返回一个假 urlopen:记录请求体,按顺序返回预置响应。"""
def fake_urlopen(http_request, timeout=None):
calls.append({
"url": http_request.full_url,
"body": json.loads(http_request.data.decode("utf-8")),
"headers": dict(http_request.headers),
"timeout": timeout,
})
return responses.pop(0) if responses else _llm_reply([])
return fake_urlopen
# ---------------------------------------------------------------------------
# 提示词与响应解析(纯函数)
# ---------------------------------------------------------------------------
def test_system_prompt_mentions_target_language_and_json_contract() -> None:
"""系统提示词说明目标语言与 JSON 条目契约(时间戳不进入模型)。"""
# 数据:目标语言 zh-CN。
# 测试过程
prompt = _system_prompt("zh-CN")
# 验证结果
assert "zh-CN" in prompt
assert "id" in prompt and "text" in prompt
def test_parse_translations_accepts_out_of_order_ids() -> None:
"""乱序返回的条目按 ID 回填(不依赖数组顺序)。"""
# 数据:ID 为 3、1、2 的乱序结果。
content = json.dumps([
{"id": 3, "text": "三"},
{"id": 1, "text": "一"},
{"id": 2, "text": "二"},
])
# 测试过程
parsed = _parse_translations(content, {1, 2, 3})
# 验证结果
assert parsed == {1: "一", 2: "二", 3: "三"}
def test_parse_translations_rejects_missing_id() -> None:
"""缺少任一 ID 时明确报错(不允许静默漏译导致时间轴错位)。"""
# 数据:缺少 ID 2。
content = json.dumps([{"id": 1, "text": "一"}, {"id": 3, "text": "三"}])
# 测试过程与验证结果
with pytest.raises(ValueError, match="missing"):
_parse_translations(content, {1, 2, 3})
def test_parse_translations_rejects_duplicate_id() -> None:
"""重复 ID 报错。"""
# 数据:ID 1 出现两次。
content = json.dumps([{"id": 1, "text": "一"}, {"id": 1, "text": "壹"}])
# 测试过程与验证结果
with pytest.raises(ValueError, match="duplicate"):
_parse_translations(content, {1})
def test_parse_translations_rejects_empty_text() -> None:
"""空正文或非字符串正文报错。"""
# 数据:空字符串与数字正文。
# 测试过程与验证结果
with pytest.raises(ValueError, match="empty or invalid"):
_parse_translations(json.dumps([{"id": 1, "text": " "}]), {1})
with pytest.raises(ValueError, match="empty or invalid"):
_parse_translations(json.dumps([{"id": 1, "text": 3}]), {1})
def test_parse_translations_rejects_unexpected_id() -> None:
"""返回了未请求的 ID 时报错。"""
# 数据:包含 ID 9(未请求)。
content = json.dumps([{"id": 9, "text": "九"}])
# 测试过程与验证结果
with pytest.raises(ValueError, match="invalid or duplicate"):
_parse_translations(content, {1})
def test_parse_translations_rejects_non_array_payload() -> None:
"""顶层不是数组时报错。"""
# 数据:对象形式的返回。
# 测试过程与验证结果
with pytest.raises(ValueError, match="JSON array"):
_parse_translations(json.dumps({"id": 1, "text": "一"}), {1})
def test_parse_translations_keeps_multiline_text_structure() -> None:
"""译文多行结构保留(去除纯空行,不截断 cue)。"""
# 数据:含空行的多行译文。
content = json.dumps([{"id": 1, "text": "第一行\n\n第二行"}])
# 测试过程
parsed = _parse_translations(content, {1})
# 验证结果:空行被去掉但两行都保留。
assert parsed[1] == "第一行\n第二行"
# ---------------------------------------------------------------------------
# translate_lines:请求体、ID 与重试
# ---------------------------------------------------------------------------
def test_translate_lines_sends_global_ids_and_maps_back(monkeypatch) -> None:
"""按全局位置 ID 请求翻译,并把结果按位置回填(空 cue 不请求但占位)。"""
# 数据:5 行,其中第 3 行为空(占位)。
lines = ["一", "二", "", "四", "五"]
calls: list[dict] = []
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr(
urllib.request, "urlopen",
_capture_urlopen(calls, [_llm_reply([(1, "one"), (2, "two"), (4, "four"), (5, "five")])]),
)
# 测试过程
result = translate_lines(lines, {"target_language": "en"})
# 验证结果:请求体只含非空行的全局 ID(1,2,4,5),结果按位置回填且空行保留。
sent = json.loads(calls[0]["body"]["messages"][1]["content"])
assert [item["id"] for item in sent] == [1, 2, 4, 5]
assert result == ["one", "two", "", "four", "five"]
def test_translate_lines_retries_on_structure_error(monkeypatch) -> None:
"""结构校验失败时重试,最终成功(最多 MAX_BATCH_RETRIES 次)。"""
# 数据:第一次返回缺 ID,第二次正确。
lines = ["一", "二"]
calls: list[dict] = []
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr(
urllib.request, "urlopen",
_capture_urlopen(calls, [
_llm_reply([(1, "one")]), # 缺 ID 2 → 触发重试
_llm_reply([(1, "one"), (2, "two")]), # 正确
]),
)
# 测试过程
result = translate_lines(lines, {})
# 验证结果:重试一次后成功,共发起 2 次请求。
assert result == ["one", "two"]
assert len(calls) == 2
def test_translate_lines_raises_after_retries_exhausted(monkeypatch) -> None:
"""结构错误耗尽重试后抛错(不返回错位译文)。"""
# 数据:每次都返回错误结构。
calls: list[dict] = []
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr(
urllib.request, "urlopen",
_capture_urlopen(calls, [_llm_reply([]) for _ in range(MAX_BATCH_RETRIES)]),
)
# 测试过程与验证结果
with pytest.raises(ValueError, match="alignment failed"):
translate_lines(["一"], {})
assert len(calls) == MAX_BATCH_RETRIES
def test_translate_lines_empty_input_makes_no_request(monkeypatch) -> None:
"""空输入直接返回空列表,不发请求。"""
# 数据:空列表。
calls: list[dict] = []
monkeypatch.setattr(urllib.request, "urlopen", _capture_urlopen(calls, []))
# 测试过程与验证结果
assert translate_lines([], {}) == []
assert calls == []
def test_translate_lines_all_empty_cues_make_no_request(monkeypatch) -> None:
"""全部为空 cue 时不请求模型,返回等长空列表。"""
# 数据:3 个空行。
calls: list[dict] = []
monkeypatch.setattr(urllib.request, "urlopen", _capture_urlopen(calls, []))
# 测试过程与验证结果
assert translate_lines(["", " ", ""], {}) == ["", "", ""]
assert calls == []
def test_translate_lines_batches_by_chunk_size(monkeypatch) -> None:
"""超过 CHUNK_SIZE 行时分批请求(每批最多 CHUNK_SIZE 条)。"""
# 数据:CHUNK_SIZE + 1 行。
total = CHUNK_SIZE + 1
lines = [f"行{i}" for i in range(1, total + 1)]
calls: list[dict] = []
def fake_urlopen(http_request, timeout=None):
body = json.loads(http_request.data.decode("utf-8"))
calls.append(body)
items = json.loads(body["messages"][1]["content"])
return _llm_reply([(item["id"], f"t{item['id']}") for item in items])
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
# 测试过程
result = translate_lines(lines, {})
# 验证结果:两批(CHUNK_SIZE + 1),结果等长且顺序正确。
assert len(calls) == 2
assert len(result) == total
assert result[0] == "t1" and result[-1] == f"t{total}"
def test_translate_lines_injects_proper_noun_rule(monkeypatch) -> None:
"""本批原文命中专名时,系统提示词追加规则(不被硬译)。"""
# 数据:含专名 ジンゴ 的一行。
calls: list[dict] = []
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr(
urllib.request, "urlopen",
_capture_urlopen(calls, [_llm_reply([(1, "译文")])]),
)
# 测试过程
translate_lines(["ジンゴがここで味わいませんか"], {})
# 验证结果:系统提示词包含该专名与禁止硬译的说明。
system_prompt = calls[0]["body"]["messages"][0]["content"]
assert "ジンゴ" in system_prompt
assert "芒果" in system_prompt
def test_translate_lines_default_model_and_env_override(monkeypatch) -> None:
"""默认模型来自 LLM_MODEL 环境变量(数据驱动,不改代码切换模型)。"""
# 数据:设置环境变量为自定义模型。
calls: list[dict] = []
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setenv("LLM_MODEL", "自定义/模型")
monkeypatch.setattr(
urllib.request, "urlopen",
_capture_urlopen(calls, [_llm_reply([(1, "x")])]),
)
# 测试过程
translate_lines(["一"], {})
# 验证结果:请求体使用环境变量指定的模型。
assert calls[0]["body"]["model"] == "自定义/模型"
def test_translate_lines_param_model_wins_over_env(monkeypatch) -> None:
"""节点参数 model 优先于环境变量。"""
# 数据:环境变量与参数都设置。
calls: list[dict] = []
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setenv("LLM_MODEL", "env/模型")
monkeypatch.setattr(
urllib.request, "urlopen",
_capture_urlopen(calls, [_llm_reply([(1, "x")])]),
)
# 测试过程
translate_lines(["一"], {"model": "param/模型"})
# 验证结果
assert calls[0]["body"]["model"] == "param/模型"
def test_translate_lines_uses_timeout_env(monkeypatch) -> None:
"""LLM_TIMEOUT_SECONDS 决定请求超时(默认 600)。"""
# 数据:设置 45 秒。
calls: list[dict] = []
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setenv("LLM_TIMEOUT_SECONDS", "45")
monkeypatch.setattr(
urllib.request, "urlopen",
_capture_urlopen(calls, [_llm_reply([(1, "x")])]),
)
# 测试过程
translate_lines(["一"], {})
# 验证结果
assert calls[0]["timeout"] == 45.0
def test_translate_lines_sends_bearer_key(monkeypatch) -> None:
"""请求头带 Bearer Key(来自 LLM_API_KEY)。"""
# 数据:设置 key。
calls: list[dict] = []
monkeypatch.setenv("LLM_API_KEY", "sk-abc")
monkeypatch.setattr(
urllib.request, "urlopen",
_capture_urlopen(calls, [_llm_reply([(1, "x")])]),
)
# 测试过程
translate_lines(["一"], {})
# 验证结果
headers = {k.lower(): v for k, v in calls[0]["headers"].items()}
assert headers.get("authorization") == "Bearer sk-abc"
# ---------------------------------------------------------------------------
# invoke 全流程
# ---------------------------------------------------------------------------
def test_invoke_translates_srt_and_writes_artifact(monkeypatch, tmp_path: Path) -> None:
"""invoke 解析 SRT → 翻译 → 写出 cn_srt,时间轴保持原样。"""
# 数据:两条真实 SRT。
srt = "1\n00:00:01,000 --> 00:00:02,000\nこんにちは\n\n2\n00:00:03,000 --> 00:00:04,000\nさようなら\n"
srt_path = tmp_path / "in.srt"
srt_path.write_text(srt, encoding="utf-8")
calls: list[dict] = []
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr(
urllib.request, "urlopen",
_capture_urlopen(calls, [_llm_reply([(1, "你好"), (2, "再见")])]),
)
request = InvokeRequest(
run_id="r", node_instance_id="n", params={},
inputs={"srt_uri": str(srt_path)}, output_dir=str(tmp_path / "out"),
)
# 测试过程
response = invoke(request)
# 验证结果:状态、产物时间轴与译文顺序。
assert response.status == "completed", response.error
content = Path(response.outputs["cn_srt_uri"]).read_text(encoding="utf-8")
assert "00:00:01,000 --> 00:00:02,000" in content
assert "你好" in content and "再见" in content
assert content.index("你好") < content.index("再见")
def test_invoke_fails_without_input(tmp_path: Path) -> None:
"""缺少 srt_uri 时失败。"""
# 数据:空输入。
request = InvokeRequest(
run_id="r", node_instance_id="n", params={}, inputs={}, output_dir=str(tmp_path)
)
# 测试过程
response = invoke(request)
# 验证结果
assert response.status == "failed"
assert "srt_uri" in (response.error or "")
def test_invoke_fails_when_input_missing(tmp_path: Path) -> None:
"""输入文件不存在时失败。"""
# 数据:不存在的路径。
request = InvokeRequest(
run_id="r", node_instance_id="n", params={},
inputs={"srt_uri": str(tmp_path / "nope.srt")}, output_dir=str(tmp_path),
)
# 测试过程
response = invoke(request)
# 验证结果
assert response.status == "failed"
assert "not found" in (response.error or "")
def test_invoke_reports_failure_on_llm_error(monkeypatch, tmp_path: Path) -> None:
"""LLM 报错时节点返回 failed(不产出半成品产物)。"""
# 数据:urlopen 抛 HTTPError。
srt_path = tmp_path / "in.srt"
srt_path.write_text("1\n00:00:01,000 --> 00:00:02,000\nこんにちは\n", encoding="utf-8")
monkeypatch.setenv("LLM_API_KEY", "sk-test")
def fake_urlopen(http_request, timeout=None):
raise urllib.error.HTTPError(http_request.full_url, 500, "server error", {}, None)
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
request = InvokeRequest(
run_id="r", node_instance_id="n", params={},
inputs={"srt_uri": str(srt_path)}, output_dir=str(tmp_path / "out"),
)
# 测试过程
response = invoke(request)
# 验证结果
assert response.status == "failed"
# ---------------------------------------------------------------------------
# 真实 LLM 集成(需要 LLM_API_KEY 与网络)
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_real_llm_translates_short_lines_with_domain_rules() -> None:
"""真实 LLM 校准:短句专名/隐语不按字面直译(真实 API,外部状态缺失则跳过)。"""
# 数据:含专名 ジンゴ 的短句;Key 与账号可用性由共享判定处理。
from nodes.llm import translate_lines as real_translate
from tests.shared.llm_service import require_llm_credentials, skip_on_service_unavailable
require_llm_credentials()
# 测试过程
with skip_on_service_unavailable():
out = real_translate(["ジンゴがここで味わいませんか"], {"target_language": "zh-CN"})
# 验证结果:有译文且未把专名硬译成"芒果"。
assert out and out[0].strip()
assert "芒果" not in out[0]