"""标题渲染前后端一致性测试。 验证 build_ass_content 生成的 ASS 样式参数与前端 drawTitleOnCanvas.ts 一致: - 字号不再设置上限,由前端/调用方控制 - 描边宽度 2px - 阴影 blur=4, offset=2 - boolean stroke/shadow 自动转换 """ import sys from pathlib import Path import pytest # Ensure packages is importable sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "packages")) from domain.ass_subtitle_builder import build_ass_content, build_ass_style class TestFontSize: """字号处理:默认值与保留逻辑,不再做上限截断。""" def test_default_font_size_is_36(self): """无 size 字段时,默认字号应为 36。""" config = {"text": "test"} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) assert ",49," in content, f"默认字号36应补偿为49(36*1.35),实际内容: {content}" def test_size_32_preserved(self): """size=32 应原样使用。""" config = {"size": 32} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) assert ",43," in content # 32*1.35=43 def test_size_60_preserved(self): """size=60 应原样保留(字号上限已移除)。""" config = {"size": 60} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] font_size = int(fields[2]) assert font_size == 81, f"字号60应补偿为81(60*1.35), 实际={font_size}" def test_font_size_alias_normalized(self): """前端传 font_size 应归一化为 size。""" config = {"font_size": 52} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] assert fields[2] == "70", f"font_size=52 应补偿为70(52*1.35), 实际={fields[2]}" def test_font_color_alias_normalized(self): """前端传 font_color 应归一化为 color。""" config = {"font_color": "#00FF00"} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) # 绿色 → &H00FF00 assert "&H00FF00" in content def test_size_24_preserved(self): """size=24 应原样使用。""" config = {"size": 24} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) assert ",32," in content # 24*1.35=32.4→32 class TestBooleanStrokeNormalization: """前端 stroke=true/false 应自动转换为标准 dict。""" def test_stroke_true_enables_outline(self): """stroke=true 应生成 outline_width=2 的样式。""" config = {"stroke": True} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) # 解析 Style 行的 Outline 字段(第17个字段,索引16) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] outline_width = float(fields[16]) assert outline_width == 2.0, f"stroke=true 应产生 outline_width=2, 实际={outline_width}" def test_stroke_false_no_outline(self): """stroke=false 应生成 outline_width=0 的样式。""" config = {"stroke": False} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] outline_width = float(fields[16]) assert outline_width == 0.0, f"stroke=false 应产生 outline_width=0, 实际={outline_width}" def test_stroke_dict_still_works(self): """stroke={enabled:true, width:3} 仍应正常工作。""" config = {"stroke": {"enabled": True, "width": 3, "color": "#FF0000"}} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] outline_width = float(fields[16]) assert outline_width == 3.0, f"自定义stroke width=3 应保留, 实际={outline_width}" class TestBooleanShadowNormalization: """前端 shadow=true/false 应自动转换为标准 dict。""" def test_shadow_true_enables_shadow(self): """shadow=true 应生成 shadow_depth=2 的样式。""" config = {"shadow": True} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] # Shadow 字段是第18个(索引17) shadow_depth = int(fields[17]) assert shadow_depth == 2, f"shadow=true 应产生 shadow_depth=2, 实际={shadow_depth}" def test_shadow_false_no_shadow(self): """shadow=false 应生成 shadow_depth=0 的样式。""" config = {"shadow": False} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] shadow_depth = int(fields[17]) assert shadow_depth == 0, f"shadow=false 应产生 shadow_depth=0, 实际={shadow_depth}" def test_shadow_dict_still_works(self): """shadow={enabled:true, blur:8} 仍应正常工作。""" config = {"shadow": {"enabled": True, "blur": 8, "offset_x": 3, "offset_y": 3}} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] shadow_depth = int(fields[17]) assert shadow_depth == 3, f"自定义shadow offset_y=3 应保留, 实际={shadow_depth}" class TestFullStyleConsistency: """完整样式参数一致性测试。""" def test_frontend_default_style_matches_backend(self): """前端默认样式参数应在后端产生一致的 ASS 输出。 前端默认:font_size=24(或用户设置), bold=false, stroke=true, shadow=true, color=#FFFFFF """ config = { "text": "标题文本", "font": "思源黑体", "size": 28, "color": "#FFFFFF", "bold": True, "italic": False, "stroke": True, "shadow": True, "position": "top", } content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="标题文本", title_config=config, ) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] # Fontname assert fields[1] == "Noto Sans SC" # Fontsize = 28*1.35=37.8→38 assert fields[2] == "38" # Bold = -1 (True) assert fields[7] == "-1" # Outline width = 2 (前端默认 stroke width) assert float(fields[16]) == 2.0 # Shadow depth = 2 (offset_y) assert int(fields[17]) == 2 # Alignment = 8 (top) assert int(fields[18]) == 8 class TestTitleSlashNewline: """用户输入 / 或 / 应触发标题换行。""" def test_halfwidth_slash_in_title(self): content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="第一行/第二行", title_config={"size": 48}, ) for line in content.splitlines(): if "Dialogue" in line and "TitleStyle" in line: assert "\\N" in line, f"斜杠应转为换行: {line}" assert "第一行" in line assert "第二行" in line break else: pytest.fail("未找到 TitleStyle Dialogue 行") def test_fullwidth_slash_in_title(self): content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="第一行/第二行", title_config={"size": 48}, ) for line in content.splitlines(): if "Dialogue" in line and "TitleStyle" in line: assert "\\N" in line, f"全角斜杠应转为换行: {line}" break else: pytest.fail("未找到 TitleStyle Dialogue 行")