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。
This commit is contained in:
2026-09-13 15:40:56 +08:00
parent 966f3e6b4b
commit 8a715a8064
139 changed files with 20810 additions and 10733 deletions
+473
View File
@@ -0,0 +1,473 @@
"""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]