1
0
Files
PPTX/tests/unit/test_presentation.py
lanyuanxiaoyao 5d60f3c2c2 feat: 实现模板元素混合模式功能
新增混合模式,允许幻灯片同时使用 template 和 elements,实现更灵活的布局组合。

核心变更:
- core/presentation.py: 修改 render_slide() 支持三种模式(纯模板/纯自定义/混合模式)
- 自定义元素可访问模板变量,实现主题色等值的统一控制
- 元素采用简单追加策略合并(模板元素在前,自定义元素在后)
- 完全向后兼容现有用法

测试覆盖:
- 新增 TestRenderSlideHybridMode 测试类,包含 8 个测试用例
- 验证向后兼容性(纯模板模式、纯自定义模式)
- 验证混合模式功能(变量共享、空元素列表、元素顺序等)
- 所有 79 个测试通过

文档更新:
- README.md: 新增"混合模式模板"章节,包含语法示例和使用场景
- README_DEV.md: 更新开发文档,说明元素合并策略和实现细节

规范更新:
- openspec/specs/template-system/spec.md:
  - 修改"系统必须支持自定义幻灯片"需求,支持混合模式
  - 新增 4 个需求:变量共享、元素合并策略、向后兼容、内联模板支持
  - 新增 13 个场景定义

归档:
- openspec/changes/archive/2026-03-04-template-element-composition/: 完整变更记录
2026-03-04 13:12:51 +08:00

