Add complete test infrastructure for yaml2pptx project with 245+ tests covering unit, integration, and end-to-end scenarios. Test structure: - Unit tests: elements, template system, validators, loaders, utils - Integration tests: presentation and rendering flows - E2E tests: CLI commands (convert, check, preview) Key features: - PptxFileValidator for Level 2 PPTX validation (file structure, element count, content matching, position tolerance) - Comprehensive fixtures for test data consistency - Mock-based testing for external dependencies - Test images generated with PIL/Pillow - Boundary case coverage for edge scenarios Dependencies added: - pytest, pytest-cov, pytest-mock - pillow (for test image generation) Documentation updated: - README.md: test running instructions - README_DEV.md: test development guide Co-authored-by: OpenSpec change: add-comprehensive-tests
457 lines
15 KiB
Python
457 lines
15 KiB
Python
"""
|
||
模板系统单元测试
|
||
|
||
测试 Template 类的初始化、变量解析、条件渲染和模板渲染功能
|
||
"""
|
||
|
||
import pytest
|
||
from pathlib import Path
|
||
from loaders.yaml_loader import YAMLError
|
||
from core.template import Template
|
||
|
||
|
||
# ============= 模板初始化测试 =============
|
||
|
||
class TestTemplateInit:
|
||
"""Template 初始化测试类"""
|
||
|
||
def test_init_without_template_dir_raises_error(self, temp_dir):
|
||
"""测试未指定模板目录会引发错误"""
|
||
with pytest.raises(YAMLError, match="未指定模板目录"):
|
||
Template("title-slide", templates_dir=None)
|
||
|
||
def test_init_with_path_separator_in_name_raises_error(self, temp_dir):
|
||
"""测试模板名称包含路径分隔符会引发错误"""
|
||
with pytest.raises(YAMLError, match="模板名称不能包含路径分隔符"):
|
||
Template("../etc/passwd", templates_dir=temp_dir)
|
||
|
||
def test_init_with_nonexistent_template_raises_error(self, temp_dir):
|
||
"""测试模板文件不存在会引发错误"""
|
||
with pytest.raises(YAMLError, match="模板文件不存在"):
|
||
Template("nonexistent", templates_dir=temp_dir)
|
||
|
||
def test_init_with_valid_template(self, sample_template):
|
||
"""测试使用有效模板初始化"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
assert template.data is not None
|
||
assert "elements" in template.data
|
||
assert "vars" in template.data
|
||
|
||
|
||
# ============= 变量解析测试 =============
|
||
|
||
class TestResolveValue:
|
||
"""resolve_value 方法测试类"""
|
||
|
||
def test_resolve_value_simple_variable(self, sample_template):
|
||
"""测试解析简单变量"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
result = template.resolve_value("{title}", {"title": "My Title"})
|
||
assert result == "My Title"
|
||
|
||
def test_resolve_value_multiple_variables(self, sample_template):
|
||
"""测试解析多个变量"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
result = template.resolve_value("{title} - {subtitle}", {
|
||
"title": "Main",
|
||
"subtitle": "Sub"
|
||
})
|
||
assert result == "Main - Sub"
|
||
|
||
def test_resolve_value_undefined_variable_raises_error(self, sample_template):
|
||
"""测试未定义变量会引发错误"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
with pytest.raises(YAMLError, match="未定义的变量"):
|
||
template.resolve_value("{undefined}", {"title": "Test"})
|
||
|
||
def test_resolve_value_preserves_non_string(self):
|
||
"""测试非字符串值保持原样"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
assert template.resolve_value(123, {}) == 123
|
||
assert template.resolve_value(None, {}) is None
|
||
assert template.resolve_value(["list"], {}) == ["list"]
|
||
|
||
def test_resolve_value_converts_to_integer(self, sample_template):
|
||
"""测试结果转换为整数"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
result = template.resolve_value("{value}", {"value": "42"})
|
||
assert result == 42
|
||
assert isinstance(result, int)
|
||
|
||
def test_resolve_value_converts_to_float(self, sample_template):
|
||
"""测试结果转换为浮点数"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
result = template.resolve_value("{value}", {"value": "3.14"})
|
||
assert result == 3.14
|
||
assert isinstance(result, float)
|
||
|
||
|
||
# ============= resolve_element 测试 =============
|
||
|
||
class TestResolveElement:
|
||
"""resolve_element 方法测试类"""
|
||
|
||
def test_resolve_element_dict(self, sample_template):
|
||
"""测试解析字典元素"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
elem = {"content": "{title}", "box": [1, 2, 3, 4]}
|
||
result = template.resolve_element(elem, {"title": "Test"})
|
||
assert result["content"] == "Test"
|
||
assert result["box"] == [1, 2, 3, 4]
|
||
|
||
def test_resolve_element_list(self, sample_template):
|
||
"""测试解析列表元素"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
elem = ["{a}", "{b}", "static"]
|
||
result = template.resolve_element(elem, {"a": "A", "b": "B"})
|
||
assert result == ["A", "B", "static"]
|
||
|
||
def test_resolve_element_nested_structure(self, sample_template):
|
||
"""测试解析嵌套结构"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
elem = {
|
||
"font": {
|
||
"size": "{size}",
|
||
"color": "#000000"
|
||
}
|
||
}
|
||
result = template.resolve_element(elem, {"size": "24"})
|
||
assert result["font"]["size"] == 24
|
||
assert result["font"]["color"] == "#000000"
|
||
|
||
def test_resolve_element_excludes_visible(self, sample_template):
|
||
"""测试 visible 字段被排除"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
elem = {"content": "test", "visible": "{condition}"}
|
||
result = template.resolve_element(elem, {})
|
||
assert "visible" not in result
|
||
|
||
|
||
# ============= 条件渲染测试 =============
|
||
|
||
class TestEvaluateCondition:
|
||
"""evaluate_condition 方法测试类"""
|
||
|
||
def test_evaluate_condition_with_non_empty_variable(self, sample_template):
|
||
"""测试非空变量条件为真"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
result = template.evaluate_condition("{subtitle != ''}", {
|
||
"subtitle": "Test Subtitle"
|
||
})
|
||
assert result is True
|
||
|
||
def test_evaluate_condition_with_empty_variable(self, sample_template):
|
||
"""测试空变量条件为假"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
result = template.evaluate_condition("{subtitle != ''}", {
|
||
"subtitle": ""
|
||
})
|
||
assert result is False
|
||
|
||
def test_evaluate_condition_with_missing_variable(self, sample_template):
|
||
"""测试缺失变量条件为假"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
result = template.evaluate_condition("{subtitle != ''}", {})
|
||
assert result is False
|
||
|
||
def test_evaluate_condition_unrecognized_format_returns_true(self, sample_template):
|
||
"""测试无法识别的条件格式默认返回 True"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
result = template.evaluate_condition("some other format", {})
|
||
assert result is True
|
||
|
||
|
||
# ============= 模板渲染测试 =============
|
||
|
||
class TestRender:
|
||
"""render 方法测试类"""
|
||
|
||
def test_render_with_required_variable(self, sample_template):
|
||
"""测试渲染包含必需变量的模板"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
result = template.render({"title": "My Presentation"})
|
||
assert len(result) == 2 # 两个元素
|
||
assert result[0]["content"] == "My Presentation"
|
||
|
||
def test_render_with_optional_variable(self, sample_template):
|
||
"""测试渲染包含可选变量的模板"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
result = template.render({"title": "Test", "subtitle": "Subtitle"})
|
||
assert len(result) == 2 # 两个元素
|
||
|
||
def test_render_without_optional_variable(self, sample_template):
|
||
"""测试不提供可选变量"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
result = template.render({"title": "Test"})
|
||
# subtitle 元素应该被跳过(visible 条件)
|
||
assert len(result) == 1
|
||
|
||
def test_render_missing_required_variable_raises_error(self, sample_template):
|
||
"""测试缺少必需变量会引发错误"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
with pytest.raises(YAMLError, match="缺少必需变量"):
|
||
template.render({}) # 缺少 title
|
||
|
||
def test_render_with_default_value(self, temp_dir):
|
||
"""测试使用默认值"""
|
||
# 创建带默认值的模板
|
||
template_content = """
|
||
vars:
|
||
- name: title
|
||
required: true
|
||
- name: subtitle
|
||
required: false
|
||
default: "Default Subtitle"
|
||
|
||
elements:
|
||
- type: text
|
||
content: "{title}"
|
||
- type: text
|
||
content: "{subtitle}"
|
||
"""
|
||
template_file = temp_dir / "templates" / "default-test.yaml"
|
||
template_file.parent.mkdir(exist_ok=True)
|
||
template_file.write_text(template_content)
|
||
|
||
template = Template("default-test", templates_dir=temp_dir / "templates")
|
||
result = template.render({"title": "Test"})
|
||
assert len(result) == 2
|
||
|
||
def test_render_filters_elements_by_visible_condition(self, sample_template):
|
||
"""测试根据 visible 条件过滤元素"""
|
||
template = Template("title-slide", templates_dir=sample_template)
|
||
|
||
# 有 subtitle - 应该显示两个元素
|
||
result_with = template.render({"title": "Test", "subtitle": "Sub"})
|
||
assert len(result_with) == 2
|
||
|
||
# 无 subtitle - 应该只显示一个元素
|
||
result_without = template.render({"title": "Test"})
|
||
assert len(result_without) == 1
|
||
|
||
|
||
# ============= 边界情况补充测试 =============
|
||
|
||
class TestTemplateBoundaryCases:
|
||
"""模板系统边界情况测试"""
|
||
|
||
def test_nested_variable_resolution(self, temp_dir):
|
||
"""测试嵌套变量解析"""
|
||
template_content = """
|
||
vars:
|
||
- name: title
|
||
required: true
|
||
- name: full_title
|
||
required: false
|
||
default: "{title} - Extended"
|
||
|
||
elements:
|
||
- type: text
|
||
content: "{full_title}"
|
||
box: [0, 0, 1, 1]
|
||
font: {}
|
||
"""
|
||
template_file = temp_dir / "templates" / "nested.yaml"
|
||
template_file.parent.mkdir(exist_ok=True)
|
||
template_file.write_text(template_content)
|
||
|
||
template = Template("nested", templates_dir=temp_dir / "templates")
|
||
result = template.render({"title": "Main"})
|
||
|
||
# 默认值中的变量应该被解析
|
||
assert result[0]["content"] == "Main - Extended"
|
||
|
||
def test_variable_with_special_characters(self, temp_dir):
|
||
"""测试变量包含特殊字符"""
|
||
template_content = """
|
||
vars:
|
||
- name: title
|
||
required: true
|
||
|
||
elements:
|
||
- type: text
|
||
content: "{title}"
|
||
box: [0, 0, 1, 1]
|
||
font: {}
|
||
"""
|
||
template_file = temp_dir / "templates" / "special.yaml"
|
||
template_file.parent.mkdir(exist_ok=True)
|
||
template_file.write_text(template_content)
|
||
|
||
template = Template("special", templates_dir=temp_dir / "templates")
|
||
|
||
special_values = [
|
||
"Test & Data",
|
||
"Test <Script>",
|
||
"Test \"Quotes\"",
|
||
"Test 'Apostrophe'",
|
||
"测试中文",
|
||
"Test: colon",
|
||
"Test; semi"
|
||
]
|
||
for value in special_values:
|
||
result = template.render({"title": value})
|
||
assert result[0]["content"] == value
|
||
|
||
def test_variable_with_numeric_value(self, temp_dir):
|
||
"""测试数字变量值"""
|
||
template_content = """
|
||
vars:
|
||
- name: width
|
||
required: true
|
||
|
||
elements:
|
||
- type: text
|
||
content: "Width: {width}"
|
||
box: [0, 0, 1, 1]
|
||
font: {}
|
||
"""
|
||
template_file = temp_dir / "templates" / "numeric.yaml"
|
||
template_file.parent.mkdir(exist_ok=True)
|
||
template_file.write_text(template_content)
|
||
|
||
template = Template("numeric", templates_dir=temp_dir / "templates")
|
||
result = template.render({"width": "100"})
|
||
|
||
# 应该保持为字符串(因为是在 content 中)
|
||
assert result[0]["content"] == "Width: 100"
|
||
|
||
def test_empty_vars_list(self, temp_dir):
|
||
"""测试空的 vars 列表"""
|
||
template_content = """
|
||
vars: []
|
||
|
||
elements:
|
||
- type: text
|
||
content: "Static Content"
|
||
box: [0, 0, 1, 1]
|
||
font: {}
|
||
"""
|
||
template_file = temp_dir / "templates" / "novars.yaml"
|
||
template_file.parent.mkdir(exist_ok=True)
|
||
template_file.write_text(template_content)
|
||
|
||
template = Template("novars", templates_dir=temp_dir / "templates")
|
||
result = template.render({})
|
||
|
||
assert len(result) == 1
|
||
|
||
def test_multiple_visible_conditions(self, temp_dir):
|
||
"""测试多个可见性条件"""
|
||
template_content = """
|
||
vars:
|
||
- name: title
|
||
required: true
|
||
- name: subtitle
|
||
required: false
|
||
default: ""
|
||
- name: footer
|
||
required: false
|
||
default: ""
|
||
|
||
elements:
|
||
- type: text
|
||
content: "{title}"
|
||
box: [0, 0, 1, 1]
|
||
font: {}
|
||
|
||
- type: text
|
||
content: "{subtitle}"
|
||
visible: "{subtitle != ''}"
|
||
box: [0, 1, 1, 1]
|
||
font: {}
|
||
|
||
- type: text
|
||
content: "{footer}"
|
||
visible: "{footer != ''}"
|
||
box: [0, 2, 1, 1]
|
||
font: {}
|
||
"""
|
||
template_file = temp_dir / "templates" / "multi-condition.yaml"
|
||
template_file.parent.mkdir(exist_ok=True)
|
||
template_file.write_text(template_content)
|
||
|
||
template = Template("multi-condition", templates_dir=temp_dir / "templates")
|
||
|
||
# 只有 title
|
||
result1 = template.render({"title": "Test"})
|
||
assert len(result1) == 1
|
||
|
||
# 有 subtitle 和 footer
|
||
result2 = template.render({
|
||
"title": "Test",
|
||
"subtitle": "Sub",
|
||
"footer": "Foot"
|
||
})
|
||
assert len(result2) == 3
|
||
|
||
def test_variable_in_position(self, temp_dir):
|
||
"""测试变量在位置中使用"""
|
||
template_content = """
|
||
vars:
|
||
- name: x_pos
|
||
required: true
|
||
|
||
elements:
|
||
- type: text
|
||
content: "Test"
|
||
box: ["{x_pos}", 1, 1, 1]
|
||
font: {}
|
||
"""
|
||
template_file = temp_dir / "templates" / "pos-var.yaml"
|
||
template_file.parent.mkdir(exist_ok=True)
|
||
template_file.write_text(template_content)
|
||
|
||
template = Template("pos-var", templates_dir=temp_dir / "templates")
|
||
result = template.render({"x_pos": "2"})
|
||
|
||
# 变量应该被解析为数字
|
||
assert result[0]["box"][0] == 2
|
||
|
||
def test_empty_template_elements(self, temp_dir):
|
||
"""测试空元素列表的模板"""
|
||
template_content = """
|
||
vars: []
|
||
|
||
elements: []
|
||
"""
|
||
template_file = temp_dir / "templates" / "empty.yaml"
|
||
template_file.parent.mkdir(exist_ok=True)
|
||
template_file.write_text(template_content)
|
||
|
||
template = Template("empty", templates_dir=temp_dir / "templates")
|
||
result = template.render({})
|
||
|
||
assert result == []
|
||
|
||
def test_variable_replacement_in_font(self, temp_dir):
|
||
"""测试字体属性中的变量替换"""
|
||
template_content = """
|
||
vars:
|
||
- name: font_size
|
||
required: true
|
||
- name: text_color
|
||
required: true
|
||
|
||
elements:
|
||
- type: text
|
||
content: "Styled Text"
|
||
box: [0, 0, 1, 1]
|
||
font:
|
||
size: {font_size}
|
||
color: {text_color}
|
||
bold: true
|
||
"""
|
||
template_file = temp_dir / "templates" / "font-vars.yaml"
|
||
template_file.parent.mkdir(exist_ok=True)
|
||
template_file.write_text(template_content)
|
||
|
||
template = Template("font-vars", templates_dir=temp_dir / "templates")
|
||
result = template.render({
|
||
"font_size": "24",
|
||
"text_color": "#ff0000"
|
||
})
|
||
|
||
assert result[0]["font"]["size"] == 24
|
||
assert result[0]["font"]["color"] == "#ff0000"
|