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 参数说明与测试资产清单
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""多线程下字幕顺序正确性测试(模拟真实 API 返回,全量真实数据)。
|
||||
|
||||
目标:验证 subtitle-ocr 在**多线程**执行时能否正确处理字幕顺序。
|
||||
|
||||
- 不走真实 vlm-ocr(Ollama)网络调用: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
|
||||
Reference in New Issue
Block a user