test: 按模块重写测试代码,删除旧平铺结构
按"测试规则"重写 tests/:一个模块一个目录、用例按数据→过程→验证三段书写、 不保留全局 conftest.py、测试过程只调用真实生产代码。 结构(73 个文件、30 个模块目录、477 用例): - tests/nodes/ 15 个模块目录(srt/whisper/ass/ffmpeg/frame_extract/vlm/ subtitle_ocr/llm/llm_filter/subtitle_cleanup/subtitle_correction/ proper_nouns/adaptive_pool/vad_profiler/echo); - tests/app/ 11 个模块目录(db/scheduler/batch/maintenance/registry/seed/ storage/config/logging/main/routers 三组 API); - tests/sdk/test_models、tests/web/test_crop、tests/shared(公共设施)。 测试数据随模块目录入库(tests/**/data/),删除根级 testdata/;.gitignore 的 data/ 改为 /data/,否则会连带忽略 tests/**/data/ 导致测试数据无法入库。 顺带发现并修复三个真实缺陷: - nodes/srt.py:相邻条目缺少空行时把下一条时间轴吞进正文(静默错位), 改为正文行遇时间戳行即报错; - src/wov_app/scheduler.py:_file_size 只捕获 OSError,含 \x00 的产物 URI 抛 ValueError 导致任务误判失败,改为同时捕获; - nodes/subtitle_correction.py:生产代码依赖测试包解析 SRT, 改用生产模块 nodes/srt.py。 真实模型/服务集成测试按外部状态跳过:新增 tests/shared/gpu_memory.py (运行时探测显存、CUDA OOM 转跳过)与 tests/shared/llm_service.py (无 Key / 余额 / 限流转跳过)。全量 477 passed。
This commit is contained in:
@@ -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) # 每条一个空行分隔
|
||||
Reference in New Issue
Block a user