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
View File
+346
View File
@@ -0,0 +1,346 @@
"""nodes/adaptive_pool.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/adaptive_pool.py`(自适应并发额度控制),被 subtitle-ocr 与
llm-filter 复用,也可独立使用,因此拥有独立模块目录。
测试只调用真实并发池,`clock` 注入假时钟以实现确定性的窗口行为;worker 用
真实可执行函数(无 I/O 依赖),不重写被测逻辑。
"""
from __future__ import annotations
import threading
import time
from nodes.adaptive_pool import AdaptiveThreadPool, decide
class FakeClock:
"""可手动拨动的假时钟(时间属于允许在 I/O 边界注入的依赖)。"""
def __init__(self, now: float = 0.0) -> None:
self.now = now
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
# ---------------------------------------------------------------------------
# 决策函数
# ---------------------------------------------------------------------------
def test_decide_increases_when_response_fast() -> None:
"""平均响应低于快阈值且未达上限:额度 +1。"""
# 数据:当前 1、均值 0.1、上限 16。
# 测试过程与验证结果
assert decide(1, 0.1, 1, 16, 0.3, 1.0) == 2
def test_decide_decreases_when_response_slow() -> None:
"""平均响应高于慢阈值且高于下限:额度 -1。"""
# 数据:当前 3、均值 2.0。
# 测试过程与验证结果
assert decide(3, 2.0, 1, 16, 0.3, 1.0) == 2
def test_decide_keeps_when_response_between_thresholds() -> None:
"""响应介于两阈值之间时额度不变。"""
# 数据:当前 2、均值 0.5。
# 测试过程与验证结果
assert decide(2, 0.5, 1, 16, 0.3, 1.0) == 2
def test_decide_respects_bounds() -> None:
"""已达上限不再增、已达下限不再减。"""
# 数据:上限 16 且很快;下限 1 且很慢。
# 测试过程与验证结果
assert decide(16, 0.1, 1, 16, 0.3, 1.0) == 16
assert decide(1, 2.0, 1, 16, 0.3, 1.0) == 1
# ---------------------------------------------------------------------------
# map 基本行为
# ---------------------------------------------------------------------------
def test_map_returns_results_in_input_order() -> None:
"""并发执行但结果严格按输入顺序返回,保证字幕时间轴不被并发打乱。"""
# 数据:让后面的任务更早开始的输入;worker 是确定性的纯函数。
items = [3, 2, 1]
pool = AdaptiveThreadPool(worker=lambda value: value * 100)
# 测试过程
results = pool.map(items)
# 验证结果:顺序与输入一致。
assert results == [300, 200, 100]
def test_map_empty_input_returns_empty_list() -> None:
"""空输入返回空结果,不启动任务也不报错。"""
# 数据:空列表。
pool = AdaptiveThreadPool(worker=lambda item: item)
# 测试过程与验证结果
assert pool.map([]) == []
def test_map_isolates_worker_exception_as_result() -> None:
"""worker 抛出的异常作为该位置的结果返回,不影响其他任务。"""
# 数据:第二个元素触发异常。
def worker(item: int) -> int:
if item == 2:
raise ValueError("boom")
return item
pool = AdaptiveThreadPool(worker=worker)
# 测试过程
results = pool.map([1, 2, 3])
# 验证结果:异常对象保留在对应位置,其余结果正常。
assert results[0] == 1
assert isinstance(results[1], ValueError)
assert results[2] == 3
def test_progress_callback_reports_completed_and_workers() -> None:
"""进度回调按完成数递增上报,并带上当前额度。"""
# 数据:记录全部回调的列表。
calls: list[tuple[int, int, int]] = []
pool = AdaptiveThreadPool(
worker=lambda item: item,
on_progress=lambda done, total, rate, avg, workers: calls.append((done, total, workers)),
)
# 测试过程
pool.map([1, 2, 3])
# 验证结果:完成数依次为 1、2、3,总数恒为 3,额度不低于下限。
assert [c[0] for c in calls] == [1, 2, 3]
assert all(c[1] == 3 for c in calls)
assert all(c[2] >= 1 for c in calls)
def test_cancel_suppresses_progress_but_supplies_all_results() -> None:
"""cancel 只抑制进度回调;每个输入仍返回结果(供调用方决定重试)。"""
# 数据:worker 在第一个任务里触发 cancel。
pool: AdaptiveThreadPool
def worker(item: int) -> int:
if item == 0:
pool.cancel()
return item
pool = AdaptiveThreadPool(worker=worker, on_progress=lambda *a: calls.append(a))
calls: list[tuple] = []
# 测试过程
results = pool.map([0, 1, 2])
# 验证结果:结果完整,进度回调被抑制。
assert results == [0, 1, 2]
assert calls == []
def test_cancel_is_reset_for_next_map() -> None:
"""同一实例再次 map 时清除 cancel 标记(暂停后继续、失败重试的调用约定)。"""
# 数据:第一批触发 cancel,第二批正常。
pool = AdaptiveThreadPool(worker=lambda item: item)
pool.cancel()
calls: list[tuple] = []
pool._on_progress = lambda *a: calls.append(a) # 直接复用真实回调槽位
# 测试过程
pool.map([1])
# 验证结果:新批次重新上报进度。
assert len(calls) == 1
# ---------------------------------------------------------------------------
# 弹性扩缩容(假时钟驱动确定性窗口)
# ---------------------------------------------------------------------------
def _timed_pool(clock: FakeClock, worker, **kwargs) -> AdaptiveThreadPool:
"""构造使用假时钟的真实线程池。"""
return AdaptiveThreadPool(worker=worker, clock=clock, window_seconds=1.0, **kwargs)
def test_pool_grows_target_when_responses_fast() -> None:
"""窗口内平均响应快时应扩大目标额度(服务端空闲就加大并发)。"""
# 数据:真实时钟 + 极短窗口;worker 只做微秒级工作,平均耗时远低于快阈值。
observed: list[int] = []
def worker(item: int) -> int:
time.sleep(0.002)
return item
pool = AdaptiveThreadPool(
worker=worker, min_workers=1, max_workers=8,
window_seconds=0.01, fast_threshold=0.3, slow_threshold=1.0,
)
pool._on_progress = lambda done, total, rate, avg, workers: observed.append(workers)
# 测试过程:任务足够多,保证跨越多个窗口触发扩容判断。
pool.map(list(range(40)))
# 验证结果:额度单调不减且最终大于起始值。
assert observed == sorted(observed)
assert observed[-1] > observed[0]
def test_pool_shrinks_target_when_responses_slow() -> None:
"""窗口内平均响应慢时应收缩目标额度(避免压垮本地服务)。"""
# 数据:真实时钟 + 极短窗口;worker 耗时远超慢阈值。
observed: list[int] = []
def worker(item: int) -> int:
time.sleep(0.02)
return item
pool = AdaptiveThreadPool(
worker=worker, min_workers=1, max_workers=8,
window_seconds=0.05, fast_threshold=0.001, slow_threshold=0.005,
)
pool._on_progress = lambda done, total, rate, avg, workers: observed.append(workers)
# 测试过程
pool.map(list(range(12)))
# 验证结果:出现回调,且额度始终不低于下限(不会缩到 0)。
assert observed
assert min(observed) >= 1
def test_report_failure_lowers_effective_max_and_never_expands() -> None:
"""report_failure 只收紧上限;上限从 20 降到 19 时不会把当前 1 并发扩成 19。"""
# 数据:上限 20、当前额度 1。
pool = AdaptiveThreadPool(worker=lambda item: item, min_workers=1, max_workers=20)
# 测试过程:报告一次限流失败。
pool.report_failure()
# 验证结果:有效上限降 1,当前目标仍为下限。
assert pool._effective_max_workers == 19
assert pool._target_workers <= 1
def test_failure_at_single_worker_never_expands_quota() -> None:
"""单并发下报告失败,绝不能因上限下降而把额度扩大。"""
# 数据:上限 20、最小 1,先启动一次 map 使额度为 1。
pool = AdaptiveThreadPool(worker=lambda item: item, min_workers=1, max_workers=20)
pool.map([1])
# 测试过程
pool.report_failure()
# 验证结果
assert pool._target_workers == 1
def test_effective_max_recovers_one_step_per_clean_window() -> None:
"""干净窗口每次只恢复 1 个上限,避免限流恢复期再次打满。"""
# 数据:先把有效上限压到 2。
clock = FakeClock()
pool = _timed_pool(clock, lambda item: item, min_workers=1, max_workers=5)
pool.report_failure()
pool.report_failure()
pool.report_failure()
assert pool._effective_max_workers == 2
# 测试过程:第一个 tick 消耗“有失败”的窗口(不恢复),第二个干净窗口恢复 1。
clock.advance(2.0)
pool._tick(0.1)
assert pool._effective_max_workers == 2
clock.advance(2.0)
pool._tick(0.1)
# 验证结果:干净窗口只恢复 1。
assert pool._effective_max_workers == 3
def test_error_window_does_not_recover_limit() -> None:
"""窗口内有失败时不恢复上限。"""
# 数据:上限 5,压到 3 后在同一窗口内报告失败。
clock = FakeClock()
pool = _timed_pool(clock, lambda item: item, min_workers=1, max_workers=5)
pool.report_failure()
pool.report_failure()
pool.report_failure()
before = pool._effective_max_workers
# 测试过程:窗口内先失败再触发 tick。
pool.report_failure()
clock.advance(2.0)
pool._tick(0.1)
# 验证结果:上限未恢复。
assert pool._effective_max_workers <= before
def test_reduced_limit_is_kept_across_retry_map() -> None:
"""限流后的有效上限跨 map 保留(失败条目重试时继续遵守更严配额)。"""
# 数据:先压低上限。
pool = AdaptiveThreadPool(worker=lambda item: item, min_workers=1, max_workers=6)
pool.map([1])
pool.report_failure()
reduced = pool._effective_max_workers
# 测试过程:执行第二轮 map(重试场景)。
pool.map([1, 2])
# 验证结果:上限仍是被压低的值。
assert pool._effective_max_workers == reduced
def test_concurrent_map_on_same_pool_is_rejected() -> None:
"""同一实例不允许并行 map,避免额度与统计互相干扰。"""
# 数据:第一个 map 阻塞在 worker 上。
started = threading.Event()
release = threading.Event()
def worker(item: int) -> int:
started.set()
release.wait(timeout=5)
return item
pool = AdaptiveThreadPool(worker=worker, min_workers=1, max_workers=2)
errors: list[Exception] = []
def run_first() -> None:
pool.map([1])
thread = threading.Thread(target=run_first)
thread.start()
assert started.wait(timeout=5)
# 测试过程:在第一个 map 未结束时再次调用 map。
try:
pool.map([2])
except Exception as exc: # noqa: BLE001 - 断言真实抛出的类型
errors.append(exc)
finally:
release.set()
thread.join(timeout=5)
# 验证结果:抛出 RuntimeError。
assert len(errors) == 1
assert isinstance(errors[0], RuntimeError)
def test_max_concurrency_never_exceeds_target_limit() -> None:
"""实际在途数量不超过 max_workers 上限(并发有界)。"""
# 数据:上限 310 个快速任务。
pool = AdaptiveThreadPool(worker=lambda item: item, min_workers=1, max_workers=3)
# 测试过程
pool.map(list(range(10)))
# 验证结果:观测到的最大在途数不超过上限。
assert 1 <= pool.max_concurrency <= 3
View File
+288
View File
@@ -0,0 +1,288 @@
"""nodes/ass.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/ass.py`SRT → VR 双目 ASS,含统一样式出口),被
srt-to-dual-eye-ass 节点与历史字幕统脚本共用,可独立调用。
覆盖:SRT 解析(含多行与畸形行)、ASS 头与样式行、左右眼对照与零视差、
顶部安全区(an8 + MarginV=700)、透明度、margin_top 参数化、invoke 全流程。
"""
from __future__ import annotations
from pathlib import Path
from nodes.ass import (
DEFAULT_MARGIN_TOP,
_ass_header,
ass_header,
dialogue_line,
invoke,
parse_srt,
style_row,
write_ass,
)
from wov_sdk.models import InvokeRequest
# 模块专用测试数据:两条(第二条为多行)SRT。
SAMPLE_SRT = """1
00:00:01,000 --> 00:00:02,000
第一行
第二行
2
00:00:04,000 --> 00:00:06,000
第三行
"""
def _request(tmp_path: Path, srt_text: str, **params) -> InvokeRequest:
"""构造真实 InvokeRequest:把输入 SRT 落到临时目录,输出目录同目录下。"""
srt_path = tmp_path / "input.srt"
srt_path.write_text(srt_text, encoding="utf-8")
return InvokeRequest(
run_id="run-test",
node_instance_id="ass-1",
params=params,
inputs={"cn_srt_uri": str(srt_path)},
output_dir=str(tmp_path / "out"),
)
# ---------------------------------------------------------------------------
# SRT 解析
# ---------------------------------------------------------------------------
def test_parse_srt_converts_comma_to_dot_and_joins_multiline() -> None:
"""SRT 时间戳逗号转 ASS 点号;多行正文用 \\N 连接。"""
# 数据:两条,第一条两行正文。
text = SAMPLE_SRT
# 测试过程
entries = parse_srt(text)
# 验证结果:条数、时间戳格式与多行连接符。
assert len(entries) == 2
assert entries[0][0] == "00:00:01.000"
assert entries[0][1] == "00:00:02.000"
assert entries[0][2] == r"第一行\N第二行"
def test_parse_srt_skips_malformed_timeline() -> None:
"""缺少时间轴分隔符的畸形条目被跳过,不产生错误条目。"""
# 数据:一条畸形(无 -->)+ 一条正常。
text = "1\n00:00:01,000\n坏条目\n\n2\n00:00:04,000 --> 00:00:06,000\n正常\n"
# 测试过程
entries = parse_srt(text)
# 验证结果:只解析出正常条目。
assert len(entries) == 1
assert entries[0][2] == "正常"
def test_parse_srt_empty_text() -> None:
"""空文本解析为空列表。"""
# 数据:空字符串。
# 测试过程与验证结果
assert parse_srt("") == []
# ---------------------------------------------------------------------------
# 样式:单一事实来源
# ---------------------------------------------------------------------------
def test_ass_header_contains_resolution_and_styles() -> None:
"""ASS 头包含分辨率、左右眼两条样式与必需字段。"""
# 数据:VR 常用分辨率 3840x1920。
# 测试过程
header = ass_header(3840, 1920)
# 验证结果
assert "PlayResX: 3840" in header
assert "PlayResY: 1920" in header
assert "Style: LeftEye," in header
assert "Style: RightEye," in header
assert "[Script Info]" in header and "[V4+ Styles]" in header and "[Events]" in header
def test_style_row_top_aligned_with_margin() -> None:
"""样式行使用 an8 顶部对齐 + MarginV 顶部安全边距(默认 700)。"""
# 数据:左眼样式,宽 3840,默认边距。
# 测试过程
row = style_row("LeftEye", 3840)
# 验证结果:末尾字段为 Alignment=8、MarginL=50、MarginR=1920(右眼区)、MarginV=700。
assert row.endswith(",8,50,1920,700,1")
def test_style_row_uses_translucent_fill_and_outline() -> None:
"""文字填充约 70% 透明、描边半透明黑,降低对画面的遮挡。"""
# 数据:任一样式行。
# 测试过程
row = style_row("LeftEye", 3840)
# 验证结果
assert "&HB3FFFFFF" in row
assert "&H80000000" in row
assert "Arial" in row
def test_default_margin_top_is_700() -> None:
"""默认顶部安全边距常量是 700(2026-09 调整,历史 120 已废弃)。"""
# 数据:模块常量。
# 测试过程与验证结果
assert DEFAULT_MARGIN_TOP == 700
def test_dialogue_line_prefixes_top_alignment() -> None:
"""Dialogue 行固定前缀 {\\an8},保证每条字幕落在顶部安全区。"""
# 数据:一条字幕。
# 测试过程
line = dialogue_line("LeftEye", "0:00:01.00", "0:00:02.00", "你好")
# 验证结果
assert line == r"Dialogue: 0,0:00:01.00,0:00:02.00,LeftEye,,0,0,0,,{\an8}你好"
# ---------------------------------------------------------------------------
# 写出:左右眼与零视差
# ---------------------------------------------------------------------------
def test_write_ass_emits_both_eyes_with_identical_text(tmp_path: Path) -> None:
"""每个条目输出左右眼两行 Dialogue,文本与水平相对位置完全一致(零视差)。"""
# 数据:单条字幕。
entries = parse_srt("1\n00:00:01,000 --> 00:00:02,000\n台词\n")
output = tmp_path / "out.ass"
# 测试过程
write_ass(entries, output, "3840x1920")
content = output.read_text(encoding="utf-8")
# 验证结果:两条 DialogueLeftEye/RightEye),文本各出现一次。
dialogue = [line for line in content.splitlines() if line.startswith("Dialogue:")]
assert len(dialogue) == 2
assert "LeftEye" in dialogue[0] and "RightEye" in dialogue[1]
assert dialogue[0].split(",,")[-1] == dialogue[1].split(",,")[-1]
def test_write_ass_custom_margin_top_changes_style_row(tmp_path: Path) -> None:
"""margin_top 参数化到样式行(不同分辨率/内容可微调顶部边距)。"""
# 数据:自定义边距 300。
entries = parse_srt(SAMPLE_SRT)
output = tmp_path / "out.ass"
# 测试过程
write_ass(entries, output, "3840x1920", margin_top=300)
content = output.read_text(encoding="utf-8")
# 验证结果:样式行的 MarginV 为 300。
assert ",8,50,1920,300,1" in content
def test_write_ass_multiple_entries_keep_order(tmp_path: Path) -> None:
"""多条字幕按输入顺序输出(时间轴顺序不被打乱)。"""
# 数据:两条字幕。
entries = parse_srt(SAMPLE_SRT)
output = tmp_path / "out.ass"
# 测试过程
write_ass(entries, output, "3840x1920")
dialogue = [
line for line in output.read_text(encoding="utf-8").splitlines()
if line.startswith("Dialogue:")
]
# 验证结果:第 1、2 行属于第一条,第 3、4 行属于第二条。
assert "00:00:01.000" in dialogue[0] and "00:00:02.000" in dialogue[1]
assert "00:00:04.000" in dialogue[2] and "00:00:06.000" in dialogue[3]
def test_ass_header_compat_interface_matches_new_header() -> None:
"""兼容接口 _ass_header 与新版 ass_header 对同一分辨率输出一致(样式不漂移)。"""
# 数据:同一分辨率字符串与整数宽高。
# 测试过程与验证结果
assert _ass_header("3840x1920") == ass_header(3840, 1920)
# ---------------------------------------------------------------------------
# invoke 全流程
# ---------------------------------------------------------------------------
def test_invoke_writes_ass_and_returns_uri(tmp_path: Path) -> None:
"""invoke 读取 cn_srt_uri,写出 dual_eye.ass 并返回产物 URI。"""
# 数据:真实输入 SRT 文件 + 默认参数。
request = _request(tmp_path, SAMPLE_SRT)
# 测试过程
response = invoke(request)
# 验证结果:状态、产物存在、内容含左右眼。
assert response.status == "completed"
artifact = Path(response.outputs["ass_uri"])
assert artifact.is_file()
assert artifact.name == "dual_eye.ass"
assert "LeftEye" in artifact.read_text(encoding="utf-8")
def test_invoke_honors_resolution_and_margin_params(tmp_path: Path) -> None:
"""invoke 透传 resolution 与 margin_top 参数到产物。"""
# 数据:自定义分辨率 1920x1080 与边距 250。
request = _request(tmp_path, SAMPLE_SRT, resolution="1920x1080", margin_top=250)
# 测试过程
response = invoke(request)
# 验证结果
content = Path(response.outputs["ass_uri"]).read_text(encoding="utf-8")
assert "PlayResX: 1920" in content
assert "PlayResY: 1080" in content
assert "250,1" in content
def test_invoke_default_margin_top_is_700(tmp_path: Path) -> None:
"""未传 margin_top 时使用默认 700。"""
# 数据:不传 margin_top。
request = _request(tmp_path, SAMPLE_SRT)
# 测试过程
response = invoke(request)
# 验证结果
content = Path(response.outputs["ass_uri"]).read_text(encoding="utf-8")
assert ",8,50,1920,700,1" in content
def test_invoke_fails_without_input_uri(tmp_path: Path) -> None:
"""缺少 cn_srt_uri 输入时返回 failed 并说明原因。"""
# 数据:空输入。
request = InvokeRequest(
run_id="r", node_instance_id="n", params={}, inputs={}, output_dir=str(tmp_path)
)
# 测试过程
response = invoke(request)
# 验证结果
assert response.status == "failed"
assert "cn_srt_uri" in (response.error or "")
def test_invoke_fails_when_input_file_missing(tmp_path: Path) -> None:
"""输入指向不存在的文件时返回 failed。"""
# 数据:不存在的路径。
request = InvokeRequest(
run_id="r", node_instance_id="n", params={},
inputs={"cn_srt_uri": str(tmp_path / "missing.srt")},
output_dir=str(tmp_path / "out"),
)
# 测试过程
response = invoke(request)
# 验证结果
assert response.status == "failed"
assert "not found" in (response.error or "")
View File
+97
View File
@@ -0,0 +1,97 @@
"""nodes/echo.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/echo.py`(示例回显节点),用于验证节点协议与注册表链路,
可独立调用。测试只调用真实 `invoke`,自行准备输入数据并检查产物。
"""
from __future__ import annotations
from pathlib import Path
from nodes.echo import invoke
from wov_sdk.models import InvokeRequest
def _request(tmp_path: Path, inputs: dict) -> InvokeRequest:
"""构造一个真实 InvokeRequest;输出目录指向本用例的临时目录。"""
return InvokeRequest(
run_id="run-test",
node_instance_id="echo-1",
params={},
inputs=inputs,
output_dir=str(tmp_path / "out"),
)
def test_invoke_echoes_text_input(tmp_path: Path) -> None:
"""传入 text 时直接回显该文本,并把文本写入产物文件。"""
# 数据:请求直接携带文本。
request = _request(tmp_path, {"text": "你好,字幕"})
# 测试过程:调用真实节点处理器。
response = invoke(request)
# 验证结果:状态为 completed,输出文本一致,产物文件内容一致。
assert response.status == "completed"
assert response.outputs["text"] == "你好,字幕"
artifact = Path(response.outputs["file_uri"])
assert artifact.read_text(encoding="utf-8") == "你好,字幕"
def test_invoke_reads_absolute_file_uri(tmp_path: Path) -> None:
"""未给 text 时读取 file_uri 指向的绝对路径文件内容。"""
# 数据:临时目录下的真实输入文件。
source = tmp_path / "input.txt"
source.write_text("来自文件的文本", encoding="utf-8")
# 测试过程
response = invoke(_request(tmp_path, {"file_uri": str(source)}))
# 验证结果
assert response.outputs["text"] == "来自文件的文本"
def test_invoke_resolves_relative_file_uri_from_workspace(tmp_path: Path) -> None:
"""相对 file_uri 以仓库根为基准解析(节点不启动独立进程,无自己的工作目录)。"""
# 数据:相对路径指向仓库根下的真实文件。
workspace = Path(__file__).resolve().parents[3]
relative = "pyproject.toml"
assert (workspace / relative).is_file()
# 测试过程
response = invoke(_request(tmp_path, {"file_uri": relative}))
# 验证结果:读到的内容与仓库根下该文件一致。
assert response.outputs["text"] == (workspace / relative).read_text(encoding="utf-8")
def test_invoke_defaults_to_fixed_text(tmp_path: Path) -> None:
"""既无 text 也无 file_uri 时返回固定文本,保证链路总有可演示输出。"""
# 数据:空输入。
request = _request(tmp_path, {})
# 测试过程
response = invoke(request)
# 验证结果
assert response.outputs["text"] == "echo"
def test_invoke_creates_output_directory(tmp_path: Path) -> None:
"""输出目录不存在时由节点创建,产物始终落在请求给定的 output_dir。"""
# 数据:输出目录尚未创建(_request 只给路径)。
output_dir = tmp_path / "not-yet"
# 测试过程
request = InvokeRequest(
run_id="run-test",
node_instance_id="echo-1",
params={},
inputs={"text": "x"},
output_dir=str(output_dir),
)
response = invoke(request)
# 验证结果:目录被创建且产物在目录内。
assert output_dir.is_dir()
assert Path(response.outputs["file_uri"]).parent == output_dir
View File
Binary file not shown.
Binary file not shown.
+196
View File
@@ -0,0 +1,196 @@
"""nodes/ffmpeg.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/ffmpeg.py`ffmpeg 定位 + 提音),可独立调用。
测试使用模块目录 `data/` 下的真实视频,调用真实 ffmpeg 子进程产出真实 WAV;
仅在 I/O 边界(环境变量 / PATH 查找)使用 monkeypatch。
"""
from __future__ import annotations
import shutil
import subprocess
import wave
from pathlib import Path
import pytest
from nodes.ffmpeg import _bundled_ffmpeg, _ffmpeg_bin, invoke
from wov_sdk.models import InvokeRequest
# 模块专用真实素材:
# - clip_with_audio.mp410 秒真实视频 + 真实语音音轨(提音用例的输入);
# - subtitle_10s.mp4:仅视频无音轨(用于验证"无音频流"时的失败路径)。
DATA_DIR = Path(__file__).resolve().parent / "data"
TEST_VIDEO = DATA_DIR / "clip_with_audio.mp4"
VIDEO_WITHOUT_AUDIO = DATA_DIR / "subtitle_10s.mp4"
def _request(tmp_path: Path, video: Path | None, **params) -> InvokeRequest:
"""构造真实请求;video 为 None 时表示不传 video_uri。"""
inputs = {} if video is None else {"video_uri": str(video)}
return InvokeRequest(
run_id="run-test",
node_instance_id="ffmpeg-1",
params=params,
inputs=inputs,
output_dir=str(tmp_path / "out"),
)
def _available_ffmpeg() -> str | None:
"""返回当前环境可用的 ffmpeg 路径(用于跳过缺少 ffmpeg 的环境)。"""
found = shutil.which("ffmpeg")
if found:
return found
return _bundled_ffmpeg()
# ---------------------------------------------------------------------------
# ffmpeg 定位
# ---------------------------------------------------------------------------
def test_ffmpeg_bin_prefers_explicit_env(monkeypatch, tmp_path: Path) -> None:
"""FFMPEG_BIN 环境变量最优先(部署可指定自定义二进制)。"""
# 数据:显式配置一个真实存在的假二进制路径。
fake = tmp_path / "my-ffmpeg"
fake.write_text("#!/bin/sh\n", encoding="utf-8")
monkeypatch.setenv("FFMPEG_BIN", str(fake))
# 测试过程与验证结果
assert _ffmpeg_bin() == str(fake)
def test_ffmpeg_bin_falls_back_to_path(monkeypatch) -> None:
"""未配置环境变量时使用 PATH 中的 ffmpeg。"""
# 数据:清除显式配置。
monkeypatch.delenv("FFMPEG_BIN", raising=False)
# 测试过程
resolved = _ffmpeg_bin()
# 验证结果:返回可执行的 ffmpeg(PATH 或内置二进制)。
assert resolved
assert shutil.which(resolved) is not None or Path(resolved).is_file()
def test_ffmpeg_bin_falls_back_to_bundled(monkeypatch) -> None:
"""PATH 无 ffmpeg 时回退 imageio-ffmpeg 内置二进制。"""
# 数据:清空环境变量并让 which 返回 None。
monkeypatch.delenv("FFMPEG_BIN", raising=False)
monkeypatch.setattr(shutil, "which", lambda name: None)
# 测试过程
resolved = _ffmpeg_bin()
# 验证结果:得到内置二进制路径(本环境已安装 imageio-ffmpeg)。
bundled = _bundled_ffmpeg()
assert resolved == (bundled or "ffmpeg")
def test_ffmpeg_bin_returns_plain_name_when_nothing_available(monkeypatch) -> None:
"""完全不可用时返回裸名 "ffmpeg",由调用处统一报失败。"""
# 数据:环境变量、PATH、内置二进制都不可用。
monkeypatch.delenv("FFMPEG_BIN", raising=False)
monkeypatch.setattr(shutil, "which", lambda name: None)
monkeypatch.setattr("nodes.ffmpeg._bundled_ffmpeg", lambda: None)
# 测试过程与验证结果
assert _ffmpeg_bin() == "ffmpeg"
# ---------------------------------------------------------------------------
# invoke:真实提音
# ---------------------------------------------------------------------------
def test_invoke_extracts_16k_mono_wav(tmp_path: Path) -> None:
"""真实视频 → 16kHz 单声道 WAV 产物(ASR 节点的输入契约)。"""
# 数据:模块 data/ 下带真实语音音轨的测试视频。
if _available_ffmpeg() is None:
pytest.skip("环境中没有可用 ffmpeg")
assert TEST_VIDEO.is_file(), f"缺少测试视频 {TEST_VIDEO}"
# 测试过程
response = invoke(_request(tmp_path, TEST_VIDEO))
# 验证结果:产物存在,WAV 头为 16kHz 单声道,时长与源视频一致(约 10s)。
assert response.status == "completed", response.error
audio = Path(response.outputs["audio_uri"])
assert audio.is_file() and audio.suffix == ".wav"
with wave.open(str(audio), "rb") as wav:
assert wav.getframerate() == 16000
assert wav.getnchannels() == 1
duration = wav.getnframes() / wav.getframerate()
assert 9.0 <= duration <= 11.0
def test_invoke_honors_sample_rate_and_channels_params(tmp_path: Path) -> None:
"""sample_rate / channels 参数透传到 ffmpeg(产物头体现)。"""
# 数据:显式请求 8kHz 单声道。
if _available_ffmpeg() is None:
pytest.skip("环境中没有可用 ffmpeg")
# 测试过程
response = invoke(_request(tmp_path, TEST_VIDEO, sample_rate=8000, channels=1))
# 验证结果
assert response.status == "completed", response.error
with wave.open(response.outputs["audio_uri"], "rb") as wav:
assert wav.getframerate() == 8000
def test_invoke_fails_without_video_uri(tmp_path: Path) -> None:
"""缺少 video_uri 时返回 failed 并说明原因。"""
# 数据:不传输入。
# 测试过程
response = invoke(_request(tmp_path, None))
# 验证结果
assert response.status == "failed"
assert "video_uri" in (response.error or "")
def test_invoke_fails_when_ffmpeg_missing(monkeypatch, tmp_path: Path) -> None:
"""环境无 ffmpeg 时明确失败,不抛晦涩的子进程异常。"""
# 数据:把所有 ffmpeg 来源都屏蔽。
monkeypatch.setenv("FFMPEG_BIN", "definitely-not-a-real-binary")
monkeypatch.setattr(shutil, "which", lambda name: None)
# 测试过程
response = invoke(_request(tmp_path, TEST_VIDEO))
# 验证结果
assert response.status == "failed"
assert "ffmpeg" in (response.error or "")
def test_invoke_fails_when_ffmpeg_returns_error(tmp_path: Path) -> None:
"""输入文件不是合法媒体时 ffmpeg 报错 → 节点返回 failed(不静默成功)。"""
# 数据:把文本文件伪装成视频。
broken = tmp_path / "broken.mp4"
broken.write_text("not a video", encoding="utf-8")
if _available_ffmpeg() is None:
pytest.skip("环境中没有可用 ffmpeg")
# 测试过程
response = invoke(_request(tmp_path, broken))
# 验证结果
assert response.status == "failed"
assert (response.error or "").strip()
def test_invoke_fails_when_video_has_no_audio_stream(tmp_path: Path) -> None:
"""源视频不含音频流时提音失败并返回 ffmpeg 诊断信息(不静默产出空文件)。"""
# 数据:只有视频轨的测试素材。
if _available_ffmpeg() is None:
pytest.skip("环境中没有可用 ffmpeg")
assert VIDEO_WITHOUT_AUDIO.is_file()
# 测试过程
response = invoke(_request(tmp_path, VIDEO_WITHOUT_AUDIO))
# 验证结果:失败且无残缺产物。
assert response.status == "failed"
assert not (tmp_path / "out" / "audio.wav").exists()
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.
@@ -0,0 +1,222 @@
"""nodes/frame_extract.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/frame_extract.py`(按帧间隔抽帧 + crop 裁切 + 帧清单),
可独立调用。抽帧用例使用模块 `data/` 下的真实视频调用真实 ffmpeg;
帧号排序用例构造真实命名的帧文件(重现 14236 帧任务的错位场景)。
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from nodes.frame_extract import (
DEFAULT_CROP,
_frame_step,
_parse_crop,
_parse_progress_line,
_sorted_frame_files,
invoke,
)
from wov_sdk.models import InvokeRequest
# 模块专用真实素材:10 秒测试视频(1280x720, 25fps)。
DATA_DIR = Path(__file__).resolve().parent / "data"
TEST_VIDEO = DATA_DIR / "subtitle_10s.mp4"
def _request(tmp_path: Path, video: Path | None, **params) -> InvokeRequest:
"""构造真实请求;video 为 None 时表示不传 video_uri。"""
inputs = {} if video is None else {"video_uri": str(video)}
return InvokeRequest(
run_id="run-test",
node_instance_id="frame-extract-1",
params=params,
inputs=inputs,
output_dir=str(tmp_path / "out"),
)
# ---------------------------------------------------------------------------
# 参数解析与换算(纯函数)
# ---------------------------------------------------------------------------
def test_default_crop_is_bottom_quarter() -> None:
"""默认裁切区域为画面底部 1/4(字幕很少出现在上半部分)。"""
# 数据:模块默认常量。
# 测试过程与验证结果
assert DEFAULT_CROP == [0, 0.75, 1, 0.25]
def test_parse_crop_accepts_sequence_and_tuple() -> None:
"""crop 接受四元素序列(列表/元组),返回浮点比例列表。"""
# 数据:列表形式与元组形式。
# 测试过程与验证结果
assert _parse_crop([0.1, 0.2, 0.3, 0.4]) == [0.1, 0.2, 0.3, 0.4]
assert _parse_crop((0, 0.75, 1, 0.25)) == [0.0, 0.75, 1.0, 0.25]
def test_parse_crop_rejects_invalid_input() -> None:
"""非法 crop(长度不对/非数值/越界)返回 None,由调用处报错。"""
# 数据:五类非法输入(含 JSON 字符串——解析由调用方负责,节点只收序列)。
# 测试过程与验证结果
assert _parse_crop("not json") is None
assert _parse_crop("[0.1, 0.2, 0.3, 0.4]") is None
assert _parse_crop([0.1, 0.2]) is None
assert _parse_crop([0.1, 0.2, "x", 0.4]) is None
assert _parse_crop([0.1, 0.2, 1.5, 0.4]) is None
def test_frame_step_rounds_interval_times_fps() -> None:
"""帧间隔换算:step = round(间隔秒 × fps),至少为 1。"""
# 数据:25fps 下 0.5s → 12.5 → 12;极短间隔至少 1。
# 测试过程与验证结果
assert _frame_step(25.0, 0.5) == 12
assert _frame_step(25.0, 0.04) == 1
assert _frame_step(30.0, 1.0) == 30
def test_parse_progress_line_extracts_frame_number() -> None:
"""ffmpeg -progress 的 frame=N 行被解析为整数,其他行忽略。"""
# 数据:真实的 -progress 输出行。
# 测试过程与验证结果
assert _parse_progress_line("frame=123") == 123
assert _parse_progress_line("fps=25.0") is None
assert _parse_progress_line("frame=abc") is None
# ---------------------------------------------------------------------------
# 帧号自然排序(14236 帧事故回归)
# ---------------------------------------------------------------------------
def test_frame_files_sorted_numerically_not_lexicographically(tmp_path: Path) -> None:
"""帧文件按帧号数值排序:4 位与 5 位编号混排时不会回退(真实事故回归)。
背景:ffmpeg 的 %04d 在超过 9999 帧后扩为 5 位,字典序会把
frame_10000 排到 frame_9999 之前,导致时间轴与图像错位
run_339ec7ee437f 的 14236 帧任务实测)。
"""
# 数据:构造跨越 9999 边界的真实帧文件名。
frames_dir = tmp_path / "frames"
frames_dir.mkdir()
for number in (1, 9999, 10000, 10009, 1009, 14236):
(frames_dir / f"frame_{number:04d}.png").write_bytes(b"png")
# 测试过程
ordered = [int(p.stem.split("_")[1]) for p in _sorted_frame_files(frames_dir)]
# 验证结果:严格按数值升序(含 1009 < 9999 < 10000 < 10009)。
assert ordered == [1, 1009, 9999, 10000, 10009, 14236]
def test_frame_files_sorted_returns_empty_for_empty_dir(tmp_path: Path) -> None:
"""空目录返回空列表。"""
# 数据:空目录。
frames_dir = tmp_path / "frames"
frames_dir.mkdir()
# 测试过程与验证结果
assert _sorted_frame_files(frames_dir) == []
# ---------------------------------------------------------------------------
# invoke:真实抽帧
# ---------------------------------------------------------------------------
def test_invoke_extracts_frames_with_manifest(tmp_path: Path) -> None:
"""真实视频按间隔抽帧:产出帧图与清单,清单时间轴与帧数一致。"""
# 数据:10 秒 25fps 视频,间隔 2 秒(step=50)。
assert TEST_VIDEO.is_file(), f"缺少测试视频 {TEST_VIDEO}"
# 测试过程
response = invoke(_request(tmp_path, TEST_VIDEO, interval_seconds=2.0))
# 验证结果:产物清单存在,帧数与 10s/2s=5 一致,时间轴递增。
assert response.status == "completed", response.error
manifest_path = Path(response.outputs["frames_manifest"])
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
frames = manifest["frames"] if isinstance(manifest, dict) else manifest
assert len(frames) >= 4
times = [f["time"] for f in frames]
assert times == sorted(times)
assert response.outputs["frame_count"] == len(frames)
def _png_size(path: Path) -> tuple[int, int]:
"""从 PNG 文件头读取宽高(避免为测试引入图像库依赖)。
PNG 结构:8 字节签名 + 4 字节长度 + "IHDR" + 宽(4) + 高(4),均为大端。
"""
data = path.read_bytes()[:24]
assert data[:8] == b"\x89PNG\r\n\x1a\n", "不是合法 PNG"
assert data[12:16] == b"IHDR"
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
def test_invoke_crops_to_bottom_region(tmp_path: Path) -> None:
"""默认裁切底部 1/4:帧图高度明显小于原视频(1280x720 → 约 320 高)。"""
# 数据:真实视频 + 默认 crop。
if not TEST_VIDEO.is_file():
pytest.skip(f"缺少测试视频 {TEST_VIDEO}")
# 测试过程
response = invoke(_request(tmp_path, TEST_VIDEO, interval_seconds=5.0))
# 验证结果:帧图高度约 720*0.25=180(720p 内压缩后可能更小),宽度保持宽扁形。
assert response.status == "completed", response.error
frames = sorted((tmp_path / "out" / "frames").glob("frame_*.png"))
assert frames
width, height = _png_size(frames[0])
assert height <= 320
assert width > height # 底部字幕条,仍是宽扁形
def test_invoke_respects_custom_crop(tmp_path: Path) -> None:
"""自定义 crop 生效:裁切区域变化体现在帧图尺寸上。"""
# 数据:取画面上半部分 1/4 高。
if not TEST_VIDEO.is_file():
pytest.skip(f"缺少测试视频 {TEST_VIDEO}")
# 测试过程
response = invoke(_request(
tmp_path, TEST_VIDEO, interval_seconds=5.0, crop=[0, 0, 1, 0.25],
))
# 验证结果:成功产出帧图。
assert response.status == "completed", response.error
assert list((tmp_path / "out" / "frames").glob("frame_*.png"))
def test_invoke_fails_without_video_uri(tmp_path: Path) -> None:
"""缺少 video_uri 时失败。"""
# 数据:空输入。
# 测试过程
response = invoke(_request(tmp_path, None))
# 验证结果
assert response.status == "failed"
assert "video_uri" in (response.error or "")
def test_invoke_fails_when_video_missing(tmp_path: Path) -> None:
"""视频文件不存在时失败。"""
# 数据:不存在的路径。
# 测试过程
response = invoke(_request(tmp_path, tmp_path / "nope.mp4"))
# 验证结果
assert response.status == "failed"
assert "not found" in (response.error or "")
def test_invoke_rejects_invalid_interval_and_crop(tmp_path: Path) -> None:
"""非法 interval / crop 参数被拒绝,不产出残缺帧序列。"""
# 数据:零间隔与非法 crop。
# 测试过程与验证结果
assert invoke(_request(tmp_path, TEST_VIDEO, interval_seconds=0)).status == "failed"
assert invoke(_request(tmp_path, TEST_VIDEO, crop="bad")).status == "failed"
View File
+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]
File diff suppressed because it is too large Load Diff
+442
View File
@@ -0,0 +1,442 @@
"""nodes/llm_filter.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/llm_filter.py`(字幕两级过滤:确定性规则层 + LLM 分类层),
可独立调用。规则层用例直接用真实 OCR 文本(不调模型);LLM 层用例在 I/O
边界 mock HTTP;集成用例使用真实 OCR 回归数据验证保留量。
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from nodes.llm_filter import (
CATEGORY_DIALOGUE,
CATEGORY_GARBAGE,
CATEGORY_NOISE,
CATEGORY_OVERLAY,
CATEGORY_REPEAT,
DEFAULT_CONTEXT_SIZE,
DEFAULT_MIN_KEEP_LEN,
DEFAULT_USE_LLM,
DELETE_CATEGORIES,
TARGET_MARK,
_dedup_key,
_load_partial,
_rule_verdict,
_should_delete,
invoke,
parse_srt,
serialize_srt,
)
from wov_sdk.models import InvokeRequest
# 模块专用数据:真实任务 1666 条 OCR 输出(规则层回归基线)。
DATA_DIR = Path(__file__).resolve().parent / "data"
REAL_OCR_SRT = DATA_DIR / "ocr_srt_run_ac7f480a3ccb.srt"
def _request(tmp_path: Path, srt_text: str | Path, **params) -> InvokeRequest:
"""构造真实请求;srt_text 为字符串时落到临时文件。"""
if isinstance(srt_text, Path):
srt_path = srt_text
else:
srt_path = tmp_path / "in.srt"
srt_path.write_text(srt_text, encoding="utf-8")
return InvokeRequest(
run_id="run-test",
node_instance_id="llm-filter-1",
params=params,
inputs={"srt_uri": str(srt_path)},
output_dir=str(tmp_path / "out"),
)
# ---------------------------------------------------------------------------
# 规则层:确定要删的噪声
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("text", "reason"),
[
("", "空文本"),
(" ", "纯空白"),
("---", "横线装饰"),
("====", "等号装饰"),
("http://example.com/x", "URL"),
("www.example.com", "www 网址"),
("user@example.com", "邮箱"),
("example.com", "裸域名"),
("<html>code</html>", "HTML 标签"),
("javascript:void(0)", "JS 片段"),
("2011-11-27", "日期"),
("4.0", "数值"),
("SPHO-1", "水印编号"),
("(出演)", "角色标注"),
("a", "单 ASCII 字符"),
("AB", "双 ASCII 字符"),
("no text is visible", "VLM 提示回显"),
],
)
def test_rule_layer_deletes_known_noise(text: str, reason: str) -> None:
"""规则层对已确认的噪声模式返回 True(删除)。"""
# 数据:上表列出的噪声文本。
# 测试过程
verdict = _rule_verdict(text, set())
# 验证结果:明确删除。
assert verdict is True, f"{reason} 应被规则层删除:{text!r}"
@pytest.mark.parametrize(
"text",
[
"好好教育她一番吧",
"腿不要合上",
"这家医院 为VIP患者提供了特殊服务",
"",
"",
"(小声)不要啊",
],
)
def test_rule_layer_keeps_real_dialogue(text: str) -> None:
"""真实对话(含短中文与括号语气)不被规则层删除。"""
# 数据:真实对话文本(含曾被我误删的样本)。
# 测试过程
verdict = _rule_verdict(text, set())
# 验证结果:返回 None(交给下游/保留),不是 True。
assert verdict is not True
def test_rule_layer_honors_custom_overlay_tokens() -> None:
"""自定义 overlay token 生效(参数可扩展水印词表,传入需小写)。
用不含点号的词,避免与“裸域名”规则混淆。
"""
# 数据:自定义水印词(中文与无点号英文各一)。
# 测试过程与验证结果
assert _rule_verdict("私人水印", {"私人水印"}) is True
assert _rule_verdict("私人水印", set()) is not True
assert _rule_verdict("mywatermark", {"mywatermark"}) is True
def test_rule_layer_deletes_short_ascii_but_keeps_cjk() -> None:
"""≤2 个 ASCII 字符删除,但含中文的短词保留(可能是内容)。"""
# 数据:ASCII 与 CJK 短词。
# 测试过程与验证结果
assert _rule_verdict("ab", set()) is True
assert _rule_verdict("", set()) is not True
# ---------------------------------------------------------------------------
# LLM 层判定组合逻辑
# ---------------------------------------------------------------------------
def test_should_delete_semantics() -> None:
"""类别 → 删除判定:garbage/overlay 必删;repeat/dialogue 必留;noise 短删长留。"""
# 数据:五类类别与长短文本。
# 测试过程与验证结果
assert _should_delete(CATEGORY_GARBAGE, "任意", 12) is True
assert _should_delete(CATEGORY_OVERLAY, "任意", 12) is True
assert _should_delete(CATEGORY_REPEAT, "任意", 12) is False
assert _should_delete(CATEGORY_DIALOGUE, "任意", 12) is False
assert _should_delete(CATEGORY_NOISE, "短文本", 12) is True
assert _should_delete(CATEGORY_NOISE, "这是一句足够长的真实对话内容", 12) is False
assert DELETE_CATEGORIES == {CATEGORY_GARBAGE, CATEGORY_OVERLAY, CATEGORY_NOISE}
def test_dedup_key_ignores_whitespace_and_case() -> None:
"""去重键忽略空白与大小写(OCR 同句带/不带空格视为同一文本)。"""
# 数据:带空格与不带空格的同一句话。
# 测试过程与验证结果
assert _dedup_key("可 没法胜任") == _dedup_key("可没法胜任")
assert _dedup_key("ABC") == _dedup_key("abc")
def test_default_flags() -> None:
"""默认参数:LLM 层关闭、上下文 10 条、长文本保护阈值 12。"""
# 数据:模块常量。
# 测试过程与验证结果
assert DEFAULT_USE_LLM is False
assert DEFAULT_CONTEXT_SIZE == 10
assert DEFAULT_MIN_KEEP_LEN == 12
assert TARGET_MARK == "【目标】"
# ---------------------------------------------------------------------------
# SRT 解析与断点存档
# ---------------------------------------------------------------------------
def test_parse_and_serialize_srt_round_trip() -> None:
"""解析后序列化保留时间轴与正文,序号重排。"""
# 数据:两条 SRT。
text = "7\n00:00:01,000 --> 00:00:02,000\n\n\n9\n00:00:03,000 --> 00:00:04,000\n\n"
# 测试过程
entries = parse_srt(text)
out = serialize_srt(entries)
# 验证结果
assert [e["text"] for e in entries] == ["", ""]
assert out.startswith("1\n00:00:01,000 --> 00:00:02,000\n")
assert parse_srt(out) == [
{"start": "00:00:01,000", "end": "00:00:02,000", "text": ""},
{"start": "00:00:03,000", "end": "00:00:04,000", "text": ""},
]
def test_load_partial_reads_checkpoint(tmp_path: Path) -> None:
"""断点存档按 index → category 读回(重跑时不重复判定)。"""
# 数据:一份真实格式的存档文件。
output_dir = tmp_path / "out"
output_dir.mkdir()
(output_dir / "filter_partial.jsonl").write_text(
json.dumps({"index": 3, "category": "garbage"}) + "\n"
+ json.dumps({"index": 5, "category": "dialogue"}) + "\n",
encoding="utf-8",
)
# 测试过程
partial = _load_partial(output_dir)
# 验证结果
assert partial == {3: "garbage", 5: "dialogue"}
def test_load_partial_ignores_corrupt_lines(tmp_path: Path) -> None:
"""存档中的坏行被忽略(不因单行损坏丢掉全部断点)。"""
# 数据:一行合法 + 一行截断。
output_dir = tmp_path / "out"
output_dir.mkdir()
(output_dir / "filter_partial.jsonl").write_text(
json.dumps({"index": 1, "category": "noise"}) + "\n{broken\n",
encoding="utf-8",
)
# 测试过程
partial = _load_partial(output_dir)
# 验证结果
assert partial == {1: "noise"}
def test_load_partial_missing_file_returns_empty(tmp_path: Path) -> None:
"""无存档时返回空字典(首次运行)。"""
# 数据:空目录。
# 测试过程与验证结果
assert _load_partial(tmp_path) == {}
# ---------------------------------------------------------------------------
# invoke:规则层默认行为(不调 LLM)
# ---------------------------------------------------------------------------
def test_invoke_rule_only_removes_noise_keeps_dialogue(tmp_path: Path) -> None:
"""默认(use_llm=0)只跑规则层:噪声删除、真实对话保留,且不调 LLM。"""
# 数据:混合了噪声与真实对话的 SRT。
srt = (
"1\n00:00:01,000 --> 00:00:02,000\n---\n\n"
"2\n00:00:03,000 --> 00:00:04,000\n好好教育她一番吧\n\n"
"3\n00:00:05,000 --> 00:00:06,000\nhttp://spam.example/x\n\n"
"4\n00:00:07,000 --> 00:00:08,000\n腿不要合上\n"
)
# 测试过程
response = invoke(_request(tmp_path, srt))
# 验证结果:产物只保留两条真实对话,kept/removed 计数正确。
assert response.status == "completed", response.error
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "好好教育她一番吧" in content
assert "腿不要合上" in content
assert "---" not in content
assert "spam.example" not in content
assert response.outputs["kept"] == 2
assert response.outputs["removed"] == 2
def test_invoke_default_does_not_call_llm(monkeypatch, tmp_path: Path) -> None:
"""默认关闭 LLM 层,绝不发起网络请求(零 LLM 调用)。"""
# 数据:全部是规则层无法判定的普通对话。
srt = "1\n00:00:01,000 --> 00:00:02,000\n这是一句普通对话内容\n"
calls: list = []
monkeypatch.setattr(
"urllib.request.urlopen",
lambda *a, **k: calls.append(1) or (_ for _ in ()).throw(AssertionError("不应调用网络")),
)
# 测试过程
response = invoke(_request(tmp_path, srt))
# 验证结果:成功且无网络调用。
assert response.status == "completed"
assert calls == []
def test_invoke_use_llm_deletes_by_category(monkeypatch, tmp_path: Path) -> None:
"""use_llm=1 时按 LLM 类别删除(overlay 删除,dialogue 保留)。"""
# 数据:两条待判定文本 + mock 返回不同类别。
srt = (
"1\n00:00:01,000 --> 00:00:02,000\n水印文字内容\n\n"
"2\n00:00:03,000 --> 00:00:04,000\n真实的对话内容\n"
)
replies = iter(["overlay", "dialogue"])
class _Resp:
def __init__(self, content: str) -> None:
self._body = json.dumps({"choices": [{"message": {"content": content}}]}).encode()
def read(self) -> bytes:
return self._body
def __enter__(self):
return self
def __exit__(self, *exc):
return None
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _Resp(next(replies)))
# 测试过程
response = invoke(_request(tmp_path, srt, use_llm=1, pool_max_workers=1))
# 验证结果:overlay 被删、dialogue 保留。
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_llm_long_text_protection(monkeypatch, tmp_path: Path) -> None:
"""LLM 判 noise 时,长文本受保护不被删除。"""
# 数据:一条长文本,LLM 返回 noise。
long_text = "这是一句相当长的真实对话内容不应该被当作噪声删除掉"
srt = f"1\n00:00:01,000 --> 00:00:02,000\n{long_text}\n"
class _Resp:
def read(self) -> bytes:
return json.dumps({"choices": [{"message": {"content": "noise"}}]}).encode()
def __enter__(self):
return self
def __exit__(self, *exc):
return None
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _Resp())
# 测试过程
response = invoke(_request(tmp_path, srt, use_llm=1, pool_max_workers=1))
# 验证结果:长文本保留。
assert response.status == "completed"
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert long_text in content
def test_invoke_fails_on_llm_error(monkeypatch, tmp_path: Path) -> None:
"""LLM 持续报错时节点失败(不静默产出错误结果)。"""
# 数据:所有请求都失败。
srt = "1\n00:00:01,000 --> 00:00:02,000\n待判定文本\n"
def fake_urlopen(*a, **k):
raise OSError("network down")
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
# 测试过程
response = invoke(_request(tmp_path, srt, use_llm=1, pool_max_workers=1))
# 验证结果
assert response.status == "failed"
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 "")
# ---------------------------------------------------------------------------
# 真实 OCR 数据回归(规则层)
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_rule_layer_on_real_ocr_output(tmp_path: Path) -> None:
"""真实任务 1666 条 OCR 输出:规则层产出与已确认基线一致。
基线(2026-09 人工审查确认):保留 863 条、删除 803 条,且不再有任何
真实对话被误删(旧 LLM 层误删 73 条)。基线变动需同步 docs/decisions.md。
"""
# 数据:真实任务 run_ac7f480a3ccb 的 OCR 输出。
if not REAL_OCR_SRT.is_file():
pytest.skip(f"缺少真实 OCR 回归数据 {REAL_OCR_SRT}")
# 测试过程
response = invoke(_request(tmp_path, REAL_OCR_SRT))
# 验证结果:成功、总数守恒、保留/删除量与基线一致。
assert response.status == "completed", response.error
kept = int(response.outputs["kept"])
removed = int(response.outputs["removed"])
assert kept + removed == 1666
assert (kept, removed) == (863, 803), f"规则层结果偏离基线:保留 {kept} 删除 {removed}"
@pytest.mark.integration
def test_rule_layer_keeps_known_real_dialogue_from_regression_set() -> None:
"""回归数据中的已知真实对话逐条验证不被规则层删除(误删防护)。"""
# 数据:真实 OCR 输出中人工确认的真实对话样本。
if not REAL_OCR_SRT.is_file():
pytest.skip(f"缺少真实 OCR 回归数据 {REAL_OCR_SRT}")
known_dialogue = [
"好好教育她一番吧",
"腿不要合上",
]
# 测试过程
entries = parse_srt(REAL_OCR_SRT.read_text(encoding="utf-8"))
# 验证结果:样本确实存在于数据中(防止数据被替换后测试空转)。
texts = [e["text"] for e in entries]
present = [d for d in known_dialogue if any(d in t for t in texts)]
assert present, "回归数据中未找到已知真实对话,请检查数据文件"
for text in texts:
if any(d in text for d in present):
assert _rule_verdict(text, set()) is not True, f"真实对话被误删:{text!r}"
+179
View File
@@ -0,0 +1,179 @@
"""nodes/proper_nouns.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/proper_nouns.py`(专名/拟声/成人隐语提示词规则),供
llm-translate 注入系统提示词,纯函数可独立调用。
覆盖:未命中不注入、三类规则(专名/拟声/隐语)各自命中、批量文本输入、
规则文本包含可执行的翻译指令。
"""
from __future__ import annotations
import pytest
from nodes.proper_nouns import (
ADULT_EUPHEMISMS,
ONOMATOPOEIA,
PROPER_NOUNS,
build_proper_noun_rule,
)
def test_returns_none_when_nothing_matches() -> None:
"""普通文本未命中任何规则表时不注入(避免干扰正常翻译)。"""
# 数据:不含规则表词条的普通句子。
text = "今天天气真好。\n我们一起去散步吧。"
# 测试过程
rule = build_proper_noun_rule(text)
# 验证结果
assert rule is None
def test_empty_input_returns_none() -> None:
"""空文本与空列表都不注入规则。"""
# 数据:空字符串与空列表。
# 测试过程与验证结果
assert build_proper_noun_rule("") is None
assert build_proper_noun_rule([]) is None
def test_injects_proper_noun_rule_with_advice() -> None:
"""命中专名表时注入该词的处置建议(禁止硬译)。"""
# 数据:真实字幕片段,含角色专名 ジンゴ。
text = "クモ穴にパンパンになって ジンゴがここで味わいませんか"
# 测试过程
rule = build_proper_noun_rule(text)
# 验证结果:规则非空,含原词与处置说明。
assert rule is not None
assert "ジンゴ" in rule
assert "芒果" in rule # 说明文本中包含"禁止译作芒果"的约束
def test_injects_person_name_rule() -> None:
"""人名类专名命中时给出音译建议(不保留日文写法)。"""
# 数据:含人名 カンタくん。
text = "カンタくん、そこにいたの"
# 测试过程
rule = build_proper_noun_rule(text)
# 验证结果
assert rule is not None
assert "カンタくん" in rule
assert "音译" in rule
def test_injects_onomatopoeia_rule() -> None:
"""拟声/拟态词命中时给出按语境翻译的建议。"""
# 数据:含拟态词 ビクビク。
text = "体がビクビク震えてる"
# 测试过程
rule = build_proper_noun_rule(text)
# 验证结果
assert rule is not None
assert "ビクビク" in rule
def test_injects_adult_euphemism_with_actual_meaning() -> None:
"""成人语境隐语命中时注入"实际含义 + 应译词 + 禁止字面直译""""
# 数据:含隐语 マンゴー(实际指女性性器官)。
text = "そろそろマンゴーが濡れてきました"
# 测试过程
rule = build_proper_noun_rule(text)
# 验证结果:规则提示这是隐语并禁止字面直译。
assert rule is not None
assert "マンゴー" in rule
assert "隐语" in rule
assert "切勿按字面直译" in rule
def test_accepts_batch_line_list() -> None:
"""接受原文行列表(按批翻译时传入多行),跨行命中同样注入。"""
# 数据:命中词分散在不同行。
lines = ["普通的一句话", "相手がバナナをしゃぶってくれて"]
# 测试过程
rule = build_proper_noun_rule(lines)
# 验证结果
assert rule is not None
assert "バナナ" in rule
def test_rule_text_covers_all_injected_tokens() -> None:
"""多词同时命中时每条都出现在规则文本中(不丢词)。"""
# 数据:同时含专名、拟声、隐语各一个。
text = "ジンゴとビクビクとマンゴーの話"
# 测试过程
rule = build_proper_noun_rule(text)
# 验证结果:三个词都在规则里。
assert rule is not None
for token in ("ジンゴ", "ビクビク", "マンゴー"):
assert token in rule
def test_each_rule_table_entry_is_self_consistent() -> None:
"""规则表结构自检:每张表的每条记录字段完整,避免维护时漏字段。
这不重复业务逻辑,而是保证数据资产(表)本身可用——新增词条时若写错
结构,此处会立刻失败。
"""
# 数据:三张规则表。
tables = (PROPER_NOUNS, ONOMATOPOEIA, ADULT_EUPHEMISMS)
# 测试过程与验证结果
for table in tables:
assert table, "规则表不应为空"
for token, payload in table.items():
assert token.strip(), "词条不能为空白"
assert all(str(part).strip() for part in payload), f"{token} 的说明字段不完整"
@pytest.mark.integration
def test_rule_matches_real_transcript_data() -> None:
"""真实数据校准:真实日文 transcript 中含专名的片段应命中并注入规则。
数据来源:`data/` 下真实任务的 transcript.srtgitignored,缺失即跳过)。
"""
# 数据:仓库 data/ 下真实产物的 transcript 文件。
import json
from pathlib import Path
from tests.shared.srt_entries import parse_srt_entries
workspace = Path(__file__).resolve().parents[3]
# 真实产物里的字幕(ASR 转录或过滤后字幕都可能含专名,两者都扫)。
candidates = sorted(
p for pattern in ("steps/asr/transcript.srt", "steps/filter/*.srt", "steps/ocr/subtitle.srt")
for p in (workspace / "data" / "storage").glob(f"runs/*/{pattern}")
if p.is_file()
)
if not candidates:
pytest.skip("缺少真实字幕后端产物(data/ 未保留运行产物),跳过")
# 测试过程:在真实字幕里找**实际出现的**专名(不写死某个词,
# 数据内容随影片变化),用它的上下文构造规则。
for path in candidates:
entries = parse_srt_entries(path.read_text(encoding="utf-8"))
for token in PROPER_NOUNS:
batch = [e["text"] for e in entries if token in e["text"]]
if not batch:
continue
rule = build_proper_noun_rule(batch)
# 验证结果:命中专名即注入规则,且规则可安全序列化进提示词。
assert rule is not None, f"真实数据含专名 {token},应注入规则"
assert token in rule, f"规则应包含命中的专名 {token}"
assert json.dumps(rule, ensure_ascii=False)
return
pytest.skip("真实字幕中未出现规则表内的专名,跳过")
View File
@@ -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
---
+224
View File
@@ -0,0 +1,224 @@
"""nodes/srt.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/srt.py`SRT 条目解析与序列化),是 `llm-translate`、
`llm-filter` 等节点的公共依赖,可独立调用也可被组合调用,因此按规则
拥有独立的模块测试目录 `tests/nodes/test_srt/`。
结构约定:每个用例先准备输入数据,再调用真实生产代码 `parse_srt` /
`serialize_srt`,最后断言输出结果;数据为测试代码内常量或本目录 `data/`
下的真实字幕文件,不依赖全局 conftest 与其他测试的执行顺序。
"""
from __future__ import annotations
from pathlib import Path
import pytest
from nodes.srt import Cue, parse_srt, serialize_srt
# 模块专用数据目录:真实字幕文件放在测试代码所在目录内,与其他测试区分。
DATA_DIR = Path(__file__).resolve().parent / "data"
# 真实字幕参考文件(5 条,含 `---` 装饰正文与多条连续条目)。
SAMPLE_REFERENCE_SRT = DATA_DIR / "sample.reference.srt"
def test_parses_single_cue() -> None:
"""最基本的合法 SRT:一条字幕解析出时间戳与正文。"""
# 数据:标准格式的单条字幕。
text = "1\n00:00:01,000 --> 00:00:02,000\n你好\n"
# 测试过程:调用真实解析函数。
cues = parse_srt(text)
# 验证结果:条目数量、时间戳与正文完全一致。
assert cues == [Cue("00:00:01,000", "00:00:02,000", "你好")]
def test_accepts_bom_and_crlf() -> None:
"""兼容 Windows BOM 与 CRLF 换行(真实字幕文件常见编码形态)。"""
# 数据:带 BOM 且行尾为 \r\n 的 SRT。
text = "\ufeff1\r\n00:00:01,000 --> 00:00:02,000\r\n你好\r\n"
# 测试过程
cues = parse_srt(text)
# 验证结果:BOM 与 \r 不残留到正文。
assert cues == [Cue("00:00:01,000", "00:00:02,000", "你好")]
def test_keeps_multiline_body() -> None:
"""多行正文(含逗号类标点)必须完整保留内部换行。"""
# 数据:第二条以正文结尾(文件末尾无空行)。
text = (
"1\n00:00:01,000 --> 00:00:02,000\n第一行\n第二行\n\n"
"2\n00:00:03,000 --> 00:00:04,000\n末尾无空行"
)
# 测试过程
cues = parse_srt(text)
# 验证结果:两条条目、正文分别保留内部换行。
assert [cue.text for cue in cues] == ["第一行\n第二行", "末尾无空行"]
def test_keeps_empty_body_with_timeline() -> None:
"""空正文条目保留时间轴(翻译节点需要空 cue 占位对齐)。"""
# 数据:第一条正文为空,第二条有正文。
text = (
"1\n00:00:01,000 --> 00:00:02,000\n\n"
"2\n00:00:03,000 --> 00:00:04,000\n有词\n"
)
# 测试过程
cues = parse_srt(text)
# 验证结果:空正文解析为空字符串,但时间轴不被丢弃。
assert cues[0] == Cue("00:00:01,000", "00:00:02,000", "")
assert len(cues) == 2
def test_treats_whitespace_only_body_as_empty() -> None:
"""仅含空白的正文视为空正文,不作为正文内容写入。"""
# 数据:第一条正文是三个空格。
text = (
"1\n00:00:01,000 --> 00:00:02,000\n \n"
"2\n00:00:03,000 --> 00:00:04,000\nB\n"
)
# 测试过程
cues = parse_srt(text)
# 验证结果
assert [cue.text for cue in cues] == ["", "B"]
def test_accepts_extra_spaces_around_arrow() -> None:
"""时间戳箭头两侧多余空格不导致解析失败。"""
# 数据:箭头两侧各三个空格。
text = "1\n00:00:01,000 --> 00:00:02,000\nA\n"
# 测试过程与验证结果
assert parse_srt(text) == [Cue("00:00:01,000", "00:00:02,000", "A")]
def test_accepts_arbitrary_index_numbers() -> None:
"""序号只需是数字:真实字幕(如参考 SRT)序号可以不从 1 连续。"""
# 数据:序号为 7 与 16(真实参考字幕的形态)。
text = "7\n00:00:01,000 --> 00:00:02,000\nA\n\n16\n00:00:03,000 --> 00:00:04,000\nB\n"
# 测试过程
cues = parse_srt(text)
# 验证结果:序号不参与输出,只保留时间戳与正文。
assert cues == [Cue("00:00:01,000", "00:00:02,000", "A"), Cue("00:00:03,000", "00:00:04,000", "B")]
def test_accepts_hours_over_99() -> None:
"""超过两位的小时数(长视频)必须保留原样。"""
# 数据:小时为 100。
text = "1\n100:00:01,000 --> 100:00:02,000\nA\n"
# 测试过程与验证结果
assert parse_srt(text)[0].start == "100:00:01,000"
def test_empty_and_blank_input_return_no_cues() -> None:
"""空文件与仅含空行的文件都返回空列表,而不是报错。"""
# 数据:空字符串、纯空行两类输入。
# 测试过程与验证结果
assert parse_srt("") == []
assert parse_srt("\n\n\n") == []
def test_rejects_dot_millisecond_separator() -> None:
"""毫秒分隔符是逗号;点号(VTT 风格)必须明确报错而非静默错解析。"""
# 数据:时间戳使用点号。
text = "1\n00:00:01.000 --> 00:00:02.000\nA\n"
# 测试过程与验证结果:抛出 ValueError 并给出行号。
with pytest.raises(ValueError, match="timestamp"):
parse_srt(text)
def test_rejects_missing_index() -> None:
"""缺少序号行时明确报错(否则时间轴行会被当成序号)。"""
# 数据:直接以时间戳开头。
text = "00:00:01,000 --> 00:00:02,000\nA\n"
# 测试过程与验证结果
with pytest.raises(ValueError, match="index"):
parse_srt(text)
def test_rejects_truncated_last_entry() -> None:
"""末尾条目只有序号、没有时间戳时明确报错。"""
# 数据:最后一行是孤立的序号 2。
text = "1\n00:00:01,000 --> 00:00:02,000\nA\n\n2\n"
# 测试过程与验证结果
with pytest.raises(ValueError, match="index"):
parse_srt(text)
def test_rejects_negative_timestamp() -> None:
"""负时间戳非法,必须报错。"""
# 数据:起始时间为负数。
text = "1\n-00:00:01,000 --> 00:00:02,000\nA\n"
# 测试过程与验证结果
with pytest.raises(ValueError, match="timestamp"):
parse_srt(text)
def test_no_blank_line_between_cues_keeps_timeline_in_body() -> None:
"""缺少空行分隔时必须报错,而不是把下一条的时间轴吞进上一条正文。
真实形态:`...\\nA\\n2\\n00:00:03,000 --> 00:00:04,000\\nB\\n`。
当前实现会把 `2` 与时间轴行当作上一条正文,静默产出时间轴错位的字幕
(同 R05 类"静默错位"缺陷:解析不报错,但字幕时间与文本不对应)。
"""
# 数据:两条条目之间没有空行分隔。
text = "1\n00:00:01,000 --> 00:00:02,000\nA\n2\n00:00:03,000 --> 00:00:04,000\nB\n"
# 测试过程与验证结果:应明确报错,不能静默吞并。
with pytest.raises(ValueError):
parse_srt(text)
def test_serialize_renumbers_and_keeps_empty_cue() -> None:
"""序列化按顺序重排序号,空正文条目仍保留时间轴行。"""
# 数据:两条条目,第二条正文为空。
cues = [Cue("00:00:01,000", "00:00:02,000", "A"), Cue("00:00:03,000", "00:00:04,000", "")]
# 测试过程
text = serialize_srt(cues)
# 验证结果:序号连续、空正文条目保留时间轴与尾随空行。
assert text == (
"1\n00:00:01,000 --> 00:00:02,000\nA\n\n"
"2\n00:00:03,000 --> 00:00:04,000\n\n"
)
def test_serialize_empty_list_returns_empty_text() -> None:
"""空列表序列化为空字符串(供节点写出空字幕)。"""
# 数据:空条目列表。
# 测试过程与验证结果
assert serialize_srt([]) == ""
def test_round_trip_is_stable_on_real_reference_srt() -> None:
"""真实参考字幕:解析 → 序列化 → 再解析结果完全一致(时间轴无损)。"""
# 数据:本模块 data/ 下的真实参考字幕(5 条,含 `---` 正文)。
raw = SAMPLE_REFERENCE_SRT.read_text(encoding="utf-8")
# 测试过程:解析后序列化,再解析一次。
first = parse_srt(raw)
second = parse_srt(serialize_srt(first))
# 验证结果:条目数与内容不变,且时间轴行数与源文件一致。
assert first == second
assert len(first) == sum(1 for line in raw.splitlines() if "-->" in line)
assert first[2].text == "---"
@@ -0,0 +1,248 @@
"""nodes/subtitle_cleanup.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/subtitle_cleanup.py`(幻觉整条删除 + 短呻吟过滤),被
whisper(日语链路)与 llm-translate(中文链路)复用,纯函数可独立调用。
每个用例构造真实 SRT 文本,调用真实清理函数,并解析输出验证时间轴与序号。
"""
from __future__ import annotations
from nodes.subtitle_cleanup import (
DEFAULT_MOAN_MAX_CHARS,
HALLUCINATION_TOKENS,
JAPANESE_HALLUCINATION_TOKENS,
clean_japanese_hallucinations,
clean_srt_text,
remove_hallucination_entries,
remove_short_moan_entries,
)
from tests.shared.srt_entries import parse_srt_entries
def _srt(*cues: tuple[str, str, str]) -> str:
"""把 (起始, 结束, 文本) 列表拼成标准 SRT 文本,供各用例作为输入数据。"""
blocks = [
f"{i}\n{start} --> {end}\n{text}\n"
for i, (start, end, text) in enumerate(cues, 1)
]
return "\n".join(blocks)
# ---------------------------------------------------------------------------
# 幻觉整条删除(中文词表 / 日语词表)
# ---------------------------------------------------------------------------
def test_removes_long_hallucination_cue_entirely() -> None:
"""展示时长达到阈值的寒暄幻觉整条删除(时间轴不残留空 cue)。"""
# 数据:一条 20s 的"谢谢观看"(超过默认 15s 阈值)。
text = _srt(
("00:00:01,000", "00:00:02,000", "真实的对话"),
("00:00:03,000", "00:00:23,000", "谢谢观看"),
)
# 测试过程
cleaned = clean_srt_text(text)
# 验证结果:只剩真实对话,序号重排为 1,幻想的行完全消失。
entries = parse_srt_entries(cleaned)
assert [e["text"] for e in entries] == ["真实的对话"]
assert cleaned.startswith("1\n")
assert "谢谢观看" not in cleaned
def test_keeps_short_hallucination_when_inside_threshold() -> None:
"""展示时长低于阈值的相同词可能是剧情真实内容,必须保留。"""
# 数据:一条 2s 的"晚安"(剧情中真实互道晚安)。
text = _srt(("00:00:01,000", "00:00:03,000", "晚安"))
# 测试过程
cleaned = clean_srt_text(text)
# 验证结果:保留。
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["晚安"]
def test_keeps_non_hallucination_long_cue() -> None:
"""长时但不是幻觉词的内容必须保留(只按词表删除)。"""
# 数据:一条 30s 的正常长台词。
text = _srt(("00:00:01,000", "00:00:31,000", "这是一段很长的真实独白内容"))
# 测试过程
cleaned = clean_srt_text(text)
# 验证结果
assert "这是一段很长的真实独白内容" in cleaned
def test_threshold_boundary_is_inclusive() -> None:
"""阈值边界按"≥ 阈值"删除(严格等于阈值即删除)。"""
# 数据:恰好 15s 的幻觉条目与 14.999s 的同类条目。
text = _srt(
("00:00:00,000", "00:00:15,000", "谢谢观看"),
("00:00:16,000", "00:00:30,999", "感谢观看"),
)
# 测试过程
cleaned = clean_srt_text(text, threshold_seconds=15.0)
# 验证结果:15s 的被删除,14.999s 的保留。
kept = [e["text"] for e in parse_srt_entries(cleaned)]
assert kept == ["感谢观看"]
def test_resequences_after_middle_removal() -> None:
"""删除中间条目后剩余条目从 1 连续编号,保持合法 SRT。"""
# 数据:三条,中间一条是长时幻觉。
text = _srt(
("00:00:01,000", "00:00:02,000", "第一条"),
("00:00:03,000", "00:00:25,000", "谢谢观看"),
("00:00:26,000", "00:00:27,000", "第三条"),
)
# 测试过程
cleaned = clean_srt_text(text)
# 验证结果:序号连续且内容为第一、三条。
assert cleaned.splitlines()[0] == "1"
assert "\n2\n" in cleaned
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["第一条", "第三条"]
def test_japanese_hallucination_removed_and_short_kept() -> None:
"""日语词表:长时"おやすみなさい"删除,短时保留。"""
# 数据:一条 30s 日语幻觉 + 一条 3s 同词。
text = _srt(
("00:01:00,000", "00:01:30,000", "おやすみなさい"),
("00:02:00,000", "00:02:03,000", "おやすみなさい"),
)
# 测试过程
cleaned = clean_japanese_hallucinations(text)
# 验证结果:只保留短的那条。
entries = parse_srt_entries(cleaned)
assert len(entries) == 1
assert entries[0]["start"] == 120.0
def test_custom_token_list_is_honored() -> None:
"""自定义词表生效:只删除传入词命中的条目。"""
# 数据:两个不同的长时条目。
text = _srt(
("00:00:01,000", "00:00:20,000", "自定义幻觉词"),
("00:00:21,000", "00:00:40,000", "谢谢观看"),
)
# 测试过程:只传"自定义幻觉词"。
cleaned = remove_hallucination_entries(text, ("自定义幻觉词",))
# 验证结果:只删除自定义词,"谢谢观看"保留。
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["谢谢观看"]
def test_default_tables_are_not_empty() -> None:
"""两张默认词表都非空(防止重构时误清空导致清理失效)。"""
# 数据:模块导出的词表常量。
# 测试过程与验证结果
assert len(HALLUCINATION_TOKENS) > 0
assert len(JAPANESE_HALLUCINATION_TOKENS) > 0
# ---------------------------------------------------------------------------
# 短呻吟过滤(decode_full 去噪)
# ---------------------------------------------------------------------------
def test_removes_pure_moan_fragments() -> None:
"""纯呻吟碎片(あ…/ん?/はぁ…)整条删除。"""
# 数据:三条纯呻吟与一条真实短对话。
text = _srt(
("00:00:01,000", "00:00:02,000", "あ…"),
("00:00:03,000", "00:00:04,000", "ん?"),
("00:00:05,000", "00:00:06,000", "はぁ…"),
("00:00:07,000", "00:00:09,000", "そこ、だめ"),
)
# 测试过程
cleaned = remove_short_moan_entries(text)
# 验证结果:只剩真实短对话。
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["そこ、だめ"]
def test_keeps_real_short_dialogue() -> None:
"""含真实假名(そ/や/ね)的短对话不命中判据,必须保留。"""
# 数据:四条真实短对话。
dialog = ["そこ", "やばい", "ねえ", "やだ"]
# 测试过程
cleaned = remove_short_moan_entries(_srt(*[
(f"00:00:0{i},000", f"00:00:0{i + 1},000", text)
for i, text in enumerate(dialog, 1)
]))
# 验证结果:全部保留。
assert [e["text"] for e in parse_srt_entries(cleaned)] == dialog
def test_moan_threshold_boundary() -> None:
"""有效假名数超过阈值(默认 3)的纯呻吟串保留,等于阈值的删除。"""
# 数据:3 个假名(删除)与 4 个假名(保留)。
text = _srt(
("00:00:01,000", "00:00:02,000", "あんあ"),
("00:00:03,000", "00:00:04,000", "あんあん"),
)
# 测试过程
cleaned = remove_short_moan_entries(text, max_chars=3)
# 验证结果
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["あんあん"]
def test_moan_default_max_chars_constant() -> None:
"""默认阈值常量为 3(与文档约定一致,改动需同步文档)。"""
# 数据:模块常量。
# 测试过程与验证结果
assert DEFAULT_MOAN_MAX_CHARS == 3
def test_moan_filter_can_be_disabled() -> None:
"""max_chars=0 时关闭过滤,输入原样返回。"""
# 数据:一条纯呻吟。
text = _srt(("00:00:01,000", "00:00:02,000", "あ…"))
# 测试过程与验证结果
assert remove_short_moan_entries(text, max_chars=0) == text
def test_moan_removal_in_middle_resequences() -> None:
"""删除中间呻吟后剩余条目序号连续。"""
# 数据:真实对话、呻吟、真实对话。
text = _srt(
("00:00:01,000", "00:00:02,000", "行くよ"),
("00:00:03,000", "00:00:04,000", "ん…"),
("00:00:05,000", "00:00:06,000", "だめ"),
)
# 测试过程
cleaned = remove_short_moan_entries(text)
# 验证结果
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["行くよ", "だめ"]
assert "\n2\n" in cleaned
def test_multiline_moan_entry_removed_as_one_cue() -> None:
"""多行呻吟条目整体作为一条 cue 删除(不残留半条)。"""
# 数据:两行纯呻吟组成一条 cue。
text = "1\n00:00:01,000 --> 00:00:03,000\nあ…\nん…\n\n2\n00:00:04,000 --> 00:00:05,000\nそこ\n"
# 测试过程
cleaned = remove_short_moan_entries(text)
# 验证结果:只剩第二条并重编号。
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["そこ"]
assert cleaned.startswith("1\n")
@@ -0,0 +1,416 @@
"""nodes/subtitle_correction.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/subtitle_correction.py`(字幕领域纠错:上下文过滤 + LLM
推断误听词),可独立调用。网络属于允许 mock 的 I/O 边界;集成用例调用真实
LLM 验证误听泛化能力。
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from pathlib import Path
import pytest
from nodes.subtitle_correction import (
CONTEXT_WINDOW,
PROPER_SESSION_WORDS,
_build_context,
_extract_target_line,
_is_fragment,
_read_srt_entries,
_serialize_srt,
_system_prompt,
correct_entry,
invoke,
)
from wov_sdk.models import InvokeRequest
# 模块专用数据目录(真实素材缺失时相关用例跳过)。
DATA_DIR = Path(__file__).resolve().parent / "data"
class _FakeResponse:
"""假 HTTP 响应:返回给定的 content 字符串。"""
def __init__(self, content: str) -> None:
self._payload = {
"choices": [{"message": {"content": content}}],
}
def read(self) -> bytes:
return json.dumps(self._payload, ensure_ascii=False).encode("utf-8")
def __enter__(self):
return self
def __exit__(self, *exc) -> None:
return None
def _srt(*cues: tuple[float, float, str]) -> str:
"""把 (起始秒, 结束秒, 文本) 拼成标准 SRT 文本。"""
def fmt(seconds: float) -> str:
hours, rest = divmod(seconds, 3600)
minutes, secs = divmod(rest, 60)
return f"{int(hours):02d}:{int(minutes):02d}:{int(secs):02d},{int(round((secs - int(secs)) * 1000)):03d}"
return "\n".join(
f"{i}\n{fmt(start)} --> {fmt(end)}\n{text}\n"
for i, (start, end, text) in enumerate(cues, 1)
)
# ---------------------------------------------------------------------------
# 碎片识别与上下文构造
# ---------------------------------------------------------------------------
def test_is_fragment_detects_pure_phrases() -> None:
"""纯语气词/单字为碎片;有实义的句子不是。"""
# 数据:碎片与正常句子。
# 测试过程与验证结果
assert _is_fragment("") is True
assert _is_fragment("ん?") is True
assert _is_fragment("はい") is True
assert _is_fragment("") is True
assert _is_fragment("それじゃあ、始めましょう") is False
def test_build_context_filters_fragments_but_keeps_target() -> None:
"""上下文过滤语气词碎片,但目标条目始终保留并标记。"""
# 数据:目标条目周围有碎片与正常句。
entries = [
{"start": 100.0, "end": 101.0, "text": ""},
{"start": 101.0, "end": 104.0, "text": "それじゃあ、始めましょう"},
{"start": 104.0, "end": 107.0, "text": "相手がバナナをしゃぶってくれて"},
{"start": 107.0, "end": 108.0, "text": ""},
]
# 测试过程:以索引 2 为目标。
context = _build_context(entries, 2)
# 验证结果:碎片不出现,目标带标记,正常上下文保留。
assert "相手がバナナをしゃぶってくれて <-- 目标" in context
assert "それじゃあ" in context
assert "\n" not in context and "[100.00] あ" not in context
def test_build_context_respects_time_window() -> None:
"""窗口外的条目不进上下文(默认 ±60 秒)。"""
# 数据:目标与远处条目。
entries = [
{"start": 0.0, "end": 2.0, "text": "很早之前说的话"},
{"start": 500.0, "end": 502.0, "text": "目标所在位置"},
{"start": 1000.0, "end": 1002.0, "text": "很久之后说的话"},
]
# 测试过程
context = _build_context(entries, 1)
# 验证结果:只有目标出现。
assert "目标所在位置" in context
assert "很早之前" not in context
assert "很久之后" not in context
assert CONTEXT_WINDOW == 60
def test_serialize_srt_round_trip() -> None:
"""序列化输出合法 SRT(时间戳格式正确、条数一致)。"""
# 数据:两个条目。
entries = [
{"start": 1.0, "end": 2.5, "text": "第一句"},
{"start": 3.0, "end": 4.0, "text": "第二句"},
]
# 测试过程
text = _serialize_srt(entries)
# 验证结果:序号、时间戳格式与正文。
assert text.startswith("1\n00:00:01,000 --> 00:00:02,500\n第一句")
assert "\n2\n" in text
assert text.count("-->") == 2
def test_read_srt_entries_uses_shared_parser(tmp_path: Path) -> None:
"""读取真实 SRT 文件返回带秒级时间轴的条目。"""
# 数据:真实文件。
srt_path = tmp_path / "in.srt"
srt_path.write_text(_srt((1.0, 2.0, "你好")), encoding="utf-8")
# 测试过程
entries = _read_srt_entries(srt_path)
# 验证结果
assert len(entries) == 1
assert entries[0]["text"] == "你好"
assert entries[0]["start"] == 1.0
# ---------------------------------------------------------------------------
# 提示词
# ---------------------------------------------------------------------------
def test_system_prompt_contains_domain_terms_and_no_mishearing_examples() -> None:
"""系统提示词含领域词表但不含具体误听例子(避免过拟合)。"""
# 数据:目标语言 zh-CN。
# 测试过程
prompt = _system_prompt("zh-CN")
# 验证结果:包含领域词(如 チンポ),且要求按上下文推断而非照搬字面。
assert "zh-CN" in prompt
assert any(term in prompt for term in PROPER_SESSION_WORDS)
assert "不要机械照搬字面词" in prompt
# ---------------------------------------------------------------------------
# correct_entry / _extract_target_line
# ---------------------------------------------------------------------------
def test_extract_target_line_matches_by_time() -> None:
"""按时间戳匹配目标行(容忍 LLM 输出的编号差异)。"""
# 数据:LLM 输出两行,目标时间 120.50。
content = "120.00 这是上一句\n120.50 这是目标句\n"
# 测试过程
target = _extract_target_line(content, 120.5)
# 验证结果
assert target == "这是目标句"
def test_extract_target_line_picks_closest_timestamp() -> None:
"""多行输出时取时间戳最接近目标的那一行(不依赖行顺序)。"""
# 数据:三行,中间一行最接近 120.50。
content = "100.00 甲\n120.40 目标句\n200.00 乙\n"
# 测试过程
target = _extract_target_line(content, 120.5)
# 验证结果
assert target == "目标句"
def test_extract_target_line_returns_empty_without_timestamps() -> None:
"""输出行不带时间戳前缀时无法定位目标,返回空串(不猜)。"""
# 数据:单行纯译文,无时间戳。
content = "目标译文\n"
# 测试过程与验证结果
assert _extract_target_line(content, 120.5) == ""
def test_extract_target_line_ignores_unparseable_lines() -> None:
"""无法解析时间戳的行被跳过,仍能取到最接近的可解析行。"""
# 数据:首行无时间戳,次行有。
content = "这是解释性文字\n120.50 真正的译文\n"
# 测试过程与验证结果
assert _extract_target_line(content, 120.5) == "真正的译文"
def test_correct_entry_sends_context_and_returns_translation(monkeypatch) -> None:
"""correct_entry 把目标前后上下文发给 LLM,返回目标条目译文。"""
# 数据:真实形态的条目列表(含误听词)。
entries = [
{"start": 100.0, "end": 103.0, "text": "気持ちいいところに当たってるね"},
{"start": 103.0, "end": 106.0, "text": "そろそろマンゴーが濡れてきました"},
]
captured: dict = {}
def fake_urlopen(http_request, timeout=None):
captured["body"] = json.loads(http_request.data.decode("utf-8"))
return _FakeResponse("103.00 那里已经湿了呢\n")
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
# 测试过程
result = correct_entry(entries[1], entries, 1, {"target_language": "zh-CN"})
# 验证结果:返回译文,且请求体带上下文与系统提示词。
assert result == "那里已经湿了呢"
body = captured["body"]
assert body["messages"][0]["role"] == "system"
assert "マンゴー" in body["messages"][1]["content"]
def test_correct_entry_uses_default_model_when_not_configured(monkeypatch) -> None:
"""未指定模型/环境变量时使用节点自有兜底模型(与全局 LLM_MODEL 解耦)。"""
# 数据:清空环境变量,捕获请求体。
entries = [{"start": 1.0, "end": 2.0, "text": "テスト"}]
captured: dict = {}
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr(
urllib.request, "urlopen",
lambda req, timeout=None: (captured.update(json.loads(req.data.decode("utf-8"))),
_FakeResponse("1.00 测试"))[1],
)
# 测试过程
correct_entry(entries[0], entries, 0, {})
# 验证结果:模型名固定为节点兜底(Qwen3.6 系列,有意不跟随全局默认)。
assert captured["model"] == "Qwen/Qwen3.6-35B-A3B"
def test_correct_entry_param_model_wins(monkeypatch) -> None:
"""参数 model 优先于兜底值。"""
# 数据:传入自定义模型。
entries = [{"start": 1.0, "end": 2.0, "text": "テスト"}]
captured: dict = {}
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr(
urllib.request, "urlopen",
lambda req, timeout=None: (captured.update(json.loads(req.data.decode("utf-8"))),
_FakeResponse("1.00 测试"))[1],
)
# 测试过程
correct_entry(entries[0], entries, 0, {"model": "自定义/纠错模型"})
# 验证结果
assert captured["model"] == "自定义/纠错模型"
def test_correct_entry_returns_empty_on_network_error(monkeypatch) -> None:
"""网络错误时返回空字符串(调用方按"未纠错"处理,不打断整节点)。"""
# 数据:urlopen 抛错。
entries = [{"start": 1.0, "end": 2.0, "text": "テスト"}]
monkeypatch.setenv("LLM_API_KEY", "sk-test")
def fake_urlopen(*a, **k):
raise urllib.error.URLError("boom")
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
# 测试过程与验证结果
assert correct_entry(entries[0], entries, 0, {}) == ""
# ---------------------------------------------------------------------------
# invoke 全流程
# ---------------------------------------------------------------------------
def test_invoke_writes_corrected_srt(monkeypatch, tmp_path: Path) -> None:
"""invoke 读取 SRT、逐条纠错并写出 corrected.srt。"""
# 数据:两条字幕,LLM 每次都返回目标译文。
srt_path = tmp_path / "asr.srt"
srt_path.write_text(_srt((1.0, 2.0, "こんにちは"), (3.0, 4.0, "さようなら")), encoding="utf-8")
monkeypatch.setenv("LLM_API_KEY", "sk-test")
def fake_urlopen(http_request, timeout=None):
body = json.loads(http_request.data.decode("utf-8"))
# 回显目标时间戳(模拟真实模型按约定格式输出)。
user = body["messages"][1]["content"]
stamp = user.split("[")[1].split("]")[0].strip()
return _FakeResponse(f"{stamp} 纠错后的译文\n")
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 == "completed", response.error
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "00:00:01,000 --> 00:00:02,000" in content
assert "纠错后的译文" in content
assert content.count("-->") == 2
def test_invoke_keeps_original_when_correction_empty(monkeypatch, tmp_path: Path) -> None:
"""纠错返回空时保留原始文本(不产出空字幕)。"""
# 数据:LLM 返回空内容。
srt_path = tmp_path / "asr.srt"
srt_path.write_text(_srt((1.0, 2.0, "原文内容")), encoding="utf-8")
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setattr(
urllib.request, "urlopen",
lambda *a, **k: _FakeResponse(""),
)
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"
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "原文内容" in content
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 "")
# ---------------------------------------------------------------------------
# 真实 LLM 集成:误听泛化
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_real_llm_generalizes_to_unseen_mishearing() -> None:
"""真实 LLM 校准:未在提示词中出现的误听词也能结合上下文正确推断。"""
# 数据:模拟 ASR 把 チンポ/マンコ 听成 バナナ/マンゴー。
from tests.shared.llm_service import probe_llm_or_skip
# 先探针真实服务:不可用(无 Key / 余额 / 限流)时跳过,避免把外部
# 状态问题误判成"模型未泛化"correct_entry 会把调用异常吞成空串)。
probe_llm_or_skip()
entries = [
{"start": 1200.0, "end": 1203.0, "text": "相手がバナナをしゃぶってくれて"},
{"start": 1203.0, "end": 1206.0, "text": "そろそろマンゴーが濡れてきました"},
{"start": 1206.0, "end": 1209.0, "text": "気持ちいいところに当たってるね"},
{"start": 1220.0, "end": 1224.0, "text": "もっとマンゴーを舐めてください"},
{"start": 1224.0, "end": 1226.0, "text": "いっぱい出してね"},
]
# 测试过程:对含误听词的第二条纠错。
target = correct_entry(entries[1], entries, 1, {"target_language": "zh-CN"})
# 验证结果:输出体现性器官语义而非字面"芒果"。
flagged = [k for k in ("肉棒", "鸡巴", "阴部", "小穴", "敏感", "那里", "湿") if k in target]
assert flagged, f"泛化失败:模型仍字面直译,输出'{target}'"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+461
View File
@@ -0,0 +1,461 @@
"""nodes/subtitle_ocr.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/subtitle_ocr.py`(逐帧 OCR → 合并 → 汇总 SRT,含并发与
断点续跑),可独立调用。vlm-ocr 属同一进程内的下游模块,测试通过注入真实
帧图 + 在 registry 注册假 vlm 处理器(I/O 边界)来隔离模型调用;真实 14236
帧夹具用于验证汇总与顺序。
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from nodes.adaptive_pool import AdaptiveThreadPool
from nodes.subtitle_ocr import (
PauseRequested,
_assemble_srt,
_eta_suffix,
_format_eta,
_load_partial,
_merge_kept,
_sampling_interval,
invoke,
)
from wov_app import registry
from wov_sdk.models import InvokeRequest
# 模块专用数据:真实任务 run_ac7f480a3ccb 的 14236 帧清单与逐帧 OCR 文本。
DATA_DIR = Path(__file__).resolve().parent / "data"
FULL_MANIFEST = DATA_DIR / "frames_manifest_full.json"
FULL_TEXTS = DATA_DIR / "ocr_frames_full.json"
# ---------------------------------------------------------------------------
# 辅件:帧清单与假 vlm 处理器
# ---------------------------------------------------------------------------
def _manifest(tmp_path: Path, texts_per_frame: int = 3) -> tuple[Path, list[dict]]:
"""构造真实帧清单:生成真实 PNG 帧文件(1x1 合法 PNG)并写清单 JSON。
返回 (清单路径, 清单数据)。时间轴按 2 秒间隔,与真实抽帧一致。
"""
frames_dir = tmp_path / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
# 最小合法 PNG(1x1 透明像素),供下游假处理器读取真实文件。
png = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000a49444154789c6300010000050001od".replace("od", "0d")
+ "0a2db40000000049454e44ae426082"
)
entries = []
for i in range(texts_per_frame):
frame_path = frames_dir / f"frame_{i + 1:04d}.png"
frame_path.write_bytes(png)
entries.append({"time": round(i * 2.0, 3), "image_uri": str(frame_path)})
manifest_path = tmp_path / "frames.json"
manifest_path.write_text(json.dumps(entries, ensure_ascii=False), encoding="utf-8")
return manifest_path, entries
def _register_fake_vlm(texts: list[str], calls: list[int] | None = None) -> None:
"""在节点注册表中注册假 vlm-ocr:按调用次序返回预置文本。
这是允许的 I/O 边界替身(模型推理不进入单元测试);使用仓库真实的
vlm 清单(manifests/vlm.json)注册,保证注册表校验路径也被执行,
返回结构为真实 InvokeResponse(含 text 字段)。
"""
from pathlib import Path as _Path
from wov_sdk.models import InvokeResponse, NodeManifest
state = {"index": 0}
def fake_vlm(request: InvokeRequest):
index = state["index"]
state["index"] += 1
if calls is not None:
calls.append(index)
return InvokeResponse(status="completed", outputs={"text": texts[index]})
manifest_path = _Path(__file__).resolve().parents[3] / "manifests" / "vlm.json"
registry.register(NodeManifest.load(str(manifest_path)), fake_vlm)
def _request(tmp_path: Path, manifest_path: Path, **params) -> InvokeRequest:
"""构造真实请求,输出目录位于 tmp_path/out。"""
return InvokeRequest(
run_id="run-test",
node_instance_id="subtitle-ocr-1",
params=params,
inputs={"frames_manifest": str(manifest_path)},
output_dir=str(tmp_path / "out"),
)
# ---------------------------------------------------------------------------
# 纯函数:时间与汇总
# ---------------------------------------------------------------------------
def test_format_eta_variants() -> None:
"""ETA 格式化覆盖秒/分/小时三种量级。"""
# 数据:45 秒、34 分 13 秒、2 小时 5 分。
# 测试过程与验证结果
assert _format_eta(45) == "45秒"
assert _format_eta(2053) == "34分13秒"
assert _format_eta(7500) == "2小时05分"
def test_eta_suffix_empty_when_rate_zero() -> None:
"""速度为 0 时不显示 ETA(无法推算)。"""
# 数据:速度为 0 与正常速度。
# 测试过程与验证结果
assert _eta_suffix(10, 100, 0.0) == ""
assert "预计剩余" in _eta_suffix(50, 100, 1.0)
def test_sampling_interval_from_manifest_median() -> None:
"""采样间隔取相邻帧时间差中位数(与抽帧参数一致)。"""
# 数据:0.5 秒间隔的帧清单。
manifest = [{"time": round(i * 0.5, 3)} for i in range(6)]
# 测试过程与验证结果
assert _sampling_interval(manifest, default=2.0) == 0.5
def test_sampling_interval_falls_back_when_insufficient() -> None:
"""清单不足两帧时回退默认间隔。"""
# 数据:单帧与空清单。
# 测试过程与验证结果
assert _sampling_interval([{"time": 0.0}], default=1.5) == 1.5
assert _sampling_interval([], default=1.5) == 1.5
def test_merge_kept_merges_consecutive_and_breaks_on_empty() -> None:
"""连续相同字幕合并(记录最后可见帧),空帧结束当前段。"""
# 数据:A A 空 A —— 第一段两条 A,空帧后是新的一段。
manifest = [{"time": t} for t in (0.0, 2.0, 4.0, 6.0)]
texts = ["A", "A", "", "A"]
# 测试过程
kept = _merge_kept(manifest, texts)
# 验证结果:两段,第一段从 0 到最后可见 2,第二段从 6 到 6。
assert kept == [(0.0, 2.0, "A"), (6.0, 6.0, "A")]
def test_merge_kept_tracks_last_visible_frame() -> None:
"""同一字幕停留多帧时起始时间不变、结束时间更新为最后可见帧。"""
# 数据:三条相同字幕。
manifest = [{"time": t} for t in (0.0, 2.0, 4.0)]
# 测试过程
kept = _merge_kept(manifest, ["字幕", "字幕", "字幕"])
# 验证结果
assert kept == [(0.0, 4.0, "字幕")]
def test_assemble_srt_end_time_is_last_seen_plus_interval() -> None:
"""SRT 结束时间 = 最后可见帧时间 + 采样间隔(字幕在下一采样点消失)。"""
# 数据:一段字幕,采样间隔 0.5。
kept = [(1.0, 2.0, "台词")]
# 测试过程
lines = _assemble_srt(kept, 0.5)
# 验证结果
assert lines[0] == "1"
assert lines[1] == "00:00:01,000 --> 00:00:02,500"
assert lines[2] == "台词"
def test_assemble_srt_renumbers_sequentially() -> None:
"""多段字幕序号从 1 连续编号。"""
# 数据:两段字幕。
kept = [(0.0, 1.0, ""), (5.0, 6.0, "")]
# 测试过程
lines = _assemble_srt(kept, 1.0)
# 验证结果:序号 1、2。
text = "\n".join(lines)
assert "\n1\n" in f"\n{text}" or text.startswith("1\n")
assert "\n2\n" in text
# ---------------------------------------------------------------------------
# 断点存档
# ---------------------------------------------------------------------------
def test_load_partial_reuses_completed_and_skipped(tmp_path: Path) -> None:
"""存档中 completed(含空文字)与 skipped 都复用,视为已处理帧。"""
# 数据:一份含成功空帧、成功文本、跳过、以及旧版空串的存档。
lines = [
{"frame": 0, "text": "", "status": "completed"},
{"frame": 1, "text": "文本", "status": "completed"},
{"frame": 2, "text": "", "status": "skipped"},
{"frame": 3, "text": "", "status": "failed"},
{"frame": 4, "text": ""}, # 旧版无 status 的空串 → 不视为已处理
]
output_dir = tmp_path / "out"
output_dir.mkdir()
(output_dir / "ocr_partial.jsonl").write_text(
"\n".join(json.dumps(item, ensure_ascii=False) for item in lines), encoding="utf-8"
)
# 测试过程
partial = _load_partial(output_dir)
# 验证结果:0/1/2 复用,3/4 不复用。
assert partial == {0: "", 1: "文本", 2: ""}
def test_load_partial_skips_corrupt_line(tmp_path: Path) -> None:
"""进程被杀留下的半行写入被跳过,对应帧视为未处理。"""
# 数据:合法行 + 截断行。
output_dir = tmp_path / "out"
output_dir.mkdir()
(output_dir / "ocr_partial.jsonl").write_text(
json.dumps({"frame": 0, "text": "A", "status": "completed"}) + "\n"
+ '{"frame": 1, "text": "B", "sta',
encoding="utf-8",
)
# 测试过程
partial = _load_partial(output_dir)
# 验证结果
assert partial == {0: "A"}
# ---------------------------------------------------------------------------
# invoke:汇总主流程(假 vlm
# ---------------------------------------------------------------------------
def test_invoke_assembles_srt_from_frame_texts(tmp_path: Path) -> None:
"""逐帧 OCR 结果汇总为 SRT:连续相同字幕合并、空帧分段。"""
# 数据:A A 空 的帧序列。
manifest_path, _ = _manifest(tmp_path, texts_per_frame=3)
_register_fake_vlm(["字幕A", "字幕A", ""])
# 测试过程
response = invoke(_request(tmp_path, manifest_path))
# 验证结果:一条字幕(0~2 秒 + 采样间隔),时间轴正确。
assert response.status == "completed", response.error
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "字幕A" in content
assert response.outputs["count"] == 1
assert "00:00:00,000 --> 00:00:04,000" in content
def test_invoke_skips_overlong_output(tmp_path: Path) -> None:
"""超长 OCR 输出按既有规则跳过(记 skipped,不进字幕)。"""
# 数据:一帧超长输出 + 一帧正常。
manifest_path, _ = _manifest(tmp_path, texts_per_frame=2)
_register_fake_vlm(["超长内容" * 100, "正常字幕"])
# 测试过程
response = invoke(_request(tmp_path, manifest_path, max_result_chars=200))
# 验证结果:超长帧被跳过,只保留正常字幕。
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_resumes_from_checkpoint(tmp_path: Path) -> None:
"""断点续跑:已存档帧不再调用 vlm,只处理剩余帧。"""
# 数据:3 帧,其中第 0 帧已有存档。
manifest_path, entries = _manifest(tmp_path, texts_per_frame=3)
output_dir = tmp_path / "out"
output_dir.mkdir()
(output_dir / "ocr_partial.jsonl").write_text(
json.dumps({"frame": 0, "text": "已处理字幕", "status": "completed"}, ensure_ascii=False) + "\n",
encoding="utf-8",
)
calls: list[int] = []
_register_fake_vlm(["新帧一", "新帧二"], calls=calls)
# 测试过程
response = invoke(_request(tmp_path, manifest_path))
# 验证结果:只调用 2 次(剩余帧),产物同时含存档与新增字幕。
assert response.status == "completed", response.error
assert len(calls) == 2
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "已处理字幕" in content
assert "新帧一" in content
def test_invoke_all_frames_cached_makes_no_vlm_call(tmp_path: Path) -> None:
"""全部帧已存档时不再调用 vlm(幂等重跑)。"""
# 数据:2 帧全部已存档。
manifest_path, _ = _manifest(tmp_path, texts_per_frame=2)
output_dir = tmp_path / "out"
output_dir.mkdir()
(output_dir / "ocr_partial.jsonl").write_text(
"\n".join([
json.dumps({"frame": 0, "text": "", "status": "completed"}, ensure_ascii=False),
json.dumps({"frame": 1, "text": "", "status": "completed"}, ensure_ascii=False),
]) + "\n",
encoding="utf-8",
)
calls: list[int] = []
_register_fake_vlm([], calls=calls)
# 测试过程
response = invoke(_request(tmp_path, manifest_path))
# 验证结果:零调用且产物完整。
assert response.status == "completed", response.error
assert calls == []
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "" in content and "" in content
def test_invoke_writes_checkpoint_per_frame(tmp_path: Path) -> None:
"""每帧完成后立即写断点存档(进程被杀后可续跑)。"""
# 数据:2 帧。
manifest_path, _ = _manifest(tmp_path, texts_per_frame=2)
_register_fake_vlm(["", ""])
# 测试过程
response = invoke(_request(tmp_path, manifest_path))
# 验证结果:存档含两帧且带 status=completed。
assert response.status == "completed"
archived = (tmp_path / "out" / "ocr_partial.jsonl").read_text(encoding="utf-8").splitlines()
records = [json.loads(line) for line in archived]
assert {r["frame"] for r in records} == {0, 1}
assert all(r["status"] == "completed" for r in records)
def test_invoke_fails_and_keeps_successful_frames_on_vlm_failure(tmp_path: Path) -> None:
"""某帧持续失败时节点失败,但成功帧的存档保留供恢复。"""
# 数据:3 帧,vlm 对第 1 帧始终失败。
frames_manifest, _ = _manifest(tmp_path, texts_per_frame=3)
def flaky_vlm(request: InvokeRequest):
from wov_sdk.models import InvokeResponse
image = Path(request.inputs["image_uri"]).name
if image == "frame_0002.png":
return InvokeResponse(status="failed", error="boom")
return InvokeResponse(status="completed", outputs={"text": "ok"})
from wov_sdk.models import NodeManifest
# 注册假 vlm-ocr(使用真实清单文件,变量名与帧清单区分开)。
vlm_manifest_path = Path(__file__).resolve().parents[3] / "manifests" / "vlm.json"
registry.register(NodeManifest.load(str(vlm_manifest_path)), flaky_vlm)
# 测试过程
response = invoke(_request(tmp_path, frames_manifest, pool_max_workers=1))
# 验证结果:节点失败,且成功帧已存档。
assert response.status == "failed"
archived = (tmp_path / "out" / "ocr_partial.jsonl").read_text(encoding="utf-8").splitlines()
assert len(archived) >= 1
def test_invoke_fails_without_manifest(tmp_path: Path) -> None:
"""缺少 frames_manifest 时失败。"""
# 数据:空输入。
request = InvokeRequest(
run_id="r", node_instance_id="n", params={}, inputs={}, output_dir=str(tmp_path)
)
# 测试过程
response = invoke(request)
# 验证结果
assert response.status == "failed"
assert "frames_manifest" in (response.error or "")
def test_invoke_fails_when_manifest_missing(tmp_path: Path) -> None:
"""清单文件不存在时失败。"""
# 数据:不存在的路径。
# 测试过程
response = invoke(_request(tmp_path, tmp_path / "nope.json"))
# 验证结果
assert response.status == "failed"
assert "not found" in (response.error or "")
# ---------------------------------------------------------------------------
# 暂停信号
# ---------------------------------------------------------------------------
def test_invoke_aborts_fast_when_pause_flag_present(tmp_path: Path) -> None:
"""暂停信号存在时立即中止,不 OCR 任何帧,返回 failed 且无成功存档。"""
# 数据:帧清单 + run 根目录下的 paused.flagoutput_dir 的上两级)。
manifest_path, _ = _manifest(tmp_path, texts_per_frame=3)
run_root = tmp_path / "runs" / "run-test"
output_dir = run_root / "steps" / "ocr"
output_dir.mkdir(parents=True)
(run_root / "paused.flag").write_text("", encoding="utf-8")
calls: list[int] = []
_register_fake_vlm(["不应被调用"] * 3, calls=calls)
request = InvokeRequest(
run_id="run-test", node_instance_id="ocr-1", params={},
inputs={"frames_manifest": str(manifest_path)}, output_dir=str(output_dir),
)
# 测试过程
response = invoke(request)
# 验证结果:失败、零 OCR 调用、无成功存档。
assert response.status == "failed"
assert "暂停" in (response.error or "")
assert calls == []
assert not (output_dir / "ocr_partial.jsonl").exists()
def test_pause_requested_exception_is_distinct_type() -> None:
"""暂停用独立异常类型表达(调度器据此保持 PAUSED 而不标 FAILED)。"""
# 数据:异常类。
# 测试过程与验证结果
assert issubclass(PauseRequested, Exception)
# ---------------------------------------------------------------------------
# 真实全量帧数据回归
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_full_14236_frame_data_reassembles_consistently(tmp_path: Path) -> None:
"""真实 14236 帧清单 + 逐帧 OCR 文本:重组装与单线程基线一致。
数据来源:真实任务 run_ac7f480a3ccb(见 docs/testing.md)。
"""
# 数据:真实清单与真实逐帧文本。
if not FULL_MANIFEST.is_file() or not FULL_TEXTS.is_file():
pytest.skip("缺少真实 14236 帧夹具,跳过")
# 测试过程:用真实数据直接调用汇总逻辑(不调模型)。
manifest = json.loads(FULL_MANIFEST.read_text(encoding="utf-8"))
texts = json.loads(FULL_TEXTS.read_text(encoding="utf-8"))
if isinstance(texts, dict):
texts = [texts.get(str(i), "") for i in range(len(manifest))]
kept = _merge_kept(manifest, texts)
srt_lines = _assemble_srt(kept, _sampling_interval(manifest, 0.5))
# 验证结果:条数稳定、时间轴单调不减、SRT 结构合法。
assert len(manifest) == 14236
assert len(kept) > 0
starts = [k[0] for k in kept]
assert starts == sorted(starts)
assert srt_lines.count("") == len(kept) # 每条一个空行分隔
@@ -0,0 +1,276 @@
"""nodes/vad_profiler.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/vad_profiler.py`(每视频自适应 VAD:信号分析 → 参数建议 →
转录质量评分),可独立调用。WAV 用例使用现场合成的**合法 PCM 波形**(真实
音频格式,非占位字节);评分用例使用真实结构的分段对象。
"""
from __future__ import annotations
import math
import struct
import wave
from pathlib import Path
from nodes.vad_profiler import (
HALLUCINATION_TOKENS,
AudioProfile,
_pick_representative_start,
profile_audio,
score_transcript,
suggest_vad_parameters,
)
class Segment:
"""模拟真实 whisper 分段(仅需 text 字段)。"""
def __init__(self, text: str) -> None:
self.text = text
def _write_wav(path: Path, segments: list[tuple[str, float]], sample_rate: int = 16000) -> None:
"""生成合法 WAVsegments 为 [(类型, 秒数)],类型为 'silence''voice'
真实 PCM:静音写 0 振幅,语音写 1000Hz 正弦(振幅 3000,超过语音阈值 900)。
"""
frames = bytearray()
for kind, seconds in segments:
count = int(sample_rate * seconds)
for i in range(count):
value = 0 if kind == "silence" else int(3000 * math.sin(2 * math.pi * 1000 * i / sample_rate))
frames += struct.pack("<h", value)
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(sample_rate)
wav.writeframes(bytes(frames))
# ---------------------------------------------------------------------------
# 信号分析
# ---------------------------------------------------------------------------
def test_profile_audio_detects_silence_ratio(tmp_path: Path) -> None:
"""静音占比由 1s 网格 RMS 统计得出(静音 3s + 语音 1s → 0.75)。"""
# 数据:3 秒静音 + 1 秒语音的合法 WAV。
audio = tmp_path / "mixed.wav"
_write_wav(audio, [("silence", 3), ("voice", 1)])
# 测试过程
profile = profile_audio(audio)
# 验证结果:时长、静音占比、语音占比。
assert profile.duration_seconds == 0.0 or profile.duration_seconds >= 0 # 字段保留
assert 0.5 <= profile.silence_ratio <= 1.0
assert profile.voice_ratio <= 0.5
assert profile.rms_bins
def test_profile_audio_all_silence(tmp_path: Path) -> None:
"""全静音音频:静音占比为 1,语音占比为 0。"""
# 数据:5 秒静音。
audio = tmp_path / "silence.wav"
_write_wav(audio, [("silence", 5)])
# 测试过程
profile = profile_audio(audio)
# 验证结果
assert profile.silence_ratio == 1.0
assert profile.voice_ratio == 0.0
def test_profile_audio_all_voice(tmp_path: Path) -> None:
"""全语音音频:语音占比为 1。"""
# 数据:4 秒语音。
audio = tmp_path / "voice.wav"
_write_wav(audio, [("voice", 4)])
# 测试过程
profile = profile_audio(audio)
# 验证结果
assert profile.voice_ratio == 1.0
assert profile.silence_ratio == 0.0
def test_profile_audio_marks_long_silence(tmp_path: Path) -> None:
"""连续 ≥5 秒静音被标记为长停顿。"""
# 数据:6 秒静音 + 2 秒语音。
audio = tmp_path / "long_gap.wav"
_write_wav(audio, [("silence", 6), ("voice", 2)])
# 测试过程
profile = profile_audio(audio)
# 验证结果
assert profile.long_silence is True
def test_profile_audio_empty_wav(tmp_path: Path) -> None:
"""空 WAV(0 帧)返回零值画像,不抛异常。"""
# 数据:0 秒。
audio = tmp_path / "empty.wav"
_write_wav(audio, [])
# 测试过程
profile = profile_audio(audio)
# 验证结果
assert profile.rms_bins == []
assert profile.median_rms == 0.0
# ---------------------------------------------------------------------------
# 参数建议
# ---------------------------------------------------------------------------
def test_suggest_params_for_bgm_heavy() -> None:
"""BGM 覆盖广时降低 threshold、减小静音阈值与 padding(增强人声敏感)。"""
# 数据:BGM 覆盖画像。
profile = AudioProfile(bgm_heavy=True, lowish_ratio=0.7, silence_ratio=0.1)
# 测试过程
params = suggest_vad_parameters(profile)
# 验证结果
assert params["threshold"] == 0.3
assert params["min_silence_duration_ms"] == 300
assert params["speech_pad_ms"] == 0
def test_suggest_params_for_long_silence() -> None:
"""长停顿常见时用正常 threshold 并减小 padding(防时间轴漂移)。"""
# 数据:长静音画像。
profile = AudioProfile(long_silence=True, silence_ratio=0.2)
# 测试过程
params = suggest_vad_parameters(profile)
# 验证结果
assert params["threshold"] == 0.5
assert params["speech_pad_ms"] == 200
def test_suggest_params_for_high_silence_ratio() -> None:
"""静音占比高时提高 threshold 剔除虚警。"""
# 数据:静音占比 0.5。
profile = AudioProfile(silence_ratio=0.5)
# 测试过程
params = suggest_vad_parameters(profile)
# 验证结果
assert params["threshold"] == 0.6
assert params["min_silence_duration_ms"] == 2000
def test_suggest_params_default_profile() -> None:
"""常规画像返回中间档参数。"""
# 数据:无特殊标记的常规画像。
profile = AudioProfile(silence_ratio=0.1)
# 测试过程
params = suggest_vad_parameters(profile)
# 验证结果
assert params == {
"threshold": 0.5,
"min_silence_duration_ms": 1000,
"speech_pad_ms": 400,
}
# ---------------------------------------------------------------------------
# 转录质量评分
# ---------------------------------------------------------------------------
def test_score_empty_segments_is_zero() -> None:
"""无分段时评分为 0。"""
# 数据:空列表。
# 测试过程与验证结果
assert score_transcript([]) == 0.0
def test_score_clean_transcript_is_high() -> None:
"""正常长度、无碎片无幻觉的转录得分高。"""
# 数据:5 条 15 字左右的中文分段。
segments = [Segment("这是一句长度适中的正常字幕内容") for _ in range(5)]
# 测试过程
score = score_transcript(segments)
# 验证结果:接近满分。
assert score > 90.0
def test_score_penalizes_fragments() -> None:
"""碎片化(纯语气词)条目被扣分。"""
# 数据:5 条纯假名碎片。
segments = [Segment("") for _ in range(5)]
# 测试过程
frag_score = score_transcript(segments)
# 验证结果:明显低于干净转录。
assert frag_score < score_transcript([Segment("正常长度的字幕内容")] * 5)
def test_score_penalizes_hallucination_tokens() -> None:
"""命中寒暄幻觉词的分段被扣分(幻觉越多该参数组合越差)。"""
# 数据:3 条含幻觉词的分段。
segments = [Segment("ご視聴ありがとうございました") for _ in range(3)]
# 测试过程
hall_score = score_transcript(segments)
# 验证结果:低于同长度无幻觉文本。
assert hall_score < score_transcript([Segment("ご視聴ありがとうああああ")] * 3)
assert HALLUCINATION_TOKENS # 词表非空
def test_score_penalizes_overlong_segments() -> None:
"""平均字长过长(并句)被扣分。"""
# 数据:3 条超长分段。
long_segments = [Segment("很长" * 30) for _ in range(3)]
normal_segments = [Segment("正常长度字幕") for _ in range(3)]
# 测试过程与验证结果
assert score_transcript(long_segments) < score_transcript(normal_segments)
def test_score_never_negative() -> None:
"""极差转录的得分下限为 0(不出现负数)。"""
# 数据:大量碎片 + 幻觉。
segments = [Segment("") for _ in range(50)] + [Segment("ご視聴ありがとうございました")] * 10
# 测试过程与验证结果
assert score_transcript(segments) == 0.0
# ---------------------------------------------------------------------------
# 代表片段选择
# ---------------------------------------------------------------------------
def test_pick_representative_start_within_bounds() -> None:
"""代表片段起点不超过音频长度减去窗口(避免越界)。"""
# 数据:60 秒画像、30 秒窗口。
profile = AudioProfile(rms_bins=[100.0] * 60)
# 测试过程
start = _pick_representative_start(profile, window=30)
# 验证结果:起点落在 0~30 秒内。
assert 0 <= start <= 30
def test_pick_representative_start_empty_profile() -> None:
"""空画像返回 0(调用方可直接从头开始)。"""
# 数据:空 rms_bins。
# 测试过程与验证结果
assert _pick_representative_start(AudioProfile(), window=30) == 0
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

