Files
vrsub/tests/test_subtitle_ocr_order_threading.py
T
cat-shark 4ebbfc5198 test: 多线程字幕顺序验证(全量真实数据夹具)+ 文档更新
- 新增 TestSubtitleOcrOrderThreading:模拟真实 vlm-ocr API 返回(不走真实
  OCR),全量 14236 帧真实数据 + 种子化可变延迟(约 8% 慢帧,模拟真实 OCR
  延迟波动),验证 4/16 线程产物与单线程确认结果逐字节一致、并发真实发生、
  完成顺序乱序但输出保序、时间轴严格递增、每条字幕与起始时刻帧文本对齐
- 全量夹具:frames_manifest_full.json + ocr_frames_full.json(真实任务
  run_ac7f480a3ccb 全部 14236 帧),常驻测试不跳过
- AGENTS.md:更新 llm-filter 参数说明与测试资产清单
2026-08-17 23:20:20 +08:00

216 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""多线程下字幕顺序正确性测试(模拟真实 API 返回,全量真实数据)。
目标:验证 subtitle-ocr 在**多线程**执行时能否正确处理字幕顺序。
- 不走真实 vlm-ocrOllama)网络调用:registry.invoke 被替换为
FakeVlmOcrApi,按 image_uri 文件名中的帧号,直接从测试数据(真实任务
run_ac7f480a3ccb 的**全量**逐帧 OCR 结果)取该帧文本返回,模拟真实
API 返回结构;输入可以是文件名(frames_manifest / image_uri);
- **真实 OCR 延迟模拟**:真实 vlm-ocr 每次调用延迟不可预判——大部分帧快、
少量帧明显慢(复杂画面/模型排队,真实约 0.1s~5s,测试按比例缩放)。
假 API 用种子化随机生成同样的快/慢分布(约 8% 慢帧),制造真实波动下
的乱序完成,对线程池的保序能力施加最贴近真实情况的压力;
- 核心断言:多线程(4/16 线程)产出的 SRT 与**用户确认过的精确结果**逐字节
一致——该结果正是真实任务 run_ac7f480a3ccb 以**单线程**workflow v4
pool 1/1)运行产出并经用户确认的,因此逐字节一致即证明"多线程 == 单线程"
同时断言并发真实发生、完成顺序确实乱序、全量时间轴严格递增、每条字幕
与其起始时刻帧的文本对齐。
全量真实数据(testdata/,常驻夹具,真实任务 run_ac7f480a3ccb 全部 14236 帧):
- frames_manifest_full.json:完整帧清单(14236 条,image_uri 改为文件名);
- ocr_frames_full.json{帧号: 该帧 OCR 文本}(位置 p ↔ 帧 p+1);
- ocr_srt_run_ac7f480a3ccb.srt:该任务单线程运行产出、用户确认过的精确结果(1666 条)。
"""
import json
import random
import re
import threading
import time
from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse
WORKSPACE = Path(__file__).resolve().parent.parent
TESTDATA = WORKSPACE / "testdata"
FULL_MANIFEST = TESTDATA / "frames_manifest_full.json"
FULL_OCR_TEXTS = TESTDATA / "ocr_frames_full.json"
CONFIRMED_SRT = TESTDATA / "ocr_srt_run_ac7f480a3ccb.srt"
TOTAL_FRAMES = 14236
# 帧号解析:image_uri 文件名形如 frame_0411.png。
_FRAME_RE = re.compile(r".*frame_(\d+)\.png")
def _frame_no(uri: str) -> int:
"""从 image_uri 文件名解析帧号。"""
return int(_FRAME_RE.match(Path(uri).name).group(1))
def _ts_to_seconds(ts: str) -> float:
"""SRT 时间戳(HH:MM:SS,mmm)转秒。"""
h, m, s = ts.replace(",", ".").split(":")
return int(h) * 3600 + int(m) * 60 + float(s)
class FakeVlmOcrApi:
"""模拟真实 vlm-ocr API:不发起真实网络调用,从测试数据返回该帧 OCR 文本。
- 输入是 image_uri 文件名,解析帧号后从全量真实 OCR 结果({帧号: 文本})
取该帧文本返回,模拟真实 API 返回结构(status=completed / outputs.text);
- **可变延迟模拟真实 OCR**:每次调用独立随机,大部分帧快
0.2~2ms,对应真实约 0.1~1s),约 slow_ratio 比例的帧明显慢
6~18ms,对应真实约 3~9s,如复杂画面/模型排队)。延迟不可预判,
对线程池的乱序恢复能力施加与真实情况一致的随机压力;
- 线程安全地记录并发峰值、完成顺序与每次延迟,供断言"多线程确实发生、
乱序完成、且存在明显慢帧"。
"""
def __init__(self, texts_by_frame: dict[int, str], seed: int = 20260817,
fast_ms: float = 1.0, slow_ms: float = 12.0,
slow_ratio: float = 0.08) -> None:
self._texts_by_frame = texts_by_frame
self._fast_ms = fast_ms
self._slow_ms = slow_ms
self._slow_ratio = slow_ratio
# 种子化随机源:延迟波动可复现(固定种子 → 测试确定性,不 flaky)。
self._rng = random.Random(seed)
self._lock = threading.Lock()
self.active = 0
self.max_active = 0
# 完成顺序(帧号):用于断言乱序完成确实发生。
self.completed_frames: list[int] = []
# 每次调用的实际延迟(毫秒):用于断言快/慢分布确实发生。
self.delays_ms: list[float] = []
def __call__(self, node_id: str, request: InvokeRequest) -> InvokeResponse:
frame_no = _frame_no(request.inputs["image_uri"])
with self._lock:
self.active += 1
self.max_active = max(self.max_active, self.active)
try:
# 真实 OCR 延迟:慢帧比例固定,具体哪帧慢由随机决定(不可预判)。
if self._rng.random() < self._slow_ratio:
delay = self._slow_ms * (0.5 + self._rng.random())
else:
delay = self._fast_ms * (0.2 + self._rng.random() * 1.8)
self.delays_ms.append(delay)
time.sleep(delay / 1000.0)
text = self._texts_by_frame[frame_no]
finally:
with self._lock:
self.active -= 1
self.completed_frames.append(frame_no)
# 与真实 vlm-ocr 节点一致的返回结构。
return InvokeResponse(status="completed", outputs={"text": text})
def _run_ocr(monkeypatch, manifest_path: Path, texts_by_frame: dict[int, str],
pool_min: int, pool_max: int, output_dir: Path, seed: int,
) -> tuple[Path, FakeVlmOcrApi]:
"""用给定线程配置运行 subtitle-ocr,返回 (产物路径, 假 API 实例)。"""
from nodes.subtitle_ocr import invoke as ocr_invoke
fake = FakeVlmOcrApi(texts_by_frame, seed=seed)
monkeypatch.setattr("wov_app.registry.invoke", fake)
response = ocr_invoke(
InvokeRequest(
run_id="order_test",
node_instance_id="",
inputs={"frames_manifest": str(manifest_path)},
params={"pool_min_workers": pool_min, "pool_max_workers": pool_max},
output_dir=str(output_dir),
)
)
assert response.status == "completed", response.error
return Path(response.outputs["srt_uri"]), fake
def _load_full_data() -> tuple[list[dict], dict[int, str]]:
"""加载全量夹具:manifest 与 {帧号: 文本}。"""
manifest = json.loads(FULL_MANIFEST.read_text(encoding="utf-8"))
texts_by_frame = {
int(key): value for key, value in json.loads(FULL_OCR_TEXTS.read_text(encoding="utf-8")).items()
}
return manifest, texts_by_frame
def _assert_alignment(srt_text: str, manifest: list[dict], texts_by_frame: dict[int, str]) -> None:
"""核心对齐断言:每条字幕的起始时刻对应的帧,其 OCR 文本必须就是本条字幕文本。
这正是"多线程下顺序正确"的最终验证:无论线程如何并发/乱序完成,
每条字幕贴的时刻必须是它真实来源帧的时刻。
"""
time_text = {
round(float(entry["time"]), 3): texts_by_frame[_frame_no(entry["image_uri"])]
for entry in manifest
}
blocks = re.findall(
r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*\n(.*?)(?=\n\s*\d+\s*\n|\Z)",
srt_text, re.DOTALL,
)
times = []
for start, _end, text in blocks:
start_s = _ts_to_seconds(start)
# 帧时间与 SRT 时间戳间允许 ±2ms 容差:format_timestamp 用 int 截断,
# 浮点 256.258 会以 256.25799.. 截断为 256,257(真实运行同样行为)。
best = min(time_text, key=lambda t: abs(t - start_s))
assert abs(best - start_s) <= 0.002, f"字幕起始时刻 {start_s}s 无对应帧"
assert time_text[best] == text.strip(), f"时刻 {start_s}s 的文本与帧不一致"
times.append(start_s)
assert all(a < b for a, b in zip(times, times[1:])), "时间轴必须严格递增"
class TestSubtitleOcrOrderUnderThreading:
"""多线程下字幕顺序正确性测试类(全量 14236 帧真实数据 + 真实可变延迟)。
确认结果(testdata/ocr_srt_run_ac7f480a3ccb.srt)是真实任务 run_ac7f480a3ccb
以**单线程**workflow v4 pool 1/1)运行产出、经用户确认的精确结果:
- 多线程(4/16 线程)+ 真实可变延迟下重放全量数据,产物必须与它逐字节一致;
- 同时必须真实发生"多线程 + 乱序完成 + 明显慢帧",而顺序仍正确。
数据全部为常驻夹具,测试始终执行(不依赖 gitignored 数据)。
"""
def test_full_real_data_variable_latency_keeps_order(self, monkeypatch, tmp_path) -> None:
"""全量真实数据 + 真实可变延迟:多线程产物与单线程确认结果逐字节一致。"""
manifest, texts_by_frame = _load_full_data()
confirmed = CONFIRMED_SRT.read_text(encoding="utf-8")
outputs: dict[tuple, str] = {}
fakes: dict[tuple, FakeVlmOcrApi] = {}
for pool_min, pool_max in ((4, 4), (16, 16)):
out_dir = tmp_path / f"p{pool_min}-{pool_max}"
srt_path, fake = _run_ocr(
monkeypatch, FULL_MANIFEST, texts_by_frame,
pool_min=pool_min, pool_max=pool_max,
output_dir=out_dir, seed=20260817,
)
outputs[(pool_min, pool_max)] = srt_path.read_text(encoding="utf-8")
fakes[(pool_min, pool_max)] = fake
# ① 多线程产物与用户确认过的精确结果(真实单线程运行)逐字节一致。
assert outputs[(4, 4)] == confirmed, "4 线程产物与确认结果不一致"
assert outputs[(16, 16)] == confirmed, "16 线程产物与确认结果不一致"
assert outputs[(4, 4)] == outputs[(16, 16)]
# ② 多线程确实发生(并发峰值>1),且可变延迟下完成顺序乱序(靠后帧
# 先完成/慢帧滞后),但输出仍与确认结果一致——线程池正确恢复了顺序。
for (pool_min, pool_max), fake in fakes.items():
assert fake.max_active > 1, f"{pool_max} 线程配置下应真实并发"
assert fake.completed_frames != sorted(fake.completed_frames), \
f"{pool_max} 线程下可变延迟应产生乱序完成"
# ③ 延迟模拟符合真实 OCR:存在明显慢帧(>3 倍快帧均值),也含极快帧。
for (_, pool_max), fake in fakes.items():
delays = fake.delays_ms
assert len(delays) == TOTAL_FRAMES, "每帧都应产生一次调用延迟"
assert max(delays) > 3.0, f"{pool_max} 线程下应存在明显慢帧(真实 OCR 延迟波动)"
assert min(delays) < 1.0, "应存在快帧(大部分帧响应快)"
# ④ 全量时间轴严格递增 + 每条字幕与其起始时刻帧的文本对齐。
_assert_alignment(confirmed, manifest, texts_by_frame)
# ⑤ 已知真实内容存在于结果中(确认结果的代表性条目)。
for line in ("北冈小姐", "这是特别病房患者的病历表", "应该已经察觉到 至今为止的一切了吧"):
assert line in confirmed, line