"""多线程下字幕顺序正确性测试(模拟真实 API 返回,全量真实数据)。 目标:验证 subtitle-ocr 在**多线程**执行时能否正确处理字幕顺序。 R06 更新:下述 1666 条文件保留作历史记录,含跨空白帧合并缺陷,已不作为 逐字节正确性标准。基线由真实单线程/完整成功存档组装产生(1942 条), 同时逐采样点检查正文和空白,要求多线程及断点结果与新基线一致。 - 不走真实 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, fast_ms=0, slow_ms=0) if pool_max == 1 else 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 的文本与帧不一致" # 每个采样点都须匹配正文,不能让同一句字幕跨过无文字帧。 end_s = _ts_to_seconds(_end) covered = [value for timestamp, value in time_text.items() if best <= timestamp < end_s - 0.002] assert covered and all(value == text.strip() for value in covered) 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() # R06:旧 1666 条结果跨空白合并,改用当前真实单线程路径构建基线。 confirmed_path, _ = _run_ocr(monkeypatch, FULL_MANIFEST, texts_by_frame, 1, 1, tmp_path / "single", 20260817) confirmed = confirmed_path.read_text(encoding="utf-8") assert confirmed.count("-->") == 1942 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 # ① 多线程产物与本次真实单线程运行基线逐字节一致(R06 修正跨空白)。 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 def test_ocr_resumes_from_partial_checkpoint(monkeypatch, tmp_path) -> None: """节点级断点:预写部分帧存档后运行,只处理未处理帧,产物与全量一致。 模拟中断时已落盘的 ocr_partial.jsonl(前 100 帧已处理):invoke 应只对 未处理帧调用 vlm-ocr,并把存档文本与新增文本合并,最终 SRT 与全量一次 跑完逐字节一致——重启不浪费已处理的帧。 """ manifest, texts_by_frame = _load_full_data() # 基线走完整 OCR 路径,包含超长输出跳过规则,不手写业务处理后的存档。 baseline_path, _ = _run_ocr(monkeypatch, FULL_MANIFEST, texts_by_frame, 1, 1, tmp_path / "baseline", 99) confirmed = baseline_path.read_text(encoding="utf-8") assert confirmed.count("-->") == 1942 out_dir = tmp_path / "resume" partial_path = out_dir / "ocr_partial.jsonl" partial_path.parent.mkdir(parents=True) lines = [] for i in range(100): # 存档按 0-based 帧序号记录;manifest[i] 的帧号 = i+1。 lines.append( json.dumps({"frame": i, "text": texts_by_frame[i + 1], "status": "completed"}, ensure_ascii=False) ) if i == 50: lines.append("") # 空行:_load_partial 必须跳过,不视为一条记录。 if i == 51: lines.append("broken-json-line") # 损坏行(进程被杀残留):跳过。 partial_path.write_text("\n".join(lines) + "\n", encoding="utf-8") from nodes.subtitle_ocr import invoke as ocr_invoke fake = FakeVlmOcrApi(texts_by_frame, seed=99) monkeypatch.setattr("wov_app.registry.invoke", fake) response = ocr_invoke( InvokeRequest( run_id="resume_test", node_instance_id="", inputs={"frames_manifest": str(FULL_MANIFEST)}, params={"pool_min_workers": 4, "pool_max_workers": 4}, output_dir=str(out_dir), ) ) assert response.status == "completed", response.error srt = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8") assert srt == confirmed # 存档 + 新增合并结果与全量一致。 # 只处理未处理帧:前 100 帧不再调用 vlm-ocr。 # 场景 2:先用真实逻辑完整跑一遍(生成存档),再以同一目录续跑—— # 第二次 pending 为空,完全不调用 vlm-ocr,产物与第一次逐字节一致 # (覆盖 _load_partial 全恢复路径,存档文本与处理逻辑天然一致)。 out_dir_all = tmp_path / "resume_all" fake_first = FakeVlmOcrApi(texts_by_frame, seed=7) monkeypatch.setattr("wov_app.registry.invoke", fake_first) resp_first = ocr_invoke( InvokeRequest( run_id="resume_all_test", node_instance_id="", inputs={"frames_manifest": str(FULL_MANIFEST)}, params={"pool_min_workers": 4, "pool_max_workers": 4}, output_dir=str(out_dir_all), ) ) assert resp_first.status == "completed", resp_first.error srt_first = Path(resp_first.outputs["srt_uri"]).read_text(encoding="utf-8") assert srt_first == confirmed assert len(fake_first.completed_frames) == TOTAL_FRAMES fake_second = FakeVlmOcrApi(texts_by_frame, seed=8) monkeypatch.setattr("wov_app.registry.invoke", fake_second) resp_second = ocr_invoke( InvokeRequest( run_id="resume_all_test", node_instance_id="", inputs={"frames_manifest": str(FULL_MANIFEST)}, params={"pool_min_workers": 4, "pool_max_workers": 4}, output_dir=str(out_dir_all), ) ) assert resp_second.status == "completed", resp_second.error srt_second = Path(resp_second.outputs["srt_uri"]).read_text(encoding="utf-8") assert srt_second == srt_first # 存档恢复与一次跑完逐字节一致。 assert len(fake_second.completed_frames) == 0 # 一帧都不重新调用。 assert len(fake.completed_frames) == TOTAL_FRAMES - 100