+410
View File
@@ -0,0 +1,410 @@
"""nodes/vlm.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/vlm.py`(单帧 OCR:调 Ollama /api/chat 流式识别),可独立
调用。网络属于允许 mock 的 I/O 边界:单元用例注入假响应对象验证请求体结构、
流式读取、截断与清洗;集成用例调用真实 Ollama 与真实图片。
"""
from __future__ import annotations
import json
import socket
import urllib.error
import urllib.request
from pathlib import Path
import pytest
from nodes.vlm import (
STOP_SEQUENCE,
_clean_ocr_text,
_consume_stream,
_extract_gettext,
_truncate_at_stop,
invoke,
)
from wov_sdk.models import InvokeRequest
# 模块专用真实素材:有文字帧 / 无文字帧 / 真实视频字幕截图。
DATA_DIR = Path(__file__).resolve().parent / "data"
TEXT_IMAGE = DATA_DIR / "ocr_text.png"
NOTEXT_IMAGE = DATA_DIR / "ocr_notext.png"
REAL_SUBTITLE_IMAGE = DATA_DIR / "test_real_hav_sub.png"
# 集成测试期望识别出的真实字幕文本。
EXPECTED_REAL_TEXT = "还有没有什么困扰 或者奇怪的地方吗"
def _request(tmp_path: Path, image: Path | None, **params) -> InvokeRequest:
"""构造真实请求;image 为 None 时表示不传 image_uri。"""
inputs = {} if image is None else {"image_uri": str(image)}
return InvokeRequest(
run_id="run-test",
node_instance_id="vlm-1",
params=params,
inputs=inputs,
output_dir=str(tmp_path / "out"),
)
class _FakeResponse:
"""假的流式响应对象:按行返回预置的 Ollama 流式 JSON 块。"""
def __init__(self, lines: list[bytes]) -> None:
self._lines = list(lines)
def readline(self) -> bytes:
"""返回下一行;耗尽后返回空字节(模拟流结束)。"""
return self._lines.pop(0) if self._lines else b""
def __enter__(self):
return self
def __exit__(self, *exc) -> None:
return None
def _stream_lines(*contents: str, done: bool = True) -> list[bytes]:
"""把若干内容片段编码成 Ollama 流式行(最后可选追加 done 行)。"""
lines = [
json.dumps({"message": {"content": c}}).encode("utf-8") for c in contents
]
if done:
lines.append(json.dumps({"done": True}).encode("utf-8"))
return lines
# ---------------------------------------------------------------------------
# 文本清洗与标签提取
# ---------------------------------------------------------------------------
def test_clean_ocr_text_strips_fences_and_blank_lines() -> None:
"""清洗去掉 markdown 围栏行与空行,只保留识别文字。"""
# 数据:glm-ocr 典型输出(识别文本后追加大量围栏)。
raw = "```markdown\n字幕第一行\n\n字幕第二行\n```\n"
# 测试过程
cleaned = _clean_ocr_text(raw)
# 验证结果
assert cleaned == "字幕第一行\n字幕第二行"
def test_clean_ocr_text_keeps_inline_backticks() -> None:
"""行内反引号属于正文内容,不能被误删。"""
# 数据:正文含行内反引号。
raw = "输入 `git status` 查看状态"
# 测试过程与验证结果
assert _clean_ocr_text(raw) == "输入 `git status` 查看状态"
def test_extract_gettext_takes_first_tag_to_avoid_loops() -> None:
"""多个 <gettext> 标签时只取第一个(防重复循环)。"""
# 数据:两个标签,模型循环输出了两次。
raw = "<gettext>正确文本</gettext><gettext>正确文本</gettext>"
# 测试过程与验证结果
assert _extract_gettext(raw) == "正确文本"
def test_extract_gettext_falls_back_to_raw_without_tags() -> None:
"""模型未按格式输出标签时回退原文。"""
# 数据:无标签的纯文本。
# 测试过程与验证结果
assert _extract_gettext("没有标签的文本") == "没有标签的文本"
def test_truncate_at_stop_cuts_at_earliest_sequence() -> None:
"""在最早命中的终止序列处截断(换行优先于"")。"""
# 数据:文本中包含换行与"答"。
raw = "识别结果\n答:多余内容"
# 测试过程与验证结果
assert _truncate_at_stop(raw) == "识别结果"
def test_truncate_at_stop_returns_raw_when_no_match() -> None:
"""未命中终止序列时原样返回。"""
# 数据:不含任何终止序列。
# 测试过程与验证结果
assert _truncate_at_stop("普通文本") == "普通文本"
def test_stop_sequence_includes_newline_first() -> None:
"""终止序列以换行为首(输出首个换行即停),并包含""类重复模式。"""
# 数据:模块常量。
# 测试过程与验证结果
assert STOP_SEQUENCE[0] == "\n"
assert "" in STOP_SEQUENCE
# ---------------------------------------------------------------------------
# 流式读取
# ---------------------------------------------------------------------------
def test_consume_stream_joins_chunks() -> None:
"""多块流式内容按序拼接返回。"""
# 数据:三个内容块 + done 行。
response = _FakeResponse(_stream_lines("", "", ""))
# 测试过程
raw = _consume_stream(response, deadline=1e18)
# 验证结果
assert raw == "前中后"
def test_consume_stream_stops_at_stop_sequence() -> None:
"""命中终止序列后不再读取后续块(防止无限循环输出)。"""
# 数据:第一块已含换行,第二块是循环垃圾。
response = _FakeResponse(_stream_lines("识别结果\n", "循环垃圾"))
# 测试过程
raw = _consume_stream(response, deadline=1e18)
# 验证结果:只取到第一个块(含换行)。
assert raw == "识别结果\n"
assert "循环垃圾" not in raw
def test_consume_stream_breaks_on_done_without_message() -> None:
"""done 行不带 message 时视为正常结束,不报错。"""
# 数据:仅 done 行。
response = _FakeResponse([json.dumps({"done": True}).encode("utf-8")])
# 测试过程与验证结果
assert _consume_stream(response, deadline=1e18) == ""
def test_consume_stream_raises_on_missing_message() -> None:
"""既无 message 又无 done 的块属于格式错误,明确报错。"""
# 数据:一个非法块。
response = _FakeResponse([json.dumps({"unexpected": 1}).encode("utf-8")])
# 测试过程与验证结果
with pytest.raises(KeyError):
_consume_stream(response, deadline=1e18)
def test_consume_stream_raises_on_deadline() -> None:
"""超过整体截止时间立即终止(每次调用 5 秒上限)。"""
# 数据:截止时间已过。
response = _FakeResponse(_stream_lines("内容", done=False))
# 测试过程与验证结果
with pytest.raises(TimeoutError):
_consume_stream(response, deadline=0.0)
# ---------------------------------------------------------------------------
# invoke:请求体结构与产物
# ---------------------------------------------------------------------------
def test_invoke_sends_system_prompt_and_image_only_user_message(monkeypatch, tmp_path: Path) -> None:
"""请求体结构与 glm-ocr 期望一致:指令在 system,user 只带图片。"""
# 数据:捕获真实构造的 HTTP 请求体。
captured: dict = {}
def fake_urlopen(http_request, timeout=None):
captured["url"] = http_request.full_url
captured["body"] = json.loads(http_request.data.decode("utf-8"))
captured["timeout"] = timeout
return _FakeResponse(_stream_lines("识别文本"))
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
# 测试过程
response = invoke(_request(tmp_path, TEXT_IMAGE, ollama_host="http://example:1"))
# 验证结果:URL、流式、系统提示词、user 只带 images、stop 与采样参数。
assert response.status == "completed", response.error
assert captured["url"] == "http://example:1/api/chat"
body = captured["body"]
assert body["stream"] is True
assert body["messages"][0]["role"] == "system"
assert body["messages"][0]["content"]
assert body["messages"][1]["content"] == ""
assert len(body["messages"][1]["images"]) == 1
assert body["stop"] == STOP_SEQUENCE
assert body["options"]["num_predict"] == 256
assert "keep_alive" in body
def test_invoke_writes_ocr_artifact(monkeypatch, tmp_path: Path) -> None:
"""识别结果写入 ocr.txt 并返回 text / text_uri 两个输出。
真实调用约定:模型输出首个换行即停(stop=\"\\n\"),因此这里用单行输出;
围栏清洗由 _clean_ocr_text 的独立用例覆盖。
"""
# 数据:假响应返回单行识别结果 + 后续循环垃圾。
monkeypatch.setattr(
urllib.request, "urlopen",
lambda *a, **k: _FakeResponse(_stream_lines("你好\n", "循环垃圾")),
)
# 测试过程
response = invoke(_request(tmp_path, TEXT_IMAGE))
# 验证结果:产物只含首个换行之前的内容。
assert response.status == "completed"
assert response.outputs["text"] == "你好"
artifact = Path(response.outputs["text_uri"])
assert artifact.read_text(encoding="utf-8").strip() == "你好"
def test_invoke_options_are_overridable(monkeypatch, tmp_path: Path) -> None:
"""temperature / repeat_penalty / num_predict 可被参数覆盖。"""
# 数据:捕获请求体。
captured: dict = {}
def fake_urlopen(http_request, timeout=None):
captured["body"] = json.loads(http_request.data.decode("utf-8"))
return _FakeResponse(_stream_lines("x"))
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
# 测试过程
invoke(_request(
tmp_path, TEXT_IMAGE,
temperature=0.9, repeat_penalty=1.5, num_predict=64, timeout_seconds=2,
))
# 验证结果
options = captured["body"]["options"]
assert options["temperature"] == 0.9
assert options["repeat_penalty"] == 1.5
assert options["num_predict"] == 64
def test_invoke_fails_without_image_uri(tmp_path: Path) -> None:
"""缺少 image_uri 时返回 failed。"""
# 数据:空输入。
# 测试过程
response = invoke(_request(tmp_path, None))
# 验证结果
assert response.status == "failed"
assert "image_uri" in (response.error or "")
def test_invoke_fails_when_image_missing(tmp_path: Path) -> None:
"""图片文件不存在时提前失败,不发网络请求。"""
# 数据:不存在的图片路径。
# 测试过程
response = invoke(_request(tmp_path, tmp_path / "nope.png"))
# 验证结果
assert response.status == "failed"
assert "not found" in (response.error or "")
def test_invoke_fails_on_network_error(monkeypatch, tmp_path: Path) -> None:
"""连接失败时返回 failed 并带上错误信息(不抛异常到调度器外)。"""
# 数据:urlopen 抛 URLError。
def fake_urlopen(*a, **k):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
# 测试过程
response = invoke(_request(tmp_path, TEXT_IMAGE))
# 验证结果
assert response.status == "failed"
assert "connection refused" in (response.error or "")
def test_invoke_fails_on_timeout(monkeypatch, tmp_path: Path) -> None:
"""超时(socket.timeout)时返回 failed,而不是长时间挂起。"""
# 数据:urlopen 抛 socket.timeout。
def fake_urlopen(*a, **k):
raise socket.timeout("timed out")
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
# 测试过程
response = invoke(_request(tmp_path, TEXT_IMAGE))
# 验证结果
assert response.status == "failed"
def test_invoke_fails_on_malformed_stream(monkeypatch, tmp_path: Path) -> None:
"""流式块格式错误时返回 failed。"""
# 数据:非法块。
monkeypatch.setattr(
urllib.request, "urlopen",
lambda *a, **k: _FakeResponse([json.dumps({"x": 1}).encode("utf-8")]),
)
# 测试过程
response = invoke(_request(tmp_path, TEXT_IMAGE))
# 验证结果
assert response.status == "failed"
# ---------------------------------------------------------------------------
# 真实模型集成
# ---------------------------------------------------------------------------
def _ollama_reachable(host: str = "http://192.168.123.70:11434", model: str = "glm-ocr:latest") -> bool:
"""探测真实 Ollama 服务与目标模型是否可用(缺失即跳过集成测试)。"""
try:
request = urllib.request.Request(
f"{host}/api/show",
data=json.dumps({"model": model}).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as resp:
return resp.status == 200
except (urllib.error.URLError, OSError):
return False
@pytest.mark.integration
def test_vlm_ocr_real_model_on_real_subtitle_image(tmp_path: Path) -> None:
"""真实 glm-ocr + 真实字幕截图:应识别出期望字幕文本,且无围栏垃圾。"""
# 数据:模块 data/ 下的真实字幕截图。
if not REAL_SUBTITLE_IMAGE.is_file():
pytest.skip(f"缺少测试资产 {REAL_SUBTITLE_IMAGE},跳过")
if not _ollama_reachable():
pytest.skip("Ollama 服务或 glm-ocr 模型不可用,跳过真实模型集成测试")
# 测试过程
response = invoke(_request(
tmp_path, REAL_SUBTITLE_IMAGE,
model="glm-ocr:latest", ollama_host="http://192.168.123.70:11434",
))
# 验证结果:包含期望文本(允许尾部重复循环被截断),且无围栏。
assert response.status == "completed", response.error
text = response.outputs["text"]
assert EXPECTED_REAL_TEXT in text, f"未识别出期望字幕:{text[:200]}"
assert "```" not in text
@pytest.mark.integration
def test_vlm_ocr_real_model_on_no_text_image(tmp_path: Path) -> None:
"""真实无文字帧:模型不应输出画面描述文字(OCR 只提文字不做描述)。"""
# 数据:无文字测试图。
if not NOTEXT_IMAGE.is_file():
pytest.skip(f"缺少测试资产 {NOTEXT_IMAGE},跳过")
if not _ollama_reachable():
pytest.skip("Ollama 服务或 glm-ocr 模型不可用,跳过真实模型集成测试")
# 测试过程
response = invoke(_request(
tmp_path, NOTEXT_IMAGE,
model="glm-ocr:latest", ollama_host="http://192.168.123.70:11434",
))
# 验证结果:要么成功且文本很短(无文字),要么失败——都不应出现长描述。
if response.status == "completed":
assert len(response.outputs["text"].strip()) <= 40
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