""" PPTX 渲染器单元测试 测试 PptxGenerator 类的初始化和渲染方法 """ import pytest from pathlib import Path from unittest.mock import Mock, MagicMock, patch from renderers.pptx_renderer import PptxGenerator from loaders.yaml_loader import YAMLError from core.elements import TextElement, ImageElement, ShapeElement, TableElement class TestPptxGeneratorInit: """PptxGenerator 初始化测试类""" @patch("renderers.pptx_renderer.PptxPresentation") def test_init_with_16_9_size(self, mock_prs_class): """测试使用 16:9 尺寸初始化""" mock_prs = Mock() mock_prs_class.return_value = mock_prs gen = PptxGenerator(size="16:9") assert gen.prs == mock_prs # 验证属性被设置(具体值由 Inches 决定) assert hasattr(mock_prs, "slide_width") assert hasattr(mock_prs, "slide_height") @patch("renderers.pptx_renderer.PptxPresentation") def test_init_with_4_3_size(self, mock_prs_class): """测试使用 4:3 尺寸初始化""" mock_prs = Mock() mock_prs_class.return_value = mock_prs gen = PptxGenerator(size="4:3") assert gen.prs == mock_prs assert hasattr(mock_prs, "slide_width") assert hasattr(mock_prs, "slide_height") @patch("renderers.pptx_renderer.PptxPresentation") def test_init_with_default_size(self, mock_prs_class): """测试默认尺寸""" mock_prs = Mock() mock_prs_class.return_value = mock_prs gen = PptxGenerator() assert gen.prs == mock_prs @patch("renderers.pptx_renderer.PptxPresentation") def test_init_with_invalid_size_raises_error(self, mock_prs_class): """测试无效尺寸引发错误""" from loaders.yaml_loader import YAMLError with pytest.raises(YAMLError, match="不支持的尺寸比例"): PptxGenerator(size="21:9") class TestAddSlide: """add_slide 方法测试类""" @patch("renderers.pptx_renderer.PptxPresentation") def test_add_slide_creates_slide(self, mock_prs_class): """测试添加幻灯片""" mock_prs = Mock() mock_layout = Mock() mock_prs.slide_layouts = [None] * 6 + [mock_layout] + [None] mock_prs.slides.add_slide.return_value = Mock() mock_prs_class.return_value = mock_prs gen = PptxGenerator() slide_data = {"background": None, "elements": []} gen.add_slide(slide_data) # 验证添加了幻灯片 mock_prs.slides.add_slide.assert_called_once_with(mock_layout) @patch("renderers.pptx_renderer.PptxPresentation") def test_add_slide_with_background(self, mock_prs_class): """测试添加带背景的幻灯片""" mock_prs = Mock() mock_slide = Mock() mock_slide.background = Mock() mock_slide.background.fill = Mock() mock_prs.slide_layouts = [None] * 7 + [Mock()] mock_prs.slides.add_slide.return_value = mock_slide mock_prs_class.return_value = mock_prs gen = PptxGenerator() slide_data = {"background": {"color": "#ffffff"}, "elements": []} gen.add_slide(slide_data) # 验证背景被设置 assert ( mock_slide.background.fill.solid.called or mock_slide.background.fill.fore_color_rgb is not None ) @patch("renderers.pptx_renderer.PptxPresentation") def test_add_slide_without_background(self, mock_prs_class): """测试添加无背景的幻灯片""" mock_prs = Mock() mock_slide = Mock() mock_prs.slide_layouts = [None] * 7 + [Mock()] mock_prs.slides.add_slide.return_value = mock_slide mock_prs_class.return_value = mock_prs gen = PptxGenerator() slide_data = {"background": None, "elements": []} gen.add_slide(slide_data) # 不应该崩溃 assert True class TestRenderText: """_render_text 方法测试类""" @patch("renderers.pptx_renderer.PptxPresentation") def test_render_text_element(self, mock_prs_class): """测试渲染文本元素""" mock_slide = self._setup_mock_slide() mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() elem = TextElement( content="Test Content", box=[1, 2, 3, 1], font={"size": 18, "bold": True, "color": "#333333", "align": "center"}, ) gen._render_text(mock_slide, elem) # 验证添加了文本框 mock_slide.shapes.add_textbox.assert_called_once() @patch("renderers.pptx_renderer.PptxPresentation") def test_render_text_with_word_wrap(self, mock_prs_class): """测试文本自动换行""" mock_slide = self._setup_mock_slide() mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() elem = TextElement(content="Long text", box=[0, 0, 1, 1], font={}) gen._render_text(mock_slide, elem) # 验证文本框被创建 mock_slide.shapes.add_textbox.assert_called_once() @patch("renderers.pptx_renderer.PptxPresentation") def test_render_multiline_text_applies_font_size_to_all_paragraphs(self, mock_prs_class): """测试多行文本字体大小应用到所有段落""" # 创建包含 3 个段落的 mock slide mock_slide = self._setup_mock_slide(num_paragraphs=3) mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() # 多行文本(包含换行符) elem = TextElement( content="第一行\n第二行\n第三行", box=[1, 2, 3, 1], font={"size": 12}, ) gen._render_text(mock_slide, elem) # 验证所有 3 个段落的字体大小都被设置 mock_tf = mock_slide.shapes.add_textbox.return_value.text_frame assert len(mock_tf.paragraphs) == 3 for para in mock_tf.paragraphs: para.font.size = 12 # Mock 会记录这个调用 @patch("renderers.pptx_renderer.PptxPresentation") def test_render_multiline_text_applies_all_styles_to_all_paragraphs(self, mock_prs_class): """测试多行文本所有样式应用到所有段落""" # 创建包含 3 个段落的 mock slide mock_slide = self._setup_mock_slide(num_paragraphs=3) mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() # 多行文本,包含所有样式属性 elem = TextElement( content="第一行\n第二行\n第三行", box=[1, 2, 3, 1], font={"size": 14, "bold": True, "italic": True, "color": "#ff0000", "align": "center"}, ) gen._render_text(mock_slide, elem) # 验证所有段落的样式都被设置 mock_tf = mock_slide.shapes.add_textbox.return_value.text_frame assert len(mock_tf.paragraphs) == 3 for para in mock_tf.paragraphs: # 验证样式属性被访问(mock 会记录这些调用) _ = para.font.size _ = para.font.bold _ = para.font.italic _ = para.font.color.rgb _ = para.alignment @patch("renderers.pptx_renderer.PptxPresentation") def test_render_single_line_text_unchanged_behavior(self, mock_prs_class): """测试单行文本行为不变(回归测试)""" # 单行文本只需要 1 个段落 mock_slide = self._setup_mock_slide(num_paragraphs=1) mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() # 单行文本 elem = TextElement( content="单行文本", box=[1, 2, 3, 1], font={"size": 18}, ) gen._render_text(mock_slide, elem) # 验证文本框被创建 mock_slide.shapes.add_textbox.assert_called_once() # 验证字体大小被设置 mock_tf = mock_slide.shapes.add_textbox.return_value.text_frame assert len(mock_tf.paragraphs) == 1 _ = mock_tf.paragraphs[0].font.size def _setup_mock_slide(self, num_paragraphs=1): """辅助函数:创建 mock slide Args: num_paragraphs: 要创建的段落数量,默认为 1 """ mock_slide = Mock() mock_text_frame = Mock() mock_text_frame.word_wrap = True # 创建指定数量的 mock 段落 mock_paragraphs = [] for _ in range(num_paragraphs): mock_paragraph = Mock() mock_paragraph.font = Mock() mock_paragraphs.append(mock_paragraph) mock_text_frame.paragraphs = mock_paragraphs mock_textbox = Mock() mock_textbox.text_frame = mock_text_frame mock_slide.shapes.add_textbox.return_value = mock_textbox return mock_slide class TestRenderImage: """_render_image 方法测试类""" @patch("renderers.pptx_renderer.PptxPresentation") def test_render_image_element(self, mock_prs_class, temp_dir, sample_image): """测试渲染图片元素""" mock_slide = Mock() mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() elem = ImageElement(box=[1, 1, 4, 3], src=sample_image.name) gen._render_image(mock_slide, elem, temp_dir) # 验证添加了图片 mock_slide.shapes.add_picture.assert_called_once() @patch("renderers.pptx_renderer.PptxPresentation") def test_render_image_nonexistent_file(self, mock_prs_class): """测试不存在的图片文件""" mock_slide = Mock() mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() elem = ImageElement(box=[1, 1, 4, 3], src="nonexistent.png") with pytest.raises(YAMLError, match="图片文件未找到"): gen._render_image(mock_slide, elem, None) @patch("renderers.pptx_renderer.PptxPresentation") def test_render_image_with_relative_path( self, mock_prs_class, temp_dir, sample_image ): """测试相对路径图片""" mock_slide = Mock() mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() elem = ImageElement(box=[1, 1, 4, 3], src=sample_image.name) gen._render_image(mock_slide, elem, temp_dir) mock_slide.shapes.add_picture.assert_called_once() class TestRenderShape: """_render_shape 方法测试类""" @patch("renderers.pptx_renderer.PptxPresentation") def test_render_rectangle(self, mock_prs_class): """测试渲染矩形""" mock_slide = self._setup_mock_slide_for_shape() mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() elem = ShapeElement(box=[1, 1, 2, 1], shape="rectangle", fill="#4a90e2") gen._render_shape(mock_slide, elem) mock_slide.shapes.add_shape.assert_called_once() @patch("renderers.pptx_renderer.PptxPresentation") def test_render_ellipse(self, mock_prs_class): """测试渲染椭圆""" mock_slide = self._setup_mock_slide_for_shape() mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() elem = ShapeElement(box=[1, 1, 2, 2], shape="ellipse", fill="#e24a4a") gen._render_shape(mock_slide, elem) mock_slide.shapes.add_shape.assert_called_once() @patch("renderers.pptx_renderer.PptxPresentation") def test_render_shape_with_line(self, mock_prs_class): """测试带边框的形状""" mock_slide = self._setup_mock_slide_for_shape() mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() elem = ShapeElement( box=[1, 1, 2, 1], shape="rectangle", fill="#4a90e2", line={"color": "#000000", "width": 2}, ) gen._render_shape(mock_slide, elem) mock_slide.shapes.add_shape.assert_called_once() def _setup_mock_slide_for_shape(self): """辅助函数:创建用于形状渲染的 mock slide""" mock_slide = Mock() mock_shape = Mock() mock_shape.fill = Mock() mock_shape.fill.solid = Mock() mock_shape.line = Mock() mock_slide.shapes.add_shape.return_value = mock_shape return mock_slide class TestRenderTable: """_render_table 方法测试类""" @patch("renderers.pptx_renderer.PptxPresentation") def test_render_table_col_widths_mismatch(self, mock_prs_class): """测试列宽不匹配""" mock_slide = self._setup_mock_slide_for_table() mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() elem = TableElement( position=[1, 1], col_widths=[2, 3, 4], # 3 列 data=[["A", "B"]], # 2 列数据 ) with pytest.raises(YAMLError, match="列宽数量"): gen._render_table(mock_slide, elem) def _setup_mock_slide_for_table(self): """辅助函数:创建用于表格渲染的 mock slide""" mock_slide = Mock() # 设置 columns 属性,支持索引访问 class MockColumns: def __init__(self, count): self._cols = [Mock() for _ in range(count)] def __getitem__(self, i): return self._cols[i] mock_table = Mock() mock_table.columns = MockColumns(3) # 设置 add_table 返回值(包含 .table 属性) mock_add_table_result = Mock() mock_add_table_result.table = mock_table mock_slide.shapes.add_table.return_value = mock_add_table_result # 设置 rows mock_table.rows = [Mock()] for row in mock_table.rows: row.cells = [Mock()] for cell in row.cells: cell.text_frame = Mock() cell.text_frame.paragraphs = [Mock()] cell.text_frame.paragraphs[0].font = Mock() return mock_slide class TestSave: """save 方法测试类""" @patch("renderers.pptx_renderer.PptxPresentation") def test_save_creates_directory(self, mock_prs_class, tmp_path): """测试保存时创建目录""" mock_prs = Mock() mock_prs_class.return_value = mock_prs gen = PptxGenerator() output_dir = tmp_path / "subdir" / "nested" output_path = output_dir / "output.pptx" gen.save(output_path) # 验证目录被创建 assert output_dir.exists() mock_prs.save.assert_called_once() @patch("renderers.pptx_renderer.PptxPresentation") def test_save_existing_file(self, mock_prs_class, tmp_path): """测试保存已存在的文件""" mock_prs = Mock() mock_prs_class.return_value = mock_prs gen = PptxGenerator() output_path = tmp_path / "output.pptx" gen.save(output_path) mock_prs.save.assert_called_once_with(str(output_path)) class TestRenderBackground: """_render_background 方法测试类""" @patch("renderers.pptx_renderer.PptxPresentation") def test_render_solid_background(self, mock_prs_class): """测试纯色背景""" mock_slide = Mock() mock_slide.background = Mock() mock_slide.background.fill = Mock() mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() background = {"color": "#ffffff"} gen._render_background(mock_slide, background) # 验证背景被设置 assert mock_slide.background.fill.solid.called @patch("renderers.pptx_renderer.PptxPresentation") def test_render_no_background(self, mock_prs_class): """测试无背景""" mock_slide = Mock() mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() # 不应该崩溃 gen._render_background(mock_slide, None) class TestSetNotes: """_set_notes 方法测试类""" @patch("renderers.pptx_renderer.PptxPresentation") def test_set_notes_with_text(self, mock_prs_class): """测试设置备注文本""" mock_slide = Mock() mock_notes_slide = Mock() mock_text_frame = Mock() mock_notes_slide.notes_text_frame = mock_text_frame mock_slide.notes_slide = mock_notes_slide mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() gen._set_notes(mock_slide, "这是演讲备注内容") # 验证备注被设置 mock_text_frame.text = "这是演讲备注内容" @patch("renderers.pptx_renderer.PptxPresentation") def test_set_notes_with_none(self, mock_prs_class): """测试设置 None 不设置备注""" mock_slide = Mock(spec=[]) # 使用 spec=[] 禁止自动创建属性 mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() # 不应该崩溃,不应该访问 notes_slide gen._set_notes(mock_slide, None) # 使用 spec=[] 后,访问不存在的属性会抛出 AttributeError # 如果没有抛出异常,说明 notes_slide 没有被访问 @patch("renderers.pptx_renderer.PptxPresentation") def test_set_notes_with_empty_string(self, mock_prs_class): """测试设置空字符串""" mock_slide = Mock() mock_notes_slide = Mock() mock_text_frame = Mock() mock_notes_slide.notes_text_frame = mock_text_frame mock_slide.notes_slide = mock_notes_slide mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() gen._set_notes(mock_slide, "") # 验证空字符串也被设置 mock_text_frame.text = "" @patch("renderers.pptx_renderer.PptxPresentation") def test_set_notes_with_multiline_text(self, mock_prs_class): """测试设置多行文本""" mock_slide = Mock() mock_notes_slide = Mock() mock_text_frame = Mock() mock_notes_slide.notes_text_frame = mock_text_frame mock_slide.notes_slide = mock_notes_slide mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() multiline_text = "第一行备注\n第二行备注\n第三行备注" gen._set_notes(mock_slide, multiline_text) # 验证多行文本被设置 mock_text_frame.text = multiline_text @patch("renderers.pptx_renderer.PptxPresentation") def test_set_notes_with_chinese_text(self, mock_prs_class): """测试设置中文备注""" mock_slide = Mock() mock_notes_slide = Mock() mock_text_frame = Mock() mock_notes_slide.notes_text_frame = mock_text_frame mock_slide.notes_slide = mock_notes_slide mock_prs_class.return_value = Mock() mock_prs_class.return_value.slide_layouts = [None] * 7 + [Mock()] mock_prs_class.return_value.slides.add_slide.return_value = mock_slide gen = PptxGenerator() chinese_text = "这是中文演讲备注内容,用于演示时参考" gen._set_notes(mock_slide, chinese_text) # 验证中文文本被设置 mock_text_frame.text = chinese_text class TestAddSlideWithNotes: """add_slide 方法备注功能测试类""" @patch("renderers.pptx_renderer.PptxPresentation") def test_add_slide_with_description_sets_notes(self, mock_prs_class): """测试幻灯片包含 description 时设置备注""" mock_slide = Mock() mock_notes_slide = Mock() mock_text_frame = Mock() mock_notes_slide.notes_text_frame = mock_text_frame mock_slide.notes_slide = mock_notes_slide mock_prs = Mock() mock_prs.slide_layouts = [None] * 6 + [Mock()] + [None] mock_prs.slides.add_slide.return_value = mock_slide mock_prs_class.return_value = mock_prs gen = PptxGenerator() slide_data = {"background": None, "elements": [], "description": "这是幻灯片的演讲说明"} gen.add_slide(slide_data) # 验证备注被设置 mock_text_frame.text = "这是幻灯片的演讲说明" @patch("renderers.pptx_renderer.PptxPresentation") def test_add_slide_without_description_no_notes(self, mock_prs_class): """测试幻灯片不包含 description 时不设置备注""" mock_slide = Mock(spec=[]) # 禁止自动创建属性 mock_prs = Mock() mock_prs.slide_layouts = [None] * 6 + [Mock()] + [None] mock_prs.slides.add_slide.return_value = mock_slide mock_prs_class.return_value = mock_prs gen = PptxGenerator() slide_data = {"background": None, "elements": []} gen.add_slide(slide_data) # 不应该访问 notes_slide(使用 spec=[] 会抛出异常如果被访问) @patch("renderers.pptx_renderer.PptxPresentation") def test_add_slide_with_template_description_ignored(self, mock_prs_class): """测试模板的 description 不被用作备注(仅幻灯片自己的 description 有效)""" mock_slide = Mock(spec=[]) # 禁止自动创建属性 mock_prs = Mock() mock_prs.slide_layouts = [None] * 6 + [Mock()] + [None] mock_prs.slides.add_slide.return_value = mock_slide mock_prs_class.return_value = mock_prs gen = PptxGenerator() # 幻灯片没有 description,则不设置备注(即使模板可能有 description) slide_data = {"background": None, "elements": []} gen.add_slide(slide_data) # 不应该访问 notes_slide(使用 spec=[] 会抛出异常如果被访问)