feat: 添加内联模板支持
支持在 YAML 源文件中直接定义模板,无需单独的模板文件。 简化单文档编写流程,降低模板系统使用门槛。 核心功能: - 在 YAML 顶层新增 templates 字段定义内联模板 - 支持变量替换、条件渲染、默认值等完整模板功能 - 内联模板优先于外部模板查找 - 同名冲突检测:禁止内联和外部模板同名 - 相互引用检测:禁止内联模板之间相互引用 - 完整的错误处理和验证机制 代码变更: - core/template.py: 新增 from_data() 类方法 - core/presentation.py: 支持内联模板查找和冲突检测 - loaders/yaml_loader.py: 新增 validate_templates_yaml() 验证 - validators/: 扩展验证器支持内联模板 测试: - 新增 9 个内联模板专项测试 - 修复 1 个变量验证测试 - 所有 333 个测试通过 文档: - README.md: 添加内联模板使用指南和最佳实践 - README_DEV.md: 说明实现细节和设计决策 完全向后兼容,不使用 templates 字段时行为不变。
This commit is contained in:
23
tests/fixtures/templates/title-slide.yaml
vendored
Normal file
23
tests/fixtures/templates/title-slide.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
vars:
|
||||
- name: title
|
||||
required: true
|
||||
- name: subtitle
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 2, 8, 1]
|
||||
content: "{title}"
|
||||
font:
|
||||
size: 44
|
||||
bold: true
|
||||
align: center
|
||||
|
||||
- type: text
|
||||
box: [1, 3.5, 8, 0.5]
|
||||
content: "{subtitle}"
|
||||
visible: "{subtitle != ''}"
|
||||
font:
|
||||
size: 24
|
||||
align: center
|
||||
@@ -449,3 +449,425 @@ elements:
|
||||
assert result[0]["font"]["size"] == 24
|
||||
assert result[0]["font"]["color"] == "#ff0000"
|
||||
assert result[0]["font"]["color"] == "#ff0000"
|
||||
|
||||
|
||||
# ============= Template.from_data() 方法测试 =============
|
||||
|
||||
class TestTemplateFromData:
|
||||
"""Template.from_data() 类方法测试类"""
|
||||
|
||||
def test_from_data_with_valid_template(self):
|
||||
"""测试从字典创建模板"""
|
||||
template_data = {
|
||||
'vars': [
|
||||
{'name': 'title', 'required': True},
|
||||
{'name': 'subtitle', 'required': False, 'default': ''}
|
||||
],
|
||||
'elements': [
|
||||
{'type': 'text', 'box': [1, 2, 8, 1], 'content': '{title}'},
|
||||
{'type': 'text', 'box': [1, 3.5, 8, 0.5], 'content': '{subtitle}', 'visible': "{subtitle != ''}"}
|
||||
]
|
||||
}
|
||||
template = Template.from_data(template_data, 'test-template')
|
||||
|
||||
assert template.data == template_data
|
||||
assert template.vars_def == {'title': {'name': 'title', 'required': True}, 'subtitle': {'name': 'subtitle', 'required': False, 'default': ''}}
|
||||
assert template.elements == template_data['elements']
|
||||
assert len(template.elements) == 2
|
||||
|
||||
def test_from_data_without_vars(self):
|
||||
"""测试没有 vars 的模板"""
|
||||
template_data = {
|
||||
'elements': [
|
||||
{'type': 'text', 'box': [1, 2, 8, 1], 'content': 'Static Content'}
|
||||
]
|
||||
}
|
||||
template = Template.from_data(template_data, 'no-vars-template')
|
||||
|
||||
assert template.vars_def == {}
|
||||
assert len(template.elements) == 1
|
||||
|
||||
def test_from_data_with_empty_vars(self):
|
||||
"""测试 vars 为空列表的模板"""
|
||||
template_data = {
|
||||
'vars': [],
|
||||
'elements': [
|
||||
{'type': 'text', 'box': [1, 2, 8, 1], 'content': 'Static'}
|
||||
]
|
||||
}
|
||||
template = Template.from_data(template_data, 'empty-vars-template')
|
||||
|
||||
assert template.vars_def == {}
|
||||
|
||||
def test_from_data_render_with_vars(self):
|
||||
"""测试 from_data 创建的模板可以正常渲染"""
|
||||
template_data = {
|
||||
'vars': [
|
||||
{'name': 'title', 'required': True}
|
||||
],
|
||||
'elements': [
|
||||
{'type': 'text', 'box': [1, 2, 8, 1], 'content': '{title}'}
|
||||
]
|
||||
}
|
||||
template = Template.from_data(template_data, 'test-template')
|
||||
result = template.render({'title': 'My Title'})
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]['content'] == 'My Title'
|
||||
|
||||
def test_from_data_render_with_default_value(self):
|
||||
"""测试 from_data 创建的模板支持默认值"""
|
||||
template_data = {
|
||||
'vars': [
|
||||
{'name': 'title', 'required': True},
|
||||
{'name': 'subtitle', 'required': False, 'default': 'Default Subtitle'}
|
||||
],
|
||||
'elements': [
|
||||
{'type': 'text', 'box': [1, 2, 8, 1], 'content': '{title}'},
|
||||
{'type': 'text', 'box': [1, 3.5, 8, 0.5], 'content': '{subtitle}', 'visible': "{subtitle != ''}"}
|
||||
]
|
||||
}
|
||||
template = Template.from_data(template_data, 'test-template')
|
||||
|
||||
# 不提供 subtitle,应该使用默认值
|
||||
result = template.render({'title': 'My Title'})
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[1]['content'] == 'Default Subtitle'
|
||||
|
||||
def test_from_data_with_conditional_element(self):
|
||||
"""测试 from_data 创建的模板支持条件渲染"""
|
||||
template_data = {
|
||||
'vars': [
|
||||
{'name': 'subtitle', 'required': False, 'default': ''}
|
||||
],
|
||||
'elements': [
|
||||
{'type': 'text', 'box': [1, 2, 8, 1], 'content': '{title}'},
|
||||
{'type': 'text', 'box': [1, 3.5, 8, 0.5], 'content': '{subtitle}', 'visible': "{subtitle != ''}"}
|
||||
]
|
||||
}
|
||||
template = Template.from_data(template_data, 'test-template')
|
||||
|
||||
# 不提供 subtitle,元素应该被过滤
|
||||
result = template.render({'title': 'Main', 'subtitle': ''})
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]['content'] == 'Main'
|
||||
|
||||
# 提供 subtitle,元素应该被显示
|
||||
result = template.render({'title': 'Main', 'subtitle': 'Subtitle'})
|
||||
assert len(result) == 2
|
||||
|
||||
def test_from_data_with_nested_variable_in_default(self):
|
||||
"""测试 from_data 创建的模板支持嵌套变量默认值"""
|
||||
template_data = {
|
||||
'vars': [
|
||||
{'name': 'title', 'required': True},
|
||||
{'name': 'full_title', 'required': False, 'default': '{title} - Extended'}
|
||||
],
|
||||
'elements': [
|
||||
{'type': 'text', 'box': [1, 2, 8, 1], 'content': '{full_title}'}
|
||||
]
|
||||
}
|
||||
template = Template.from_data(template_data, 'test-template')
|
||||
result = template.render({'title': 'Main'})
|
||||
|
||||
# 默认值中的 {title} 应该被替换
|
||||
assert result[0]['content'] == 'Main - Extended'
|
||||
|
||||
def test_from_data_rejects_template_reference_in_elements(self):
|
||||
"""测试内联模板元素中引用其他模板应该被拒绝"""
|
||||
template_data = {
|
||||
'vars': [
|
||||
{'name': 'title', 'required': True}
|
||||
],
|
||||
'elements': [
|
||||
{'type': 'text', 'box': [1, 2, 8, 1], 'content': '{title}', 'template': 'other-template'}
|
||||
]
|
||||
}
|
||||
template = Template.from_data(template_data, 'test-template')
|
||||
|
||||
# 渲染时应该抛出错误
|
||||
with pytest.raises(YAMLError, match="内联模板不支持相互引用"):
|
||||
template.render({'title': 'My Title'})
|
||||
|
||||
class TestInlineTemplates:
|
||||
"""内联模板功能集成测试类"""
|
||||
|
||||
def test_presentation_with_inline_templates(self, temp_dir):
|
||||
"""测试演示文稿包含内联模板定义"""
|
||||
yaml_content = '''
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
templates:
|
||||
my-template:
|
||||
vars:
|
||||
- name: title
|
||||
required: true
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 2, 8, 1]
|
||||
content: "{title}"
|
||||
|
||||
slides:
|
||||
- template: my-template
|
||||
vars:
|
||||
title: "内联模板测试"
|
||||
'''
|
||||
yaml_file = temp_dir / "inline_test.yaml"
|
||||
yaml_file.write_text(yaml_content, encoding='utf-8')
|
||||
|
||||
from core.presentation import Presentation
|
||||
pres = Presentation(str(yaml_file))
|
||||
|
||||
assert 'my-template' in pres.inline_templates
|
||||
assert len(pres.inline_templates) == 1
|
||||
|
||||
def test_presentation_without_inline_templates(self, temp_dir):
|
||||
"""测试演示文稿不包含内联模板定义"""
|
||||
yaml_content = '''
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 2, 8, 1]
|
||||
content: "直接元素"
|
||||
'''
|
||||
yaml_file = temp_dir / "no_inline_test.yaml"
|
||||
yaml_file.write_text(yaml_content, encoding='utf-8')
|
||||
|
||||
from core.presentation import Presentation
|
||||
pres = Presentation(str(yaml_file))
|
||||
|
||||
assert hasattr(pres, 'inline_templates')
|
||||
assert pres.inline_templates == {}
|
||||
|
||||
class TestInlineTemplateNameConflict:
|
||||
"""内联和外部模板同名冲突测试类"""
|
||||
|
||||
def test_inline_and_external_template_same_name_raises_error(self, temp_dir):
|
||||
"""测试内联和外部模板同名时抛出 ERROR"""
|
||||
from core.presentation import Presentation
|
||||
|
||||
# 创建外部模板
|
||||
templates_dir = temp_dir / "templates"
|
||||
templates_dir.mkdir(exist_ok=True)
|
||||
|
||||
external_template_content = '''vars:
|
||||
- name: title
|
||||
required: true
|
||||
|
||||
elements:
|
||||
- type: text
|
||||
content: "{title}"
|
||||
'''
|
||||
(templates_dir / "conflict-template.yaml").write_text(external_template_content, encoding='utf-8')
|
||||
|
||||
# 创建演示文稿 YAML(包含同名内联模板)
|
||||
yaml_content = '''metadata:
|
||||
size: "16:9"
|
||||
|
||||
templates:
|
||||
conflict-template:
|
||||
vars:
|
||||
- name: title
|
||||
required: true
|
||||
elements:
|
||||
- type: text
|
||||
content: "{title}"
|
||||
|
||||
slides:
|
||||
- template: conflict-template
|
||||
vars:
|
||||
title: "测试"
|
||||
'''
|
||||
yaml_file = temp_dir / "test.yaml"
|
||||
yaml_file.write_text(yaml_content, encoding='utf-8')
|
||||
|
||||
# 应该抛出 YAMLError
|
||||
with pytest.raises(YAMLError, match="模板名称冲突"):
|
||||
pres = Presentation(str(yaml_file), templates_dir=str(templates_dir))
|
||||
# 手动调用 get_template 来触发冲突检测
|
||||
pres.get_template('conflict-template')
|
||||
|
||||
def test_inline_only_no_conflict(self, temp_dir):
|
||||
"""测试只有内联模板时正常工作"""
|
||||
yaml_content = '''
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
templates:
|
||||
my-template:
|
||||
vars:
|
||||
- name: title
|
||||
required: true
|
||||
elements:
|
||||
- type: text
|
||||
content: "{title}"
|
||||
|
||||
slides:
|
||||
- template: my-template
|
||||
vars:
|
||||
title: "测试"
|
||||
'''
|
||||
yaml_file = temp_dir / "inline_only.yaml"
|
||||
yaml_file.write_text(yaml_content, encoding='utf-8')
|
||||
|
||||
from core.presentation import Presentation
|
||||
pres = Presentation(str(yaml_file))
|
||||
|
||||
# 不提供外部模板目录,应该正常加载
|
||||
assert 'my-template' in pres.inline_templates
|
||||
slide_data = pres.data['slides'][0]
|
||||
rendered = pres.render_slide(slide_data)
|
||||
assert len(rendered['elements']) == 1
|
||||
|
||||
class TestInlineTemplateErrorHandling:
|
||||
"""内联模板错误处理测试类"""
|
||||
|
||||
def test_templates_not_a_dict_raises_error(self, temp_dir):
|
||||
"""测试 templates 字段不是字典时抛出错误"""
|
||||
yaml_content = '''
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
templates: "invalid_templates_value"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
content: "test"
|
||||
'''
|
||||
yaml_file = temp_dir / "invalid_templates.yaml"
|
||||
yaml_file.write_text(yaml_content, encoding='utf-8')
|
||||
|
||||
from loaders.yaml_loader import YAMLError
|
||||
from core.presentation import Presentation
|
||||
|
||||
with pytest.raises(YAMLError, match="'templates' 必须是一个字典"):
|
||||
Presentation(str(yaml_file))
|
||||
|
||||
def test_template_missing_elements_raises_error(self, temp_dir):
|
||||
"""测试模板缺少 elements 字段时抛出错误"""
|
||||
yaml_content = '''
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
templates:
|
||||
no-elements:
|
||||
vars:
|
||||
- name: title
|
||||
required: true
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
content: "test"
|
||||
'''
|
||||
yaml_file = temp_dir / "no_elements.yaml"
|
||||
yaml_file.write_text(yaml_content, encoding='utf-8')
|
||||
|
||||
from loaders.yaml_loader import YAMLError
|
||||
from core.presentation import Presentation
|
||||
|
||||
with pytest.raises(YAMLError, match="缺少必需字段 'elements'"):
|
||||
Presentation(str(yaml_file))
|
||||
|
||||
def test_template_var_missing_name_raises_error(self, temp_dir):
|
||||
"""测试变量定义缺少 name 字段时抛出错误"""
|
||||
yaml_content = '''
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
templates:
|
||||
test-template:
|
||||
vars:
|
||||
- required: true
|
||||
elements:
|
||||
- type: text
|
||||
content: "{title}"
|
||||
|
||||
slides:
|
||||
- template: test-template
|
||||
vars:
|
||||
title: "Test"
|
||||
'''
|
||||
yaml_file = temp_dir / "invalid_var.yaml"
|
||||
yaml_file.write_text(yaml_content, encoding='utf-8')
|
||||
|
||||
from loaders.yaml_loader import YAMLError
|
||||
from core.presentation import Presentation
|
||||
|
||||
with pytest.raises(YAMLError, match="缺少必需字段 'name'"):
|
||||
Presentation(str(yaml_file))
|
||||
|
||||
def test_missing_required_variable_raises_error(self, temp_dir):
|
||||
"""测试缺少必需变量时抛出错误"""
|
||||
yaml_content = '''
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
templates:
|
||||
title-slide:
|
||||
vars:
|
||||
- name: title
|
||||
required: true
|
||||
elements:
|
||||
- type: text
|
||||
content: "{title}"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
# 缺少必需的 title 变量
|
||||
subtitle: "Test"
|
||||
'''
|
||||
yaml_file = temp_dir / "missing_var.yaml"
|
||||
yaml_file.write_text(yaml_content, encoding='utf-8')
|
||||
|
||||
from core.presentation import Presentation
|
||||
pres = Presentation(str(yaml_file))
|
||||
slide_data = pres.data['slides'][0]
|
||||
|
||||
# 渲染时应该抛出错误
|
||||
with pytest.raises(YAMLError, match="缺少必需变量: title"):
|
||||
pres.render_slide(slide_data)
|
||||
|
||||
def test_backward_compatibility_without_templates_field(self, temp_dir):
|
||||
"""测试不使用 templates 字段时保持向后兼容"""
|
||||
# 复用现有的 sample_yaml fixture
|
||||
yaml_content = '''
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- background:
|
||||
color: "#ffffff"
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Hello, World!"
|
||||
font:
|
||||
size: 44
|
||||
bold: true
|
||||
color: "#333333"
|
||||
align: center
|
||||
'''
|
||||
yaml_file = temp_dir / "backward_test.yaml"
|
||||
yaml_file.write_text(yaml_content, encoding='utf-8')
|
||||
|
||||
from core.presentation import Presentation
|
||||
|
||||
# 不使用 templates 字段,应该保持现有行为
|
||||
pres = Presentation(str(yaml_file))
|
||||
|
||||
# 不应该有 inline_templates 属性(因为字段不存在)
|
||||
assert not hasattr(pres, 'inline_templates') or pres.inline_templates == {}
|
||||
|
||||
# 渲染应该正常工作
|
||||
slide_data = pres.data['slides'][0]
|
||||
rendered = pres.render_slide(slide_data)
|
||||
assert len(rendered['elements']) == 1
|
||||
|
||||
Reference in New Issue
Block a user