"""预览视频标题渲染修复测试 — 覆盖3个断点。 断点1: generate_video() → _render_video() 传递 custom_title 断点2: _render_video() 解析 custom_title 并注入 virtual_plan.config["title"] 断点3: generate_ass_from_timeline() ASR路径也渲染标题 """ import json import time from pathlib import Path from unittest.mock import MagicMock, PropertyMock, patch import pytest # ── 断点2: _render_video 标题注入 ───────────────────────────────────────────── class TestRenderVideoCustomTitleInjection: """验证 _render_video 正确接收并注入 custom_title 到 virtual_plan.config['title']。""" @pytest.fixture def sample_custom_title(self): """模拟前端发送的 custom_title JSON(含 font_size/font_color)。""" return json.dumps( { "text": "测试标题", "font": "思源黑体", "font_size": 30, "font_color": "#FF0000", "position": "top", "bold": True, "stroke": True, "shadow": False, }, ensure_ascii=False, ) def _call_render_video_with_capture(self, custom_title, template_config=None, tmp_path=None): """调用 _render_video,在 RenderAdapter 处中断并捕获 virtual_plan.config。""" from worker_app.tasks.generation import _render_video captured_config = {} class FakePlan: def __init__(self): self.config = {} self.id = "test-plan" fake_plan = FakePlan() def capture_and_raise(*args, **kwargs): # 此时 title 已注入到 fake_plan.config captured_config.update(fake_plan.config or {}) raise RuntimeError("STOP_HERE") with ( patch("worker_app.tasks.generation._build_plan_and_clips_from_task") as mock_build, patch("worker_app.tasks.generation._load_template_plan_config", return_value=template_config), patch("worker_app.tasks.generation.time.monotonic", side_effect=[0.0, 1.0]), patch("video_processing.render_adapter.RenderAdapter") as mock_adapter_cls, ): mock_build.return_value = (fake_plan, [], {}) mock_adapter_cls.side_effect = capture_and_raise with pytest.raises(RuntimeError, match="STOP_HERE"): _render_video( task_id="test-task", downloaded_videos=[tmp_path / "v1.mp4"] if tmp_path else [Path("/tmp/v1.mp4")], voice_path=None, editing_mode=MagicMock(value="one_take"), project_id="proj-1", template_id="tpl-1", user_id="user-1", temp_path=tmp_path or Path("/tmp"), output_name="test_output", resolution="1280x720", bgm_config={}, voice_ids=[], custom_title=custom_title, ) return captured_config def test_custom_title_injected_into_plan_config(self, sample_custom_title, tmp_path): """custom_title JSON 应被解析并注入 virtual_plan.config['title']。""" config = self._call_render_video_with_capture(sample_custom_title, tmp_path=tmp_path) assert "title" in config title_cfg = config["title"] assert title_cfg["text"] == "测试标题" # 字段归一化: font_size → size assert title_cfg["size"] == 30 # 字段归一化: font_color → color assert title_cfg["color"] == "#FF0000" def test_custom_title_overrides_template_title(self, sample_custom_title, tmp_path): """用户自定义标题应覆盖模板默认标题。""" template_config = {"title": {"text": "模板默认标题", "size": 24}} config = self._call_render_video_with_capture( sample_custom_title, template_config=template_config, tmp_path=tmp_path ) # 用户标题应覆盖模板标题 assert config["title"]["text"] == "测试标题" assert config["title"]["size"] == 30 def test_empty_custom_title_no_injection(self, tmp_path): """空 custom_title 不应注入 title 字段。""" config = self._call_render_video_with_capture("", tmp_path=tmp_path) assert "title" not in config def test_malformed_custom_title_gracefully_ignored(self, tmp_path): """非法 JSON 不应崩溃,应跳过注入。""" config = self._call_render_video_with_capture("{invalid json!!!", tmp_path=tmp_path) assert "title" not in config # ── 断点3: generate_ass_from_timeline ASR路径支持标题 ────────────────────────── class TestGenerateAssFromTimelineWithTitle: """验证 generate_ass_from_timeline 在有标题时生成包含 TitleStyle 的 ASS。""" def test_title_included_in_ass_output(self, tmp_path): """有 title_text 时,ASS 输出应包含 TitleStyle 和标题事件。""" from video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline timeline = SubtitleTimeline( segments=[ SubtitleSegment(start=0.0, end=2.0, text="你好世界"), ] ) ass_path = tmp_path / "test.ass" result = generate_ass_from_timeline( ass_path, timeline, video_width=1280, video_height=720, video_duration=10.0, subtitle_config={"font": "思源黑体", "size": 24}, title_text="我的标题", title_config={"font": "思源黑体", "size": 36, "color": "#FFFFFF", "position": "top"}, ) content = result.read_text(encoding="utf-8") # 应包含 TitleStyle assert "TitleStyle" in content # 应包含标题文本 assert "我的标题" in content # 也应包含 ASR 字幕 assert "你好世界" in content def test_no_title_no_title_style(self, tmp_path): """无标题时,ASS 输出不应包含 TitleStyle。""" from video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline timeline = SubtitleTimeline( segments=[ SubtitleSegment(start=0.0, end=2.0, text="只有字幕"), ] ) ass_path = tmp_path / "test.ass" result = generate_ass_from_timeline( ass_path, timeline, video_width=1280, video_height=720, video_duration=10.0, subtitle_config={}, title_text="", title_config={}, ) content = result.read_text(encoding="utf-8") assert "TitleStyle" not in content assert "只有字幕" in content def test_title_field_normalization_in_ass(self, tmp_path): """前端字段名 font_size/font_color 应被正确归一化。""" from video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline timeline = SubtitleTimeline(segments=[SubtitleSegment(start=0.0, end=2.0, text="test")]) ass_path = tmp_path / "test.ass" result = generate_ass_from_timeline( ass_path, timeline, video_width=1280, video_height=720, video_duration=10.0, subtitle_config={}, title_text="归一化测试", title_config={ "font_size": 30, # 前端字段名 "font_color": "#FF0000", # 前端字段名 "position": "top", }, ) content = result.read_text(encoding="utf-8") assert "TitleStyle" in content assert "归一化测试" in content def test_title_boolean_stroke_shadow_compat(self, tmp_path): """boolean stroke/shadow 应被兼容处理。""" from video_processing.subtitle_generator import generate_ass_from_timeline from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline timeline = SubtitleTimeline(segments=[SubtitleSegment(start=0.0, end=2.0, text="test")]) ass_path = tmp_path / "test.ass" result = generate_ass_from_timeline( ass_path, timeline, video_width=1280, video_height=720, video_duration=10.0, subtitle_config={}, title_text="描边测试", title_config={ "size": 36, "stroke": True, # boolean "shadow": False, # boolean }, ) content = result.read_text(encoding="utf-8") assert "TitleStyle" in content assert "描边测试" in content # ── 断点1: _render_video 签名包含 custom_title ──────────────────────────────── class TestRenderVideoSignature: """验证 _render_video 函数签名正确。""" def test_custom_title_parameter_exists(self): """_render_video 应有 custom_title 参数,默认空字符串。""" import inspect from worker_app.tasks.generation import _render_video sig = inspect.signature(_render_video) assert "custom_title" in sig.parameters assert sig.parameters["custom_title"].default == ""