603 lines
18 KiB
Python
Raw 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.
"""
Presentation 类单元测试
测试 Presentation 类的初始化、模板缓存和渲染方法
"""
import pytest
from pathlib import Path
from unittest.mock import patch, Mock
from core.presentation import Presentation
from loaders.yaml_loader import YAMLError
class TestPresentationInit:
"""Presentation 初始化测试类"""
def test_init_with_valid_yaml(self, sample_yaml):
"""测试使用有效 YAML 初始化"""
pres = Presentation(str(sample_yaml))
assert pres.data is not None
assert "slides" in pres.data
assert pres.pres_file == sample_yaml
def test_init_with_templates_dir(self, sample_yaml, sample_template):
"""测试带模板目录初始化"""
pres = Presentation(str(sample_yaml), str(sample_template))
assert pres.templates_dir == str(sample_template)
assert isinstance(pres.template_cache, dict)
def test_init_without_templates_dir(self, sample_yaml):
"""测试不带模板目录初始化"""
pres = Presentation(str(sample_yaml))
assert pres.templates_dir is None
assert isinstance(pres.template_cache, dict)
@patch("core.presentation.load_yaml_file")
@patch("core.presentation.validate_presentation_yaml")
def test_init_loads_yaml(self, mock_validate, mock_load, temp_dir):
"""测试初始化时加载 YAML"""
mock_data = {"slides": [{"elements": []}]}
mock_load.return_value = mock_data
yaml_path = temp_dir / "test.yaml"
pres = Presentation(str(yaml_path))
mock_load.assert_called_once_with(str(yaml_path))
mock_validate.assert_called_once()
def test_init_with_invalid_yaml(self, temp_dir):
"""测试使用无效 YAML 初始化"""
invalid_yaml = temp_dir / "invalid.yaml"
invalid_yaml.write_text("invalid: [unclosed")
with pytest.raises(Exception):
Presentation(str(invalid_yaml))
class TestPresentationSize:
"""Presentation size 属性测试类"""
def test_size_16_9(self, temp_dir):
"""测试 16:9 尺寸"""
yaml_content = """
metadata:
size: "16:9"
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
assert pres.size == "16:9"
def test_size_4_3(self, temp_dir):
"""测试 4:3 尺寸"""
yaml_content = """
metadata:
size: "4:3"
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
assert pres.size == "4:3"
def test_size_default(self, temp_dir):
"""测试默认尺寸"""
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
assert pres.size == "16:9" # 默认值
def test_size_without_metadata(self, temp_dir):
"""测试无 metadata 时的默认尺寸"""
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
assert pres.size == "16:9"
class TestGetTemplate:
"""get_template 方法测试类"""
@patch("core.presentation.Template")
def test_get_template_caches_template(self, mock_template_class, sample_template):
"""测试模板被缓存"""
mock_template = Mock()
mock_template_class.return_value = mock_template
# 创建一个使用模板的 YAML
yaml_content = """
slides:
- elements: []
"""
yaml_path = sample_template / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
# 第一次获取
template1 = pres.get_template("test_template")
# 第二次获取
template2 = pres.get_template("test_template")
# 应该是同一个实例
assert template1 is template2
@patch("core.presentation.Template")
def test_get_template_creates_new_template(
self, mock_template_class, sample_template
):
"""测试创建新模板"""
mock_template = Mock()
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = sample_template / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
# 获取模板
template = pres.get_template("new_template")
# 应该创建模板
mock_template_class.assert_called_once()
assert template == mock_template
def test_get_template_without_templates_dir(self, sample_yaml):
"""测试无模板目录时获取模板"""
pres = Presentation(str(sample_yaml))
# 应该在调用 Template 时失败,而不是 get_template
with patch("core.presentation.Template") as mock_template_class:
mock_template_class.side_effect = YAMLError("No template dir")
with pytest.raises(YAMLError):
pres.get_template("test")
class TestRenderSlide:
"""render_slide 方法测试类"""
def test_render_slide_without_template(self, sample_yaml):
"""测试渲染不使用模板的幻灯片"""
pres = Presentation(str(sample_yaml))
slide_data = {
"elements": [
{"type": "text", "content": "Test", "box": [0, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
assert "elements" in result
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Test"
@patch("core.presentation.Template")
def test_render_slide_with_template(
self, mock_template_class, temp_dir, sample_template
):
"""测试渲染使用模板的幻灯片"""
mock_template = Mock()
mock_template.render.return_value = [
{
"type": "text",
"content": "Template Title",
"box": [0, 0, 1, 1],
"font": {},
}
]
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {"template": "title-slide", "vars": {"title": "My Title"}}
result = pres.render_slide(slide_data)
# 模板应该被渲染
mock_template.render.assert_called_once_with({"title": "My Title"})
assert "elements" in result
def test_render_slide_with_background(self, sample_yaml):
"""测试渲染带背景的幻灯片"""
pres = Presentation(str(sample_yaml))
slide_data = {"background": {"color": "#ffffff"}, "elements": []}
result = pres.render_slide(slide_data)
assert result["background"] == {"color": "#ffffff"}
def test_render_slide_without_background(self, sample_yaml):
"""测试渲染无背景的幻灯片"""
pres = Presentation(str(sample_yaml))
slide_data = {"elements": []}
result = pres.render_slide(slide_data)
assert result["background"] is None
@patch("core.presentation.create_element")
def test_render_slide_converts_dict_to_objects(
self, mock_create_element, sample_yaml
):
"""测试字典转换为元素对象"""
mock_elem = Mock()
mock_create_element.return_value = mock_elem
pres = Presentation(str(sample_yaml))
slide_data = {
"elements": [
{"type": "text", "content": "Test", "box": [0, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# create_element 应该被调用
mock_create_element.assert_called()
assert result["elements"][0] == mock_elem
def test_render_slide_with_template_merges_background(
self, mock_template_class, temp_dir, sample_template
):
"""测试使用模板时合并背景"""
mock_template = Mock()
mock_template.render.return_value = [
{"type": "text", "content": "Title", "box": [0, 0, 1, 1], "font": {}}
]
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "test",
"vars": {},
"background": {"color": "#ff0000"},
}
result = pres.render_slide(slide_data)
# 背景应该被保留
assert result["background"] == {"color": "#ff0000"}
def test_render_slide_empty_elements_list(self, sample_yaml):
"""测试空元素列表"""
pres = Presentation(str(sample_yaml))
slide_data = {"elements": []}
result = pres.render_slide(slide_data)
assert result["elements"] == []
@patch("core.presentation.create_element")
def test_render_slide_with_multiple_elements(
self, mock_create_element, sample_yaml
):
"""测试多个元素"""
mock_elem1 = Mock()
mock_elem2 = Mock()
mock_create_element.side_effect = [mock_elem1, mock_elem2]
pres = Presentation(str(sample_yaml))
slide_data = {
"elements": [
{"type": "text", "content": "T1", "box": [0, 0, 1, 1], "font": {}},
{"type": "text", "content": "T2", "box": [1, 1, 1, 1], "font": {}},
]
}
result = pres.render_slide(slide_data)
assert len(result["elements"]) == 2
class TestRenderSlideHybridMode:
"""render_slide 混合模式测试类"""
@patch("core.presentation.Template")
def test_hybrid_mode_basic(self, mock_template_class, temp_dir, sample_template):
"""测试混合模式基本功能template + elements"""
mock_template = Mock()
mock_template.render.return_value = [
{"type": "text", "content": "From Template", "box": [0, 0, 1, 1], "font": {}}
]
mock_template.resolve_element.side_effect = lambda elem, vars: elem
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "test-template",
"vars": {"title": "Test"},
"elements": [
{"type": "text", "content": "Custom Element", "box": [2, 2, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该有 2 个元素1 个来自模板1 个自定义
assert len(result["elements"]) == 2
assert result["elements"][0].content == "From Template"
assert result["elements"][1].content == "Custom Element"
@patch("core.presentation.Template")
def test_hybrid_mode_variable_sharing(self, mock_template_class, temp_dir, sample_template):
"""测试自定义元素访问模板变量"""
mock_template = Mock()
mock_template.render.return_value = []
# 模拟 resolve_element 解析变量
def resolve_element_mock(elem, vars):
resolved = elem.copy()
if "content" in resolved and "{" in resolved["content"]:
resolved["content"] = resolved["content"].replace("{theme_color}", vars.get("theme_color", ""))
if "fill" in resolved and "{" in resolved["fill"]:
resolved["fill"] = resolved["fill"].replace("{theme_color}", vars.get("theme_color", ""))
return resolved
mock_template.resolve_element.side_effect = resolve_element_mock
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "test-template",
"vars": {"theme_color": "#3949ab"},
"elements": [
{"type": "shape", "fill": "{theme_color}", "box": [0, 0, 1, 1]}
]
}
result = pres.render_slide(slide_data)
# 自定义元素应该使用模板变量
assert result["elements"][0].fill == "#3949ab"
def test_hybrid_mode_empty_elements(self, temp_dir, sample_template):
"""测试空 elements 列表"""
yaml_content = """
slides:
- elements: []
templates:
test-template:
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [0, 0, 1, 1]
font: {}
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
slide_data = {
"template": "test-template",
"vars": {"title": "Test"},
"elements": []
}
result = pres.render_slide(slide_data)
# 空 elements 列表应该只渲染模板元素
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Test"
def test_backward_compat_template_only(self, temp_dir, sample_template):
"""测试向后兼容:纯模板模式"""
yaml_content = """
slides:
- elements: []
templates:
test-template:
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [0, 0, 1, 1]
font: {}
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
slide_data = {
"template": "test-template",
"vars": {"title": "Test"}
}
result = pres.render_slide(slide_data)
# 应该只有模板元素
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Test"
def test_backward_compat_custom_only(self, sample_yaml):
"""测试向后兼容:纯自定义元素模式"""
pres = Presentation(str(sample_yaml))
slide_data = {
"elements": [
{"type": "text", "content": "Custom", "box": [0, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该只有自定义元素
assert len(result["elements"]) == 1
assert result["elements"][0].content == "Custom"
def test_hybrid_mode_with_inline_template(self, temp_dir):
"""测试内联模板与自定义元素混合使用"""
yaml_content = """
slides:
- elements: []
templates:
test-template:
vars:
- name: title
elements:
- type: text
content: "{title}"
box: [0, 0, 1, 1]
font: {}
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path))
slide_data = {
"template": "test-template",
"vars": {"title": "Inline Template"},
"elements": [
{"type": "text", "content": "Custom", "box": [2, 2, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该有 2 个元素
assert len(result["elements"]) == 2
assert result["elements"][0].content == "Inline Template"
assert result["elements"][1].content == "Custom"
@patch("core.presentation.Template")
def test_hybrid_mode_with_external_template(self, mock_template_class, temp_dir, sample_template):
"""测试外部模板与自定义元素混合使用"""
mock_template = Mock()
mock_template.render.return_value = [
{"type": "text", "content": "External", "box": [0, 0, 1, 1], "font": {}}
]
mock_template.resolve_element.side_effect = lambda elem, vars: elem
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "external-template",
"vars": {},
"elements": [
{"type": "text", "content": "Custom", "box": [2, 2, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 应该有 2 个元素
assert len(result["elements"]) == 2
assert result["elements"][0].content == "External"
assert result["elements"][1].content == "Custom"
@patch("core.presentation.Template")
def test_hybrid_mode_element_order(self, mock_template_class, temp_dir, sample_template):
"""测试元素顺序:模板元素在前,自定义元素在后"""
mock_template = Mock()
mock_template.render.return_value = [
{"type": "text", "content": "Template1", "box": [0, 0, 1, 1], "font": {}},
{"type": "text", "content": "Template2", "box": [1, 0, 1, 1], "font": {}}
]
mock_template.resolve_element.side_effect = lambda elem, vars: elem
mock_template_class.return_value = mock_template
yaml_content = """
slides:
- elements: []
"""
yaml_path = temp_dir / "test.yaml"
yaml_path.write_text(yaml_content)
pres = Presentation(str(yaml_path), str(sample_template))
slide_data = {
"template": "test-template",
"vars": {},
"elements": [
{"type": "text", "content": "Custom1", "box": [2, 0, 1, 1], "font": {}},
{"type": "text", "content": "Custom2", "box": [3, 0, 1, 1], "font": {}}
]
}
result = pres.render_slide(slide_data)
# 验证顺序:模板元素在前
assert len(result["elements"]) == 4
assert result["elements"][0].content == "Template1"
assert result["elements"][1].content == "Template2"
assert result["elements"][2].content == "Custom1"
assert result["elements"][3].content == "Custom2"