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。
@@ -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 上限(并发有界)。"""
|
||||
# 数据:上限 3,10 个快速任务。
|
||||
pool = AdaptiveThreadPool(worker=lambda item: item, min_workers=1, max_workers=3)
|
||||
|
||||
# 测试过程
|
||||
pool.map(list(range(10)))
|
||||
|
||||
# 验证结果:观测到的最大在途数不超过上限。
|
||||
assert 1 <= pool.max_concurrency <= 3
|
||||
@@ -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")
|
||||
|
||||
# 验证结果:两条 Dialogue(LeftEye/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 "")
|
||||
@@ -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
|
||||
@@ -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.mp4:10 秒真实视频 + 真实语音音轨(提音用例的输入);
|
||||
# - 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()
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 101 KiB |
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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}"
|
||||
@@ -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.srt(gitignored,缺失即跳过)。
|
||||
"""
|
||||
# 数据:仓库 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("真实字幕中未出现规则表内的专名,跳过")
|
||||
@@ -0,0 +1,19 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:01,440
|
||||
---
|
||||
|
||||
2
|
||||
00:00:01,440 --> 00:00:04,320
|
||||
SUB 001
|
||||
|
||||
3
|
||||
00:00:05,760 --> 00:00:06,240
|
||||
---
|
||||
|
||||
4
|
||||
00:00:06,240 --> 00:00:09,120
|
||||
SUB 002
|
||||
|
||||
5
|
||||
00:00:09,600 --> 00:00:10,080
|
||||
---
|
||||
@@ -0,0 +1,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}'"
|
||||
@@ -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.flag(output_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:
|
||||
"""生成合法 WAV:segments 为 [(类型, 秒数)],类型为 '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
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 127 KiB |
@@ -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
|
||||
@@ -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.WhisperModel(I/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
|
||||