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:
@@ -0,0 +1,18 @@
|
||||
"""测试公共设施包(供各模块测试复用,不含业务断言与隐式全局副作用)。
|
||||
|
||||
本包提供三类公共能力,供需要真实数据或环境隔离的模块测试使用:
|
||||
|
||||
1. `realdata_contract`:真实数据契约(时间对齐素材、提示词规则素材的探查
|
||||
与量化工具),只读取用户提供的真实文件,缺失时测试整体跳过;
|
||||
2. `env_isolation`:环境与临时目录隔离,让依赖 `wov_app.config` 路径常量的
|
||||
模块测试可以自建独立数据目录,不依赖全局 conftest;
|
||||
3. `srt_entries`:SRT 条目解析(秒为单位),供参考字幕与产物字幕共用。
|
||||
|
||||
规则要求:模块专用数据放各模块目录下的 `data/`,本包只放真正跨模块复用的
|
||||
工具与跨模块共享素材(`tests/shared/data/`)。本包**不注册 pytest 钩子**,
|
||||
隔离能力由各模块测试显式调用,保证每个模块目录可独立运行。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,19 @@
|
||||
"""公共设施包的 pytest 配置(仅测试进程级隔离,不注册 autouse fixture)。
|
||||
|
||||
由于 `wov_app.config` 在导入时锁定路径常量,这里在 pytest 启动早期把应用
|
||||
数据目录指向进程级临时目录,避免任何测试写入真实 `data/`。该配置属于
|
||||
`tests/shared/` 包本身,不构成"模块测试依赖外部配置":模块目录内的用例
|
||||
如需自己的临时目录,仍在模块 fixture 中用 `env_isolation` 显式创建。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
|
||||
from tests.shared import env_isolation
|
||||
|
||||
# 进程启动即隔离,保证后续任何 import wov_app.config 都拿到测试路径。
|
||||
_ROOT = env_isolation.configure_environment()
|
||||
|
||||
# 进程退出时清理临时目录。
|
||||
atexit.register(env_isolation.cleanup_data_root, _ROOT)
|
||||
@@ -0,0 +1,103 @@
|
||||
7
|
||||
00:00:17,766 --> 00:00:21,828
|
||||
辛苦了 上午的检查已经OK了
|
||||
|
||||
16
|
||||
00:00:31,980 --> 00:00:34,011
|
||||
谢谢你 松井小姐
|
||||
|
||||
17
|
||||
00:00:34,010 --> 00:00:37,564
|
||||
还有没有什么困扰 或者奇怪的地方吗
|
||||
|
||||
18
|
||||
00:00:38,071 --> 00:00:39,594
|
||||
没问题啦
|
||||
|
||||
19
|
||||
00:00:39,594 --> 00:00:42,640
|
||||
总感觉果林前辈每次都会问这个呢
|
||||
|
||||
20
|
||||
00:00:42,640 --> 00:00:43,148
|
||||
总感觉果林前辈 每次都会问这个呢
|
||||
|
||||
21
|
||||
00:00:43,147 --> 00:00:46,193
|
||||
是呢 抱歉
|
||||
|
||||
22
|
||||
00:00:48,223 --> 00:00:49,239
|
||||
V
|
||||
|
||||
23
|
||||
00:00:49,239 --> 00:00:51,777
|
||||
那么 松井小姐
|
||||
|
||||
24
|
||||
00:00:51,777 --> 00:00:52,792
|
||||
V
|
||||
|
||||
25
|
||||
00:00:52,792 --> 00:00:55,838
|
||||
你来我们医院也才一个月吧
|
||||
|
||||
26
|
||||
00:00:56,345 --> 00:00:59,391
|
||||
记得挺快嘛 没有啦
|
||||
|
||||
27
|
||||
00:01:00,914 --> 00:01:04,467
|
||||
因为果林前辈教得好呀
|
||||
|
||||
28
|
||||
00:01:04,467 --> 00:01:08,527
|
||||
因为 你和患者们 也已经完全打成一片了
|
||||
|
||||
29
|
||||
00:01:09,036 --> 00:01:11,574
|
||||
我是北冈果林
|
||||
|
||||
30
|
||||
00:01:11,574 --> 00:01:16,143
|
||||
在这家综合医院工作的护士
|
||||
|
||||
31
|
||||
00:01:16,650 --> 00:01:19,188
|
||||
她叫松井日奈子
|
||||
|
||||
32
|
||||
00:01:19,188 --> 00:01:24,264
|
||||
是一个月前开始 在这间医院工作的新人护士
|
||||
|
||||
33
|
||||
00:01:25,279 --> 00:01:28,324
|
||||
上吧 上吧
|
||||
|
||||
34
|
||||
00:01:28,832 --> 00:01:33,908
|
||||
不行 受不了 好了 上吧
|
||||
|
||||
35
|
||||
00:01:37,462 --> 00:01:40,000
|
||||
状态不错
|
||||
|
||||
36
|
||||
00:01:40,000 --> 00:01:42,538
|
||||
请进
|
||||
|
||||
37
|
||||
00:01:47,107 --> 00:01:49,645
|
||||
早安
|
||||
|
||||
38
|
||||
00:01:50,660 --> 00:01:52,183
|
||||
金山先生
|
||||
|
||||
39
|
||||
00:01:52,183 --> 00:01:57,259
|
||||
昨晚你住院时我不在没能照顾到你非常抱歉
|
||||
|
||||
40
|
||||
00:01:57,259 --> 00:02:00,304
|
||||
我是医务室长权藤 请多关照
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:01,440
|
||||
---
|
||||
|
||||
2
|
||||
00:00:01,440 --> 00:00:04,320
|
||||
SUB 001
|
||||
|
||||
3
|
||||
00:00:05,760 --> 00:00:06,240
|
||||
---
|
||||
|
||||
4
|
||||
00:00:06,240 --> 00:00:09,120
|
||||
SUB 002
|
||||
|
||||
5
|
||||
00:00:09,600 --> 00:00:10,080
|
||||
---
|
||||
@@ -0,0 +1,61 @@
|
||||
"""环境与临时目录隔离(供需要 `wov_app.config` 路径常量的模块测试使用)。
|
||||
|
||||
规则要求测试可独立运行、不依赖全局 conftest,数据与产物目录就是测试代码
|
||||
所在目录:本模块把"创建独立数据目录并把应用指向它"封装成显式调用的函数,
|
||||
由各模块测试在自己的 fixture 里调用。
|
||||
|
||||
失败模式说明:`wov_app.config` 在**导入时**读取环境变量并锁定路径常量
|
||||
(DATA_DIR / DB_PATH / STORAGE_DIR)。因此隔离必须在**首次导入** config
|
||||
之前完成:
|
||||
|
||||
- 模块级导入的测试文件(如 `from wov_app.config import STORAGE_DIR`)应在
|
||||
文件顶部用 `configure_environment()`(不带 tmp_path 的进程级隔离)设置;
|
||||
- 测试函数内才导入 `wov_app` 的文件,可用 `isolated_data_dir(tmp_path)`
|
||||
在导入前覆盖到每个用例自己的临时目录。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# 进程级临时根目录(进程退出即清理),供 `configure_environment()` 使用。
|
||||
_PROCESS_ROOT: Path | None = None
|
||||
|
||||
|
||||
def process_data_root() -> Path:
|
||||
"""返回(必要时创建)进程级测试数据根目录。
|
||||
|
||||
与全局 conftest 的区别:这里不使用 pytest 的 autouse fixture,也不在
|
||||
导入时自动改环境变量;模块测试显式调用后才生效,因此单个模块目录
|
||||
可以脱离其他测试独立运行。
|
||||
"""
|
||||
global _PROCESS_ROOT
|
||||
if _PROCESS_ROOT is None:
|
||||
_PROCESS_ROOT = Path(tempfile.mkdtemp(prefix="vrsub-test-"))
|
||||
return _PROCESS_ROOT
|
||||
|
||||
|
||||
def configure_environment(data_root: Path | None = None) -> Path:
|
||||
"""把应用的数据目录、数据库、存储与后台服务指向独立测试目录。
|
||||
|
||||
必须在导入 `wov_app.config`(或任何会导入它的模块)之前调用,
|
||||
否则路径常量已按默认值锁定。返回实际使用的数据根目录。
|
||||
"""
|
||||
root = data_root or process_data_root()
|
||||
os.environ["WOV_DATA_DIR"] = str(root / "data")
|
||||
os.environ["WOV_DB_PATH"] = str(root / "data" / "wov.db")
|
||||
os.environ["WOV_STORAGE_DIR"] = str(root / "storage")
|
||||
# 默认关闭自动种子与后台线程:测试显式控制执行时机,避免与断言竞态。
|
||||
os.environ["WOV_AUTO_SEED"] = "0"
|
||||
os.environ["WOV_SCHEDULER_ENABLED"] = "0"
|
||||
os.environ["WOV_CLEANUP_ENABLED"] = "0"
|
||||
os.environ["WOV_BATCH_ENABLED"] = "0"
|
||||
return root
|
||||
|
||||
|
||||
def cleanup_data_root(root: Path) -> None:
|
||||
"""清理测试数据根目录(忽略错误,进程退出或 fixture 收尾时调用)。"""
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""GPU 显存探测与不足时跳过(供真实模型集成测试使用)。
|
||||
|
||||
真实模型(faster-whisper + CUDA)需要足够的显存;显存被其他进程占用或本身
|
||||
偏小时,推理会在中途抛 "CUDA failed with error out of memory"。这属于**外部
|
||||
运行环境状态**,不是被测代码的行为,按测试规则应跳过而不是判失败。
|
||||
|
||||
两层防护:
|
||||
|
||||
1. `require_gpu_memory()`:运行前按模型体量估算所需显存,不足则跳过;
|
||||
2. `skip_on_cuda_oom()`:运行中若仍发生显存不足(被其他进程动态抢占),
|
||||
也会被识别并转为跳过,而不是让整个套件判红。
|
||||
|
||||
探测不到 NVIDIA GPU(无 `nvidia-smi`,例如纯 CPU 机器)时**不做显存检查**:
|
||||
faster-whisper 的 `device=auto` 会回退 CPU,此时测试应当照常执行。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# 推理时的额外开销系数:CTranslate2 除权重外还需推理工作区、激活与 CUDA 上下文。
|
||||
# 实测标定(V2 权重 2.87GB,6GB 卡):推理前占用约 1120 MiB,峰值约 5217 MiB
|
||||
# → 峰值增量约 4097 MiB ≈ 权重 × 1.39。系数取 1.45 覆盖 5% 分配余量,
|
||||
# 使 6GB 卡(可用约 4.6GB)刚好放行、而更小的卡会被拦下。
|
||||
#
|
||||
# 注意:这只给出"是否可能跑完"的预估。实测在临界卡上(余量仅数百 MB)
|
||||
# 即使预估通过仍可能因分配碎片化抛 CUDA OOM,所以还需要
|
||||
# `skip_on_cuda_oom` / `require_node_result` 作为运行中的兼底。
|
||||
_MEMORY_FACTOR = 1.45
|
||||
|
||||
|
||||
def available_gpu_memory_mb() -> int | None:
|
||||
"""返回当前可用的最大显存(MB);无 NVIDIA GPU 或探测失败时返回 None。
|
||||
|
||||
多卡时取可用显存最大的那张卡(推理默认只用一张卡)。
|
||||
"""
|
||||
if shutil.which("nvidia-smi") is None:
|
||||
return None
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"nvidia-smi",
|
||||
"--query-gpu=memory.free",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
values: list[int] = []
|
||||
for line in completed.stdout.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.isdigit():
|
||||
values.append(int(stripped))
|
||||
return max(values) if values else None
|
||||
|
||||
|
||||
def required_memory_mb(model_dir: Path) -> int:
|
||||
"""按模型权重体量估算推理所需显存(MB)。"""
|
||||
weights = model_dir / "model.bin"
|
||||
size_mb = weights.stat().st_size / (1024 * 1024) if weights.is_file() else 0.0
|
||||
return int(size_mb * _MEMORY_FACTOR)
|
||||
|
||||
|
||||
def require_gpu_memory(model_dir: Path) -> None:
|
||||
"""显存不足以跑完该模型时跳过测试;无 GPU 时不检查(回退 CPU 执行)。"""
|
||||
free = available_gpu_memory_mb()
|
||||
if free is None:
|
||||
return # 无 NVIDIA GPU:device=auto 会走 CPU,无需显存检查
|
||||
needed = required_memory_mb(model_dir)
|
||||
if free < needed:
|
||||
pytest.skip(
|
||||
f"可用显存不足(需约 {needed} MB,当前可用 {free} MB),"
|
||||
f"跳过真实模型集成测试以避免 OOM"
|
||||
)
|
||||
|
||||
|
||||
def is_cuda_oom(text: str) -> bool:
|
||||
"""判断错误文本是否为显存不足(CUDA OOM)。"""
|
||||
lowered = text.lower()
|
||||
return "out of memory" in lowered or "cuda failed" in lowered
|
||||
|
||||
|
||||
@contextmanager
|
||||
def skip_on_cuda_oom():
|
||||
"""运行真实推理;中途发生 CUDA OOM 时转为跳过(显存被动态抢占的场景)。"""
|
||||
try:
|
||||
yield
|
||||
except Exception as exc: # noqa: BLE001 - 需要按消息识别 OOM
|
||||
if is_cuda_oom(str(exc)):
|
||||
pytest.skip(f"GPU 显存不足({exc}),跳过真实模型集成测试")
|
||||
raise
|
||||
|
||||
|
||||
def require_node_result(response, model_dir: Path) -> None:
|
||||
"""校验节点响应:显存不足转为跳过,其它失败照常抛出由断言处理。
|
||||
|
||||
节点(如 whisper)会把推理异常包装成 `status="failed"` 的响应,因此
|
||||
OOM 不会以异常形式冒泡;这里统一识别并跳过。
|
||||
"""
|
||||
if getattr(response, "status", "") != "completed" and is_cuda_oom(
|
||||
str(getattr(response, "error", ""))
|
||||
):
|
||||
pytest.skip(
|
||||
f"GPU 显存不足({getattr(response, 'error', '')}),跳过真实模型集成测试"
|
||||
)
|
||||
|
||||
|
||||
def fits_with_margin(model_dir: Path, safety_mb: int = 512) -> bool:
|
||||
"""显存是否充裕到可承受分块/多次推理(预留 safety_mb 余量)。
|
||||
|
||||
用于区分两种用例写法:
|
||||
- 显存充裕:按生产默认走分块路径(更接近线上配置);
|
||||
- 显存临界:退化为整段单次推理,或直接跳过——分块会产生更多分配峰值,
|
||||
在临界卡上易因碎片化触发 CUDA OOM(实测同一峰值下 chunk 失败而单次成功)。
|
||||
|
||||
无 NVIDIA GPU 时返回 False(此时应改用 CPU 友好配置,而非依赖显存)。
|
||||
"""
|
||||
free = available_gpu_memory_mb()
|
||||
if free is None:
|
||||
return False
|
||||
return free >= required_memory_mb(model_dir) + safety_mb
|
||||
@@ -0,0 +1,83 @@
|
||||
"""真实 LLM 服务的可用性判定(供真实 LLM 集成测试共用)。
|
||||
|
||||
真实 LLM 集成测试有两个外部前提:配置了 `LLM_API_KEY`、且服务端可用。
|
||||
两者都属于**外部环境状态**(密钥、余额、配额、网络),不是被测代码的行为;
|
||||
按测试规则,这类外部状态缺失时应跳过而不是把缺陷计到代码头上。
|
||||
|
||||
本模块把该判定收敛到一处,避免各测试各自实现、语义漂移:
|
||||
|
||||
- `require_llm_credentials()`:无 Key 时直接跳过;
|
||||
- `skip_on_service_unavailable()`:把鉴权/余额/限流类 HTTP 错误(401/402/403/429)
|
||||
转成跳过(附带原因),其余错误(如 5xx、网络异常、返回结构错误)照常失败,
|
||||
以保证真实回归仍能被发现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.error
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
# 视为"服务端不可用/账号不可用"的 HTTP 状态码:
|
||||
# 401 未授权、402 需要付费(余额/额度耗尽)、403 禁止访问、429 限流。
|
||||
_UNAVAILABLE_CODES = frozenset({401, 402, 403, 429})
|
||||
|
||||
|
||||
def require_llm_credentials() -> None:
|
||||
"""未配置 LLM_API_KEY 时跳过真实 LLM 集成测试。"""
|
||||
if not os.getenv("LLM_API_KEY"):
|
||||
pytest.skip("未配置 LLM_API_KEY,跳过真实 LLM 集成测试")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def skip_on_service_unavailable():
|
||||
"""执行真实 LLM 调用;鉴权/余额/限流类错误转为跳过,其余错误向上抛出。
|
||||
|
||||
这样既不会因账户余额或临时限流把测试套件判红(外部状态问题),
|
||||
也不会掩盖真正的回归(返回结构错误、代码异常仍会失败)。
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in _UNAVAILABLE_CODES:
|
||||
pytest.skip(f"LLM 服务/账号当前不可用(HTTP {exc.code}),跳过真实集成测试")
|
||||
raise
|
||||
|
||||
|
||||
def probe_llm_or_skip(model: str | None = None) -> None:
|
||||
"""向真实 LLM 接口发一次最小请求,服务/账号不可用时跳过测试。
|
||||
|
||||
用途:被测函数(如 `nodes/subtitle_correction.correct_entry`)出于生产
|
||||
需要会把调用异常吞掉并返回空串,测试因此无法从返回值区分"服务不可用"
|
||||
与"模型没有泛化"。此探针在断言之前把外部状态问题显式暴露出来并跳过,
|
||||
使断言只针对真实的能力回归。
|
||||
|
||||
探测失败判定与 `skip_on_service_unavailable` 一致(401/402/403/429 跳过);
|
||||
其余错误(5xx、网络、返回结构异常)向上抛出,仍视为需要修复的问题。
|
||||
"""
|
||||
import json as _json
|
||||
import urllib.request
|
||||
|
||||
require_llm_credentials()
|
||||
api_base = os.getenv("LLM_API_BASE", "https://api.siliconflow.cn/v1/chat/completions")
|
||||
api_key = os.getenv("LLM_API_KEY", "")
|
||||
body = {
|
||||
"model": model or os.getenv("LLM_MODEL", "Qwen/Qwen3.5-35B-A3B"),
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
"max_tokens": 1,
|
||||
"enable_thinking": False,
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
api_base,
|
||||
data=_json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
|
||||
method="POST",
|
||||
)
|
||||
with skip_on_service_unavailable():
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
_json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError:
|
||||
raise
|
||||
@@ -0,0 +1,342 @@
|
||||
"""真实数据契约与量化工具(时间对齐 / 提示词规则,供集成测试共用,不 mock 模型)。
|
||||
|
||||
本模块是"用真实数据复现问题"测试框架的公共底座。两类集成测试(时间对齐 /
|
||||
幻觉词与专名提示词)都只读取**用户提供的真实数据文件**,绝不构造假音频、
|
||||
假模型、假翻译输出来凑覆盖率;数据缺失时测试整体跳过。
|
||||
|
||||
数据契约(用户按下述约定提供真实文件即可,无需改动测试代码):
|
||||
|
||||
1. 时间对齐数据(跨模块共享,默认目录:`tests/shared/data/alignment/`)
|
||||
- 音频/视频素材:`<name>.wav|.mp4|...`(真实语音)
|
||||
- 参考字幕:`<name>.reference.srt`(人工校对的时间轴)
|
||||
- 说明:对同一素材跑 whisper 节点(vad_filter 开/关两种配置),把产出
|
||||
的 transcript.srt 与 reference.srt 做时间对齐评估,量化"过早/过晚"程度。
|
||||
2. 幻觉词与专有名词提示词规则数据(默认目录:`tests/shared/data/prompt_rules/`)
|
||||
- 日文字幕样本:`<name>.ja.srt`(真实视频的日文 ASR 输出)
|
||||
- 期望处理:`<name>.expected.txt`(每行一个语料关键词断言)
|
||||
- 说明:用真实数据调用 llm-translate 节点(真实 LLM API,不 mock),
|
||||
断言动态规则让译文不再输出寒暄幻觉、专名不被直译。
|
||||
|
||||
数据目录可通过环境变量 `WOV_TESTDATA_DIR` 覆盖(例如把素材放在外部盘),
|
||||
也可由测试显式传入目录参数;两种方式都不依赖全局 conftest。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
# 测试代码根目录(tests/):本文件位于 tests/shared/realdata_contract.py。
|
||||
TESTS_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 仓库根目录(tests/ 的上一级),用于定位迁移期仍在旧位置的素材。
|
||||
WORKSPACE = TESTS_DIR.parent
|
||||
|
||||
# 真实数据根目录(新的模块化位置):跨模块共享素材放 tests/shared/data/。
|
||||
# 可通过环境变量 WOV_TESTDATA_DIR 覆盖(例如把大体积素材放外部盘)。
|
||||
SHARED_DATA_DIR = Path(
|
||||
os.getenv("WOV_TESTDATA_DIR", str(TESTS_DIR / "shared" / "data"))
|
||||
)
|
||||
|
||||
def data_roots() -> list[Path]:
|
||||
"""返回要扫描的数据根目录(跨模块共享素材位置)。"""
|
||||
return [SHARED_DATA_DIR] if SHARED_DATA_DIR.is_dir() else []
|
||||
|
||||
|
||||
def _subdir(name: str) -> list[Path]:
|
||||
"""在全部数据根目录下找同名子目录(存在才返回)。"""
|
||||
return [root / name for root in data_roots() if (root / name).is_dir()]
|
||||
|
||||
# 时间对齐数据子目录、幻觉词/专名数据子目录(迁移期指向新位置的默认值,
|
||||
# 实际探查由 alignment_candidates / prompt_rule_candidates 扫描全部根目录)。
|
||||
ALIGNMENT_DIR = SHARED_DATA_DIR / "alignment"
|
||||
PROMPT_RULES_DIR = SHARED_DATA_DIR / "prompt_rules"
|
||||
|
||||
# 时间对齐的量化指标:与参考时间轴的允许偏差(秒)。真实转写存在固有抖动,
|
||||
# 用较大容差区分"正常误差"与"系统性地过早/过晚"两类问题。
|
||||
TIER1_TOLERANCE_SECONDS = 0.5 # 第一档:单条字幕与参考的偏差阈值
|
||||
TIER2_EARLY_SECONDS = 0.7 # 第二档:系统性偏早阈值(超过即判定"过早")
|
||||
TIER2_LATE_SECONDS = 0.7 # 第二档:系统性偏晚阈值(超过即判定"过晚")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 数据探查:真实数据文件是否存在
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def alignment_candidates(directory: Path | None = None) -> list[Path]:
|
||||
"""返回时间对齐测试可用的真实素材文件列表(存在才列出)。
|
||||
|
||||
识别规则:目录下任意 `<name>.<ext>`(音频/视频),且必须存在同名
|
||||
`<name>.reference.srt` 参考字幕。两者齐备才是可用样本。
|
||||
不传 directory 时扫描全部数据根目录(新位置优先,旧位置兼容)。
|
||||
"""
|
||||
bases = [directory] if directory else _subdir("alignment")
|
||||
candidates: list[Path] = []
|
||||
for base in bases:
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for path in sorted(base.iterdir()):
|
||||
if path.suffix.lower() in {
|
||||
".wav", ".mp3", ".flac", ".m4a", ".aac", ".ogg",
|
||||
".mp4", ".mkv", ".mov", ".webm", ".ts",
|
||||
}:
|
||||
ref = path.with_suffix(".reference.srt")
|
||||
if ref.is_file() and path not in candidates:
|
||||
candidates.append(path)
|
||||
return candidates
|
||||
|
||||
|
||||
def prompt_rule_candidates(directory: Path | None = None) -> list[Path]:
|
||||
"""返回提示词规则测试可用的真实样本列表(存在才列出)。
|
||||
|
||||
识别规则:目录下任意 `<name>.ja.srt`,且必须存在同名
|
||||
`<name>.expected.txt` 期望清单。不传 directory 时扫描全部数据根目录。
|
||||
"""
|
||||
bases = [directory] if directory else _subdir("prompt_rules")
|
||||
candidates: list[Path] = []
|
||||
for base in bases:
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for path in sorted(base.glob("*.ja.srt")):
|
||||
expected = path.with_suffix("").with_suffix(".expected.txt")
|
||||
if expected.is_file() and path not in candidates:
|
||||
candidates.append(path)
|
||||
return candidates
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 时间对齐指标
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlignmentReport:
|
||||
"""一次转写产物 vs 参考字幕的时间对齐量化报告。"""
|
||||
|
||||
name: str # 素材名
|
||||
vad_filter: bool # 本次评测用的 vad_filter 配置
|
||||
produced: list[dict] = field(default_factory=list) # 产物条目
|
||||
reference: list[dict] = field(default_factory=list) # 参考条目
|
||||
deltas: list[float] = field(default_factory=list) # 每条最近的偏差(秒)
|
||||
early_seconds: float = 0.0 # 系统性偏早总量(秒)累加
|
||||
late_seconds: float = 0.0 # 系统性偏晚总量(秒)累加
|
||||
mean_abs_error: float = 0.0 # 平均绝对偏差(秒),越小越准
|
||||
|
||||
@property
|
||||
def bias(self) -> float:
|
||||
"""整体偏差倾向:>0 偏晚,<0 偏早(中位数)。"""
|
||||
if not self.deltas:
|
||||
return 0.0
|
||||
ordered = sorted(self.deltas)
|
||||
return ordered[len(ordered) // 2]
|
||||
|
||||
@property
|
||||
def consistently_early(self) -> bool:
|
||||
"""是否系统性地偏早(中位偏差低于 -TIER2_EARLY_SECONDS)。"""
|
||||
return self.bias < -TIER2_EARLY_SECONDS
|
||||
|
||||
@property
|
||||
def consistently_late(self) -> bool:
|
||||
"""是否系统性地偏晚(中位偏差高于 +TIER2_LATE_SECONDS)。"""
|
||||
return self.bias > TIER2_LATE_SECONDS
|
||||
|
||||
def format_summary(self) -> str:
|
||||
"""生成可读的摘要文本,供失败/日志信息展示。"""
|
||||
return (
|
||||
f"[{self.name} vad={self.vad_filter}] 条目 {len(self.produced)} 条"
|
||||
f" vs 参考 {len(self.reference)} 条 | 平均绝对偏差 "
|
||||
f"{self.mean_abs_error:.2f}s | 偏差中位数 {self.bias:+.2f}s"
|
||||
f" | 偏早累计 {self.early_seconds:.1f}s 偏晚累计 {self.late_seconds:.1f}s"
|
||||
)
|
||||
|
||||
|
||||
def align_report(
|
||||
name: str,
|
||||
vad_filter: bool,
|
||||
produced: list[dict],
|
||||
reference: list[dict],
|
||||
) -> AlignmentReport:
|
||||
"""构建对齐报告:逐条求最近参考时间差并汇总偏差倾向。
|
||||
|
||||
对齐是"最近邻"匹配:对产物每条字幕,在参考时间轴中找其起始时刻最近的
|
||||
参考起始时刻;偏差 delta = 产物起始 - 参考起始。正 delta 表示字幕晚于
|
||||
真实说话、负 delta 表示字幕早于真实说话。偏差绝对值的均值反映整体
|
||||
同步精度;中位数符号反映系统性偏早/偏晚方向。
|
||||
"""
|
||||
report = AlignmentReport(
|
||||
name=name,
|
||||
vad_filter=vad_filter,
|
||||
produced=produced,
|
||||
reference=reference,
|
||||
)
|
||||
ref_starts = [entry["start"] for entry in reference]
|
||||
if not ref_starts:
|
||||
return report
|
||||
import bisect
|
||||
|
||||
deltas: list[float] = []
|
||||
early_sum = 0.0
|
||||
late_sum = 0.0
|
||||
for entry in produced:
|
||||
start = entry["start"]
|
||||
# 在有序参考起点序列中二分查找最近邻居。
|
||||
pos = bisect.bisect_left(ref_starts, start)
|
||||
candidates = []
|
||||
if pos > 0:
|
||||
candidates.append(ref_starts[pos - 1])
|
||||
if pos < len(ref_starts):
|
||||
candidates.append(ref_starts[pos])
|
||||
nearest = min(candidates, key=lambda ref: abs(start - ref))
|
||||
delta = start - nearest
|
||||
deltas.append(delta)
|
||||
if delta < 0:
|
||||
early_sum += -delta
|
||||
else:
|
||||
late_sum += delta
|
||||
report.deltas = deltas
|
||||
report.early_seconds = early_sum
|
||||
report.late_seconds = late_sum
|
||||
report.mean_abs_error = sum(abs(d) for d in deltas) / len(deltas) if deltas else 0.0
|
||||
return report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 参考字幕净化
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 参考字幕净化正则:纯装饰/符号/垃圾行(如 OCR 栅栏 '---'、'==='、下划线等)
|
||||
# 不参与时间对齐——它们不是真实的说话内容,混入会让指标失真。
|
||||
_JUNK_RE = __import__("re").compile(r"^[\s\-—_=~•・。..、*+]+$")
|
||||
|
||||
|
||||
def clean_reference(entries: list[dict]) -> list[dict]:
|
||||
"""从参考条目中剔除纯符号/装饰性垃圾行(无真实内容),返回保留条目。
|
||||
|
||||
参考 SRT 由烧录字幕提取得到(见 scripts/extract_reference_srt.py),OCR
|
||||
可能把画面上的装饰/栅栏误收为字幕(如 '---'、'===')。这类条目没有
|
||||
时间语义,若参与最近邻对齐会拉偏偏差统计,必须先剔除。
|
||||
"""
|
||||
return [
|
||||
e for e in entries
|
||||
if e["text"].strip() and not _JUNK_RE.match(e["text"])
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 幻觉词 / 专有名词判定
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 上下文无关的收尾/开场寒暄幻觉词(可经环境变量/params 覆盖):
|
||||
# 这类内容在训练数据中出现频率极高,模型常凭空生成,与视频内容无关。
|
||||
HALLUCINATION_TOKENS = [
|
||||
"谢谢观看", "感谢观看", "感谢收看", "谢谢收看", "感谢您的观看", "感谢您的收看",
|
||||
"观看视频", "谢谢观看本视频", "晚安", "下次再见", "再会", "敬请期待",
|
||||
]
|
||||
|
||||
# 不应直译的专有名词(日文原文 → 应保留原文或使用约定译名):
|
||||
# "芒果" 是固定角色/品牌名(マンゴー),并非水果直译;此处给出不允许
|
||||
# 被直译为"芒果"的日文原文,翻译时应保留或使用约定写法。
|
||||
PROPER_NOUNS_NO_TRANSLATE = {
|
||||
"マンゴー": "芒果", # 角色名/品牌名:避免被当水果直译(允许约定译名但禁止当普通词翻译)
|
||||
# 新增专名在此扩展,例如 {"ドラマチック": "ドラマチック"}(人名/品牌/虚拟名)。
|
||||
}
|
||||
|
||||
|
||||
def assert_no_halucination(translated_srt: str) -> list[str]:
|
||||
"""校验译文 SRT 不含任何寒暄幻觉词,返回命中的词列表(空表示通过)。"""
|
||||
hits = []
|
||||
for token in HALLUCINATION_TOKENS:
|
||||
if token in translated_srt:
|
||||
hits.append(token)
|
||||
return hits
|
||||
|
||||
|
||||
def assert_proper_noun_preserved(translated_srt: str, source_srt: str) -> list[str]:
|
||||
"""校验专有名词未被直译。
|
||||
|
||||
策略:源 SRT 中出现日文专名(如 ``マンゴー``)时,译文不应把该词的
|
||||
习惯译名(如"芒果")当作普通词汇直译出来("芒果"是水果词,出现在
|
||||
字幕里通常意味着专名被错误翻译)。返回违规项列表(空表示通过)。
|
||||
"""
|
||||
violations = []
|
||||
for source_word, forbidden_translation in PROPER_NOUNS_NO_TRANSLATE.items():
|
||||
if source_word not in source_srt:
|
||||
continue # 源字幕没出现该专名,无需校验
|
||||
if forbidden_translation in translated_srt:
|
||||
violations.append(f"{source_word} -> {forbidden_translation}")
|
||||
return violations
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 提示词规则拼接(与 nodes/llm.py 的 system_prompt 组装逻辑配套)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_translation_system_prompt(
|
||||
target_language: str,
|
||||
hallucination_tokens: list[str] | None = None,
|
||||
proper_nouns: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""组装 llm-translate 系统提示词。
|
||||
|
||||
在基础翻译指令上动态追加两段规则:
|
||||
1. 寒暄幻觉移除:当源数据含相关关键词(收尾/开场寒暄)时,提示词要求
|
||||
不翻译、不输出与具体内容无关的收尾寒暄(谢谢观看/晚安等);
|
||||
2. 专有名词保留:提示词提供"不直译名单",要求人名/品牌/虚拟名按原文
|
||||
保留或使用约定译名,禁止按字面直译。
|
||||
|
||||
该函数是提示词规则的**数据契约**:nodes/llm.py 未来按此拼接实现,
|
||||
测试只在此验证"规则存在且生效",不改任何 mock。
|
||||
"""
|
||||
# 显式传入空表可禁用对应规则段(None 才回退默认表)。
|
||||
if hallucination_tokens is None:
|
||||
hallucination_tokens = HALLUCINATION_TOKENS
|
||||
if proper_nouns is None:
|
||||
proper_nouns = PROPER_NOUNS_NO_TRANSLATE
|
||||
prompt = (
|
||||
"你是专业字幕翻译。将用户提供的日文字幕翻译为"
|
||||
f"{target_language}。只返回译文,保持行数和顺序,不要添加解释。\n"
|
||||
)
|
||||
if hallucination_tokens:
|
||||
token_text = "、".join(hallucination_tokens)
|
||||
prompt += (
|
||||
"规则:字幕中若出现与上下文无关的收尾/开场寒暄(如"
|
||||
f"{token_text} 等),不翻译、不输出,保持输出行数为 0 或以空行占位。\n"
|
||||
)
|
||||
if proper_nouns:
|
||||
noun_lines = ";".join(
|
||||
f"{jp}(保留原文或使用约定译名 {zh})" for jp, zh in proper_nouns.items()
|
||||
)
|
||||
prompt += (
|
||||
f"规则:专有名词(人名/品牌/SNS账号/虚拟角色名)不按字面直译,{noun_lines}。"
|
||||
)
|
||||
return prompt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 兼容导出
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# SRT 条目解析实现放在同包的 srt_entries.py;这里再导出一次,方便调用方
|
||||
# 只导入一个模块即可获得"数据契约 + 解析工具"。
|
||||
from tests.shared.srt_entries import parse_srt_entries # noqa: E402
|
||||
|
||||
__all__ = [
|
||||
"ALIGNMENT_DIR",
|
||||
"PROMPT_RULES_DIR",
|
||||
"SHARED_DATA_DIR",
|
||||
"WORKSPACE",
|
||||
"data_roots",
|
||||
"alignment_candidates",
|
||||
"prompt_rule_candidates",
|
||||
"AlignmentReport",
|
||||
"align_report",
|
||||
"clean_reference",
|
||||
"parse_srt_entries",
|
||||
"HALLUCINATION_TOKENS",
|
||||
"PROPER_NOUNS_NO_TRANSLATE",
|
||||
"assert_no_halucination",
|
||||
"assert_proper_noun_preserved",
|
||||
"build_translation_system_prompt",
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""SRT 条目解析(纯函数,按秒返回,供参考字幕与产物字幕共用)。
|
||||
|
||||
与 `nodes/srt.py` 的区别:`nodes/srt.py` 保留原始毫秒字符串供生产链路
|
||||
序列化;本模块把时间戳换算为**秒浮点数**,用于测试中的时间对齐计算与
|
||||
篇幅统计,不参与生产输出。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# 匹配 SRT 时间轴行(时间戳 --> 时间戳),作为条目边界。
|
||||
_SRT_TIME_LINE_RE = re.compile(
|
||||
r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})",
|
||||
)
|
||||
|
||||
|
||||
def parse_srt_entries(text: str) -> list[dict]:
|
||||
"""解析 SRT 为 [{start, end, text}](秒为单位)。
|
||||
|
||||
按行分块:一个条目 = 序号行 + 时间轴行 + 若干文本行(可空)。即使文本为
|
||||
空串(如翻译补空占位、空字幕)也计入一条,时间轴不丢失。正文多行用空格
|
||||
连接,便于按关键词检索。
|
||||
"""
|
||||
entries: list[dict] = []
|
||||
lines = text.splitlines()
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
line = lines[index].strip()
|
||||
# 跳过序号行与空行,找时间轴行。
|
||||
if not line or not _SRT_TIME_LINE_RE.search(line):
|
||||
index += 1
|
||||
continue
|
||||
match = _SRT_TIME_LINE_RE.search(line)
|
||||
start = _ts_to_seconds(match.group(1))
|
||||
end = _ts_to_seconds(match.group(2))
|
||||
index += 1
|
||||
# 收集后续非序号、非时间轴的文本行(可空/多行),直到空行或序号行。
|
||||
text_parts: list[str] = []
|
||||
while index < len(lines):
|
||||
nxt = lines[index].strip()
|
||||
if not nxt:
|
||||
break # 空行:条目结束
|
||||
if _SRT_TIME_LINE_RE.search(nxt):
|
||||
break # 下一个时间轴:条目结束
|
||||
if nxt.isdigit():
|
||||
break # 下一个序号:条目结束
|
||||
text_parts.append(nxt)
|
||||
index += 1
|
||||
entries.append(
|
||||
{
|
||||
"start": start,
|
||||
"end": end,
|
||||
"text": " ".join(text_parts),
|
||||
}
|
||||
)
|
||||
index += 1
|
||||
return entries
|
||||
|
||||
|
||||
def _ts_to_seconds(ts: str) -> float:
|
||||
"""把 SRT 时间戳(HH:MM:SS,mmm)换算为秒。"""
|
||||
hours, minutes, rest = ts.split(":")
|
||||
seconds, millis = rest.split(",")
|
||||
return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000
|
||||
@@ -0,0 +1,127 @@
|
||||
"""真实语音时间对齐集成测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
覆盖模块:`nodes/whisper.py`(真实转写)+ `tests/shared/realdata_contract.py`
|
||||
(对齐量化工具)。这是模块级直测的补充:用真实模型 + 真实音频 + 人工校对
|
||||
参考字幕,量化字幕时间轴与"说话真实发生时间"的偏差。
|
||||
|
||||
数据契约:`tests/shared/data/alignment/` 下 `<name>.wav|.mp4` 与其同名
|
||||
`<name>.reference.srt`(人工校对)。素材缺失或本地无模型时跳过。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nodes.whisper import _local_model_candidates, invoke
|
||||
from tests.shared.realdata_contract import (
|
||||
TIER1_TOLERANCE_SECONDS,
|
||||
align_report,
|
||||
alignment_candidates,
|
||||
clean_reference,
|
||||
)
|
||||
from tests.shared.srt_entries import parse_srt_entries
|
||||
from wov_sdk.models import InvokeRequest
|
||||
|
||||
|
||||
def _model_available() -> Path | None:
|
||||
"""返回可用的 V2 权重目录(缺失则返回 None 供跳过)。
|
||||
|
||||
只接受 V2 权重:V3 已全面停用(均改用 V2),留在盘上的 V3 目录不得被
|
||||
测试使用,否则测的不是线上实际运行的模型。
|
||||
"""
|
||||
from tests.nodes.test_whisper.test_transcribe import _v2_model_candidates
|
||||
|
||||
for candidate in _v2_model_candidates():
|
||||
if candidate.is_dir() and (candidate / "model.bin").is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _sample_dir() -> Path | None:
|
||||
"""返回包含对齐素材的目录(共享数据目录;缺失时返回 None)。"""
|
||||
directory = Path(__file__).resolve().parents[2] / "shared" / "data" / "alignment"
|
||||
return directory if directory.is_dir() else None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_real_alignment_candidates_discovered() -> None:
|
||||
"""对齐素材探查:能找到成对的媒体与参考字幕(数据契约可用)。"""
|
||||
# 数据:共享数据目录下的真实素材。
|
||||
directory = _sample_dir()
|
||||
if directory is None:
|
||||
pytest.skip("缺少 tests/shared/data/alignment 目录")
|
||||
|
||||
# 测试过程
|
||||
candidates = alignment_candidates(directory)
|
||||
|
||||
# 验证结果:至少一对,且每对都有同名 reference.srt。
|
||||
assert candidates, "应至少有一对素材 + 参考字幕"
|
||||
for media in candidates:
|
||||
assert media.with_suffix(".reference.srt").is_file()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_real_whisper_alignment_within_tolerance(tmp_path) -> None:
|
||||
"""真实转写产物 vs 人工参考字幕:时间偏差在容差内且无系统性偏移。
|
||||
|
||||
这验证"字幕时间与说话时刻对齐"这一核心功能,使用真实模型与真实音频。
|
||||
"""
|
||||
# 数据:最小的真实对齐素材(避免长视频拖慢测试)。
|
||||
directory = _sample_dir()
|
||||
model_dir = _model_available()
|
||||
if directory is None or model_dir is None:
|
||||
pytest.skip("缺少对齐素材或本地 whisper 权重,跳过")
|
||||
# 显存不足时跳过(转写整段真实音频需要显存,属外部环境状态)。
|
||||
from tests.shared.gpu_memory import require_gpu_memory, require_node_result
|
||||
|
||||
require_gpu_memory(model_dir)
|
||||
candidates = alignment_candidates(directory)
|
||||
if not candidates:
|
||||
pytest.skip("没有成对的对齐素材")
|
||||
media = min(candidates, key=lambda p: p.stat().st_size)
|
||||
reference = clean_reference(
|
||||
parse_srt_entries(media.with_suffix(".reference.srt").read_text(encoding="utf-8"))
|
||||
)
|
||||
if not reference:
|
||||
pytest.skip("参考字幕为空")
|
||||
|
||||
# 测试过程:走真实生产链路——先提音(视频/任意容器 → 16kHz 单声道 WAV),
|
||||
# 再转写。whisper 只接受 WAV,直接喂 mp4 会解析失败(对齐素材多为 mp4)。
|
||||
from nodes.ffmpeg import invoke as extract_audio
|
||||
|
||||
audio = extract_audio(InvokeRequest(
|
||||
run_id="alignment", node_instance_id="ffmpeg-1", params={},
|
||||
inputs={"video_uri": str(media)}, output_dir=str(tmp_path / "audio"),
|
||||
))
|
||||
require_node_result(audio, model_dir)
|
||||
assert audio.status == "completed", audio.error
|
||||
response = invoke(InvokeRequest(
|
||||
run_id="alignment", node_instance_id="whisper-1",
|
||||
params={
|
||||
"language": "ja",
|
||||
# 本用例目标是"时间轴对齐":整段单次解码(chunk_seconds=0)避免
|
||||
# 分块上下文带来的额外显存与上下文损失;分块路径本身已由
|
||||
# whisper 模块测试覆盖。实测 6GB 卡上该配置稳定完成。
|
||||
"chunk_seconds": 0,
|
||||
"vad_filter": True,
|
||||
"model_path": str(model_dir),
|
||||
},
|
||||
inputs={"audio_uri": audio.outputs["audio_uri"]},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
))
|
||||
require_node_result(response, model_dir)
|
||||
assert response.status == "completed", response.error
|
||||
produced = parse_srt_entries(open(response.outputs["srt_uri"], encoding="utf-8").read())
|
||||
report = align_report(media.stem, True, produced, reference)
|
||||
|
||||
# 验证结果(基线由 V2 模型实测标定,见 docs/testing.md):
|
||||
# - 产出条数与参考规模同量级(不丢整段、不大量幻觉);
|
||||
# - 平均绝对偏差在真实转写抖动范围内;
|
||||
# - 中位偏差不构成系统性偏早/偏晚。
|
||||
assert produced, "真实转写应产出字幕"
|
||||
assert len(produced) >= len(reference) * 0.5, report.format_summary()
|
||||
assert report.mean_abs_error <= 2.0, report.format_summary()
|
||||
assert not report.consistently_early, report.format_summary()
|
||||
assert not report.consistently_late, report.format_summary()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""tests/shared/srt_entries.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`tests/shared/srt_entries.py`(按秒解析 SRT 条目,供各模块测试
|
||||
计算时间轴与统计),是测试公共设施中的一个独立功能单元,拥有独立测试目录。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tests.shared.srt_entries import parse_srt_entries
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "alignment"
|
||||
|
||||
|
||||
def test_parses_single_cue_with_seconds() -> None:
|
||||
"""单条字幕解析为秒级时间轴与正文。"""
|
||||
# 数据:一条标准 SRT。
|
||||
text = "1\n00:00:01,500 --> 00:00:02,250\n你好\n"
|
||||
|
||||
# 测试过程
|
||||
entries = parse_srt_entries(text)
|
||||
|
||||
# 验证结果
|
||||
assert entries == [{"start": 1.5, "end": 2.25, "text": "你好"}]
|
||||
|
||||
|
||||
def test_keeps_empty_text_entry_with_timeline() -> None:
|
||||
"""空正文条目仍计入并保留时间轴(翻译补空占位需要)。"""
|
||||
# 数据:空正文 + 有正文两条。
|
||||
text = "1\n00:00:01,000 --> 00:00:02,000\n\n2\n00:00:03,000 --> 00:00:04,000\n有词\n"
|
||||
|
||||
# 测试过程
|
||||
entries = parse_srt_entries(text)
|
||||
|
||||
# 验证结果
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["text"] == ""
|
||||
assert entries[1]["text"] == "有词"
|
||||
|
||||
|
||||
def test_joins_multiline_text_with_space() -> None:
|
||||
"""多行正文合并为空格连接(便于关键词检索与统计)。"""
|
||||
# 数据:两行正文。
|
||||
text = "1\n00:00:01,000 --> 00:00:02,000\n第一行\n第二行\n"
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert parse_srt_entries(text)[0]["text"] == "第一行 第二行"
|
||||
|
||||
|
||||
def test_stops_entry_at_next_timestamp_or_index() -> None:
|
||||
"""条目在下一个时间轴或序号行处结束(不跨条吞并)。"""
|
||||
# 数据:三条紧凑排列(无多余空行)。
|
||||
text = (
|
||||
"1\n00:00:01,000 --> 00:00:02,000\n甲\n"
|
||||
"2\n00:00:03,000 --> 00:00:04,000\n乙\n"
|
||||
"3\n00:00:05,000 --> 00:00:06,000\n丙\n"
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
entries = parse_srt_entries(text)
|
||||
|
||||
# 验证结果
|
||||
assert [e["text"] for e in entries] == ["甲", "乙", "丙"]
|
||||
|
||||
|
||||
def test_parses_real_reference_subtitle_file() -> None:
|
||||
"""真实参考字幕文件:条数等于时间轴行数(无丢失)。"""
|
||||
# 数据:共享数据目录下的真实参考字幕。
|
||||
path = DATA_DIR / "sample.reference.srt"
|
||||
if not path.is_file():
|
||||
import pytest
|
||||
|
||||
pytest.skip(f"缺少数据文件 {path}")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
# 测试过程
|
||||
entries = parse_srt_entries(text)
|
||||
|
||||
# 验证结果
|
||||
assert len(entries) == sum(1 for line in text.splitlines() if "-->" in line)
|
||||
|
||||
|
||||
def test_empty_input_returns_empty_list() -> None:
|
||||
"""空文本返回空列表。"""
|
||||
# 数据:空字符串。
|
||||
# 测试过程与验证结果
|
||||
assert parse_srt_entries("") == []
|
||||
Reference in New Issue
Block a user