Files
cat-shark 7a7212f70c docs: 注释规范要求精简可读,并清理生产代码中的历史叙事
AGENTS.md 的注释规范新增三节可执行约束:

- 只写代码真实逻辑:注释只回答"做什么"与"为什么必须这么做",禁止写决策/
  修改时间、历史版本对比、实测数据与实验结论、事故与缺陷编号(run_xxxx /
  batch_xxxx / R01 等)——这些属 docs/decisions.md 与审查跟踪文件;当前生效
  的约束可以写,但不附带它何时因何变成这样。
- 精简可读:单段连续注释不超过 3 行;docstring 一句话概括职责,不重复函数名
  已表达的信息;不写逐行翻译代码的废话注释,只在非显然处(业务规则、边界、
  易错点、外部约束)加注。
- 覆盖范围:测试注释只说明验证什么行为,回归用例可保留一句溯源;并明确
  参数说明应写在**参数读取处**附近,而不是把多个参数的解释堆在离使用位置
  很远的注释块里。

按此清理生产代码(注释净减 70 行,18 个文件),典型处理:

- nodes/whisper.py:删掉堆在一起、含"用户 2026-08 决定 / 实测 savr-1054"
  等叙事的参数块,把各参数说明移到各自的读取处与 model.transcribe 调用处;
- nodes/llm_filter.py、nodes/subtitle_cleanup.py:模块 docstring 去掉英文
  背景叙事与条数统计,保留"默认只跑规则层""整条删除而非 '-' 占位"等当前
  行为;
- src/wov_app/{batch,db,scheduler}.py 与 routers:去掉 batch_xxx/run_xxx 事故
  编号与"修复前……"对比,改为一句"否则会出现什么问题";
- nodes/ass.py、frame_extract.py:去掉废弃值对比与日期,保留判据本身。

安全验证:用 AST 对比(剥离 docstring 后比较语法树)确认 18 个文件**零逻辑
变更**;`nodes/proper_nouns.py` 的规则表 reason 字段会注入 LLM 提示词,属于
数据而非注释,已恢复原值。全量测试 476 passed。
2026-09-13 16:37:49 +08:00

289 lines
9.7 KiB
Python
Raw Permalink 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.
"""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(样式单一事实来源,改动需同步文档)。"""
# 数据:模块常量。
# 测试过程与验证结果
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")
# 验证结果:两条 DialogueLeftEye/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 "")