"""标题渲染前后端一致性测试。 验证 build_ass_content 生成的 ASS 样式参数与前端 drawTitleOnCanvas.ts 一致: - 字号上限 36px - 描边宽度 2px - 阴影 blur=4, offset=2 - boolean stroke/shadow 自动转换 """ import sys from pathlib import Path # 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 TestFontSizeCap: """字号上限应与前端 Math.min(settings.size, 36) 一致。""" 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 ",36," in content, f"默认字号应为36,实际内容: {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 ",32," in content def test_size_60_capped_at_36(self): """size=60 应被 cap 到 36。""" config = {"size": 60} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) # 解析 Style 行的 Fontsize 字段(第3个字段,索引2) 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 == 36, f"字号60应被cap到36, 实际={font_size}" def test_size_24_preserved(self): """size=24 应原样使用(小于36,不cap)。""" config = {"size": 24} content = build_ass_content( video_width=1080, video_height=1920, video_duration=10.0, title_text="测试标题", title_config=config, ) assert ",24," in content 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] == "思源黑体" # Fontsize = 28 (小于36,不cap) assert fields[2] == "28" # 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