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
Binary file not shown.
+545
View File
@@ -0,0 +1,545 @@
"""nodes/whisper.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/whisper.py`(转写:模型解析 + 分块 + 时间轴合并 + 幻觉清洗
入口),可独立调用。模型推理属允许替身的 I/O 边界:单元用例注入结构真实的
假模型/假 ffmpeg;集成用例使用真实 faster-whisper 模型与真实语音。
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import wave
from pathlib import Path
import pytest
from nodes.ffmpeg import _ffmpeg_bin
from nodes.whisper import (
_append_srt_lines,
_is_windows,
_load_cuda_libraries,
_local_model_candidates,
_wav_duration_seconds,
format_timestamp,
invoke,
resolve_model_path,
)
from wov_sdk.models import InvokeRequest
# 模块专用真实素材:60 秒真实语音(16kHz 单声道 WAV)。
DATA_DIR = Path(__file__).resolve().parent / "data"
SPEECH_WAV = DATA_DIR / "speech_60s.wav"
class FakeSegment:
"""结构真实的 whisper 分段替身(start/end/text 与真实段一致)。"""
def __init__(self, start: float, end: float, text: str) -> None:
self.start = start
self.end = end
self.text = text
class FakeInfo:
"""结构真实的转写信息替身(含 language 字段)。"""
def __init__(self, language: str = "ja") -> None:
self.language = language
class FakeModel:
"""结构真实的假模型:按预置分段返回,并记录每次调用的参数。"""
def __init__(self, segments: list[FakeSegment]) -> None:
self._segments = segments
self.calls: list[dict] = []
def transcribe(self, audio, **kwargs):
self.calls.append({"audio": str(audio), **kwargs})
return iter(self._segments), FakeInfo()
def _request(tmp_path: Path, audio: Path | None, **params) -> InvokeRequest:
"""构造真实请求;audio 为 None 时表示不传 audio_uri。"""
inputs = {} if audio is None else {"audio_uri": str(audio)}
return InvokeRequest(
run_id="run-test",
node_instance_id="whisper-1",
params=params,
inputs=inputs,
output_dir=str(tmp_path / "out"),
)
def _inject_model(monkeypatch, model: FakeModel) -> None:
"""把假模型注入到 faster_whisper.WhisperModelI/O 边界替身)。
节点在 invoke 内部 `from faster_whisper import WhisperModel` 延迟导入,
因此必须 patch 库模块上的名字,才能让真实调用路径拿到假模型。
"""
import faster_whisper
monkeypatch.setattr(faster_whisper, "WhisperModel", lambda *a, **k: model)
# 假模型不需要真实权重,屏蔽 CUDA 库预加载以避免无 GPU 环境的副作用。
monkeypatch.setattr("nodes.whisper._load_cuda_libraries", lambda: None)
# ---------------------------------------------------------------------------
# 模型路径解析(本地优先)
# ---------------------------------------------------------------------------
def test_resolve_model_path_explicit_param_wins(tmp_path: Path) -> None:
"""请求参数 model_path 优先级最高。"""
# 数据:显式路径(含分隔符,按原样返回)。
explicit = str(tmp_path / "custom-model")
# 测试过程与验证结果
assert resolve_model_path({"model_path": explicit}, env={}) == explicit
def test_resolve_model_path_bare_name_resolves_locally(tmp_path: Path) -> None:
"""裸模型名在本地 model/ 目录下解析(存在 model.bin 时)。"""
# 数据:构造 <模型目录>/<名称>/model.bin。
models_root = tmp_path / "model"
target = models_root / "my-model"
target.mkdir(parents=True)
(target / "model.bin").write_bytes(b"weights")
candidates = [models_root / "faster-whisper-large-v2"]
# 测试过程
resolved = resolve_model_path({"model_path": "my-model"}, env={}, candidates=candidates)
# 验证结果:解析到本地目录。
assert resolved == str(target)
def test_resolve_model_path_env_used_when_no_param(tmp_path: Path) -> None:
"""无参数时使用 WHISPER_MODEL_PATH 环境变量。"""
# 数据:环境变量指向真实存在的模型目录。
model_dir = tmp_path / "env-model"
model_dir.mkdir()
(model_dir / "model.bin").write_bytes(b"w")
# 测试过程与验证结果
assert resolve_model_path({}, env={"WHISPER_MODEL_PATH": str(model_dir)}) == str(model_dir)
def test_resolve_model_path_prefers_complete_local_candidate(tmp_path: Path) -> None:
"""无参数/环境变量时使用本地候选目录(含 model.bin 才算完整)。"""
# 数据:第一个候选缺失 model.bin,第二个完整。
broken = tmp_path / "broken"
broken.mkdir()
good = tmp_path / "good"
good.mkdir()
(good / "model.bin").write_bytes(b"w")
# 测试过程
resolved = resolve_model_path({}, env={}, candidates=[broken, good])
# 验证结果:跳过不完整候选,选中完整目录。
assert resolved == str(good)
def test_resolve_model_path_falls_back_to_remote_name(tmp_path: Path) -> None:
"""全部本地候选缺失时回退到可下载的模型名。"""
# 数据:空候选目录。
empty = tmp_path / "empty"
empty.mkdir()
# 测试过程与验证结果
assert resolve_model_path({}, env={}, candidates=[empty]) == "large-v2"
def test_local_model_candidates_are_platform_paths() -> None:
"""本地候选包含单体内置模型目录(跨平台用 pathlib 表达)。"""
# 数据:无。
# 测试过程
candidates = _local_model_candidates()
# 验证结果:非空且都是 Path。
assert candidates
assert all(isinstance(p, Path) for p in candidates)
# ---------------------------------------------------------------------------
# 时间戳与 WAV 时长
# ---------------------------------------------------------------------------
def test_format_timestamp_pads_and_handles_hours() -> None:
"""时间戳格式化为 HH:MM:SS,mmm,毫秒与小时均正确。"""
# 数据:0、1.5、3661.004 秒。
# 测试过程与验证结果
assert format_timestamp(0) == "00:00:00,000"
assert format_timestamp(1.5) == "00:00:01,500"
assert format_timestamp(3661.004) == "01:01:01,004"
def test_wav_duration_from_real_header(tmp_path: Path) -> None:
"""WAV 时长按文件头精确计算(分块偏移依赖它,不能用假设块长)。"""
# 数据:3 秒合法 WAV。
path = tmp_path / "3s.wav"
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16000)
wav.writeframes(b"\x00\x00" * 16000 * 3)
# 测试过程
duration = _wav_duration_seconds(path, fallback=99.0)
# 验证结果
assert duration == pytest.approx(3.0, abs=0.01)
def test_wav_duration_falls_back_on_invalid_file(tmp_path: Path) -> None:
"""非法 WAV 时回退到给定默认值(不抛异常中断整片转写)。"""
# 数据:非 WAV 内容。
path = tmp_path / "broken.wav"
path.write_bytes(b"not a wav")
# 测试过程与验证结果
assert _wav_duration_seconds(path, fallback=42.0) == 42.0
# ---------------------------------------------------------------------------
# SRT 行追加与时间轴偏移
# ---------------------------------------------------------------------------
def test_append_srt_lines_applies_offset_and_index() -> None:
"""分块转写按块偏移平移时间轴,并延续 SRT 序号。"""
# 数据:一段本地时间 0~2 秒的分段,偏移 60 秒,起始序号 5。
segments = [FakeSegment(0.0, 2.0, "你好")]
# 测试过程
lines: list[str] = []
added = _append_srt_lines(lines, segments, offset=60.0, start_index=5)
# 验证结果:序号为 5,时间轴为 60~62 秒,返回本段新增条数 1。
assert added == 1
assert lines[0] == "5"
assert lines[1] == "00:01:00,000 --> 00:01:02,000"
assert lines[2] == "你好"
def test_append_srt_lines_strips_segment_text() -> None:
"""分段文本两端空白被去除(避免 SRT 正文带多余空格)。"""
# 数据:一条文本带首尾空格。
segments = [FakeSegment(1.0, 2.0, " 正常 ")]
# 测试过程
lines: list[str] = []
_append_srt_lines(lines, segments, offset=0.0, start_index=1)
# 验证结果:正文为去空白后的文本,序号为 1。
assert lines[0] == "1"
assert lines[2] == "正常"
def test_append_srt_lines_continues_numbering_across_chunks() -> None:
"""跨块调用时序号连续(调用方按上一块返回的条数累加)。"""
# 数据:两块各一段,第二块起始序号 = 1 + 第一块条数。
first: list[str] = []
added = _append_srt_lines(first, [FakeSegment(0, 1, "")], 0.0, 1)
second: list[str] = []
_append_srt_lines(second, [FakeSegment(0, 1, "")], 60.0, 1 + added)
# 验证结果:第二块序号为 2,时间轴带 60 秒偏移。
assert second[0] == "2"
assert second[1].startswith("00:01:00,000")
# ---------------------------------------------------------------------------
# 平台分支
# ---------------------------------------------------------------------------
def test_is_windows_flag_matches_platform() -> None:
"""_is_windows 反映当前平台(测试需同时可在 Windows 与 Linux 运行)。"""
# 数据:当前运行平台。
# 测试过程与验证结果
assert _is_windows() == (os.name == "nt")
def test_load_cuda_libraries_is_noop_off_windows(tmp_path: Path, monkeypatch) -> None:
"""非 Windows 平台加载 CUDA 库为无操作(Linux 由系统/venv 提供)。"""
# 数据:强制 _is_windows 为 False。
monkeypatch.setattr("nodes.whisper._is_windows", lambda: False)
# 测试过程与验证结果:不抛异常。
_load_cuda_libraries()
def test_load_cuda_libraries_scans_site_packages_on_windows(tmp_path: Path, monkeypatch) -> None:
"""Windows 下扫描 site-packages/nvidia/*/bin 并注册 DLL 搜索目录。"""
# 数据:伪造含 cublas/cudnn/cuda_nvrtc 三个厂商 bin 目录的 site-packages。
site = tmp_path / "site-packages"
vendors = ("cublas", "cudnn", "cuda_nvrtc")
for package in vendors:
bin_dir = site / "nvidia" / package / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / f"{package}.dll").write_bytes(b"dll")
added: list[str] = []
monkeypatch.setattr("nodes.whisper._is_windows", lambda: True)
monkeypatch.setattr("nodes.whisper.sysconfig.get_paths", lambda: {"purelib": str(site)})
monkeypatch.setattr("os.add_dll_directory", added.append, raising=False)
# 测试过程
_load_cuda_libraries()
# 验证结果:三个厂商的 bin 目录都被加入 DLL 搜索路径。
assert len(added) == len(vendors)
assert all("nvidia" in path for path in added)
def test_load_cuda_libraries_skips_missing_vendor_dirs(tmp_path: Path, monkeypatch) -> None:
"""厂商目录不存在时跳过,不报错(部分轮子未安装)。"""
# 数据:只有 cublas 一个厂商目录。
site = tmp_path / "site-packages"
(site / "nvidia" / "cublas" / "bin").mkdir(parents=True)
added: list[str] = []
monkeypatch.setattr("nodes.whisper._is_windows", lambda: True)
monkeypatch.setattr("nodes.whisper.sysconfig.get_paths", lambda: {"purelib": str(site)})
monkeypatch.setattr("os.add_dll_directory", added.append, raising=False)
# 测试过程
_load_cuda_libraries()
# 验证结果:只注册存在的那一个。
assert len(added) == 1
# ---------------------------------------------------------------------------
# invoke:分块转写与合并(假模型)
# ---------------------------------------------------------------------------
def test_invoke_transcribes_with_fake_model_and_writes_srt(tmp_path: Path, monkeypatch) -> None:
"""invoke 调用模型转写并写出 SRT(结构真实的分段替身)。"""
# 数据:真实 WAV 输入 + 假模型返回两段。
assert SPEECH_WAV.is_file(), f"缺少测试素材 {SPEECH_WAV}"
model = FakeModel([FakeSegment(0.0, 2.0, "第一句"), FakeSegment(2.5, 4.0, "第二句")])
_inject_model(monkeypatch, model)
# 测试过程
response = invoke(_request(tmp_path, SPEECH_WAV, chunk_seconds=0))
# 验证结果:产物存在且含两段文本与时间轴。
assert response.status == "completed", response.error
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "第一句" in content and "第二句" in content
assert "00:00:00,000 --> 00:00:02,000" in content
assert content.index("第一句") < content.index("第二句")
def test_invoke_passes_params_to_model(tmp_path: Path, monkeypatch) -> None:
"""节点参数透传到模型调用(language/task/beam_size 等)。"""
# 数据:指定语言与任务。
model = FakeModel([FakeSegment(0.0, 1.0, "x")])
_inject_model(monkeypatch, model)
# 测试过程
invoke(_request(
tmp_path, SPEECH_WAV, chunk_seconds=0,
language="ja", task="translate", beam_size=3, vad_filter=False,
condition_on_previous_text=False,
))
# 验证结果:模型收到对应参数。
call = model.calls[0]
assert call["language"] == "ja"
assert call["task"] == "translate"
assert call["beam_size"] == 3
assert call["vad_filter"] is False
def test_invoke_default_vad_and_condition_flags(tmp_path: Path, monkeypatch) -> None:
"""默认 vad_filter=True 且 condition_on_previous_text=False(防重复)。"""
# 数据:不传相关参数。
model = FakeModel([FakeSegment(0.0, 1.0, "x")])
_inject_model(monkeypatch, model)
# 测试过程
invoke(_request(tmp_path, SPEECH_WAV, chunk_seconds=0))
# 验证结果
assert model.calls[0]["vad_filter"] is True
assert model.calls[0]["condition_on_previous_text"] is False
def test_invoke_fails_without_audio_uri(tmp_path: Path) -> None:
"""缺少 audio_uri 时失败。"""
# 数据:空输入。
# 测试过程
response = invoke(_request(tmp_path, None))
# 验证结果
assert response.status == "failed"
assert "audio_uri" in (response.error or "")
def test_invoke_fails_when_audio_missing(tmp_path: Path) -> None:
"""音频文件不存在时失败。"""
# 数据:不存在的路径。
# 测试过程
response = invoke(_request(tmp_path, tmp_path / "nope.wav"))
# 验证结果
assert response.status == "failed"
assert "not found" in (response.error or "")
def test_invoke_cleans_japanese_hallucination_in_decode_full(tmp_path: Path, monkeypatch) -> None:
"""decode_full 模式下,长时日语寒暄幻觉整条删除(不留下 '-' 占位)。"""
# 数据:假模型返回一段 30 秒的"おやすみなさい"。
model = FakeModel([FakeSegment(0.0, 30.0, "おやすみなさい")])
_inject_model(monkeypatch, model)
# 测试过程
response = invoke(_request(tmp_path, SPEECH_WAV, chunk_seconds=0, decode_full=True))
# 验证结果:幻觉被删除,产物无该文本。
assert response.status == "completed", response.error
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "おやすみなさい" not in content
def test_invoke_filters_short_moan_in_decode_full(tmp_path: Path, monkeypatch) -> None:
"""decode_full 模式下短呻吟碎片被过滤,真实短对话保留。"""
# 数据:呻吟碎片 + 真实短对话。
model = FakeModel([
FakeSegment(0.0, 1.0, "あ…"),
FakeSegment(1.5, 3.0, "そこ、だめ"),
])
_inject_model(monkeypatch, model)
# 测试过程
response = invoke(_request(tmp_path, SPEECH_WAV, chunk_seconds=0, decode_full=True))
# 验证结果:呻吟被删,真实对话保留。
assert response.status == "completed", response.error
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "そこ、だめ" in content
assert "あ…" not in content
def test_invoke_keeps_moan_when_filter_disabled(tmp_path: Path, monkeypatch) -> None:
"""short_moan_max_chars=0 时关闭呻吟过滤。"""
# 数据:一条呻吟。
model = FakeModel([FakeSegment(0.0, 1.0, "あ…")])
_inject_model(monkeypatch, model)
# 测试过程
response = invoke(_request(
tmp_path, SPEECH_WAV, chunk_seconds=0, decode_full=True, short_moan_max_chars=0,
))
# 验证结果:呻吟保留。
assert response.status == "completed", response.error
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "あ…" in content
# ---------------------------------------------------------------------------
# 真实模型集成
# ---------------------------------------------------------------------------
# 已废弃的模型目录(不得用于测试):V3 已全面停用,全部改用 V2。
_DEPRECATED_MODEL_DIRS = ("faster-whisper-large-v3",)
def _v2_model_candidates() -> list[Path]:
"""返回可用的 V2 权重目录(排除已废弃的 V3)。
解析顺序:
1. `nodes.whisper` 文档化的默认候选(通用 V2 转写模型);
2. `model/` 下其它已下载的 V2 权重(例如中文直出模型)。
V3 权重已全面停用(用户 2026-09 决定:均改用 V2),即使留在盘上也不得
被测试使用,否则测的不是线上实际运行的模型。
"""
candidates = [p for p in _local_model_candidates() if p.name not in _DEPRECATED_MODEL_DIRS]
model_root = Path(__file__).resolve().parents[3] / "model"
if model_root.is_dir():
for path in sorted(model_root.iterdir()):
if not path.is_dir() or path.name in _DEPRECATED_MODEL_DIRS:
continue
# V3 的判据:preprocessor_config.json 的 feature_size == 128。
if _is_v3_weights(path):
continue
candidates.append(path)
return candidates
def _is_v3_weights(model_dir: Path) -> bool:
"""按 preprocessor_config.json 的 feature_size 判断是否为 V3 权重。
Whisper V2 的 mel 特征维度是 80,V3 是 128;这是区分两代权重的稳定判据
(目录名可能被人工改名,不能只靠名字判断)。
"""
import json
config = model_dir / "preprocessor_config.json"
if not config.is_file():
return False
try:
return int(json.loads(config.read_text(encoding="utf-8")).get("feature_size", 80)) == 128
except (ValueError, TypeError, OSError):
return False
def _real_model_available() -> Path | None:
"""返回一个可用的 V2 权重目录(缺失则返回 None 供跳过)。"""
for candidate in _v2_model_candidates():
if candidate.is_dir() and (candidate / "model.bin").is_file():
return candidate
return None
@pytest.mark.integration
def test_real_whisper_transcribes_real_speech(tmp_path: Path) -> None:
"""真实 faster-whisper 模型 + 真实语音:端到端转写产出可用 SRT。
本地无模型或素材时跳过;有则必须执行,作为假模型单测的校准。
"""
# 数据:模块 data/ 下的真实 60 秒语音。
if not SPEECH_WAV.is_file():
pytest.skip(f"缺少测试素材 {SPEECH_WAV}")
model_dir = _real_model_available()
if model_dir is None:
pytest.skip("本地没有完整 whisper 权重,跳过真实模型集成测试")
# 显存不足时跳过(真实模型推理需要显存,属外部环境状态)。
from tests.shared.gpu_memory import (
fits_with_margin,
require_gpu_memory,
require_node_result,
)
require_gpu_memory(model_dir)
# 分块路径(生产默认)会产生更多分配峰值,在临界显存卡上易触发 CUDA OOM;
# 显存充裕时走分块覆盖该路径,否则退化为整段单次推理。
chunk_seconds = 20 if fits_with_margin(model_dir) else 0
# 测试过程:真实模型转写真实语音。
response = invoke(_request(
tmp_path, SPEECH_WAV, chunk_seconds=chunk_seconds, language="ja",
model_path=str(model_dir),
))
# 验证结果:成功、产物为合法 SRT、时间轴递增且不超音频时长。
require_node_result(response, model_dir)
assert response.status == "completed", response.error
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
timelines = [line for line in content.splitlines() if "-->" in line]
assert timelines, "真实转写应产出至少一条字幕"
from tests.shared.srt_entries import parse_srt_entries
entries = parse_srt_entries(content)
starts = [e["start"] for e in entries]
assert starts == sorted(starts)
assert max(starts) <= 62.0