Compare commits
8 Commits
0a804eacc8
...
22614d6f55
| Author | SHA1 | Date | |
|---|---|---|---|
| 22614d6f55 | |||
| e82a6a945e | |||
| ef3fa6a06a | |||
| e31a7e9bed | |||
| c73bd0fedd | |||
| f273cef195 | |||
| ab2510a400 | |||
| 027a832c9a |
46
README.md
46
README.md
@@ -16,7 +16,7 @@
|
||||
|
||||
### 安装
|
||||
|
||||
本工具使用 [uv](https://github.com/astral-sh/uv) 管理依赖,运行时会自动安装所需的 Python 包。
|
||||
本工具使用 [uv](https://github.com/astral-sh/uv) 管理依赖。项目依赖在 pyproject.toml 中声明,运行时会自动安装所需的 Python 包。
|
||||
|
||||
### 基本用法
|
||||
|
||||
@@ -101,7 +101,7 @@ uv run yaml2pptx.py convert presentation.yaml --skip-validation
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
size: 16:9 # 或 4:3
|
||||
size: "16:9" # 或 "4:3"
|
||||
|
||||
slides:
|
||||
- background:
|
||||
@@ -121,7 +121,7 @@ slides:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
size: 16:9
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
@@ -352,7 +352,45 @@ yaml2pptx 采用模块化架构,易于扩展:
|
||||
- `flask` - 预览服务器
|
||||
- `watchdog` - 文件监听
|
||||
|
||||
所有依赖由 uv 自动管理,无需手动安装。
|
||||
依赖在 pyproject.toml 中声明,由 uv 自动管理,无需手动安装。
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
项目包含完整的测试套件,使用 pytest 框架。
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 安装测试依赖
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
# 运行所有测试
|
||||
uv run pytest
|
||||
|
||||
# 运行特定类型的测试
|
||||
uv run pytest tests/unit/ # 单元测试
|
||||
uv run pytest tests/integration/ # 集成测试
|
||||
uv run pytest tests/e2e/ # 端到端测试
|
||||
|
||||
# 运行特定测试文件
|
||||
uv run pytest tests/unit/test_elements.py
|
||||
|
||||
# 显示详细输出
|
||||
uv run pytest -v
|
||||
|
||||
# 显示测试覆盖率
|
||||
uv run pytest --cov=. --cov-report=html
|
||||
```
|
||||
|
||||
### 测试结构
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/ # 单元测试 - 测试各模块独立功能
|
||||
├── integration/ # 集成测试 - 测试模块间协作
|
||||
├── e2e/ # 端到端测试 - 测试完整用户场景
|
||||
└── fixtures/ # 测试数据
|
||||
```
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
|
||||
153
README_DEV.md
153
README_DEV.md
@@ -186,7 +186,7 @@ python yaml2pptx.py input.yaml output.pptx
|
||||
```
|
||||
|
||||
**依赖管理**:
|
||||
- 所有依赖在 `yaml2pptx.py` 的 `/// script` 头部声明
|
||||
- 所有依赖在 `pyproject.toml` 的 `[project.dependencies]` 中声明
|
||||
- uv 会自动安装依赖,无需手动 `pip install`
|
||||
|
||||
### 2. 命令行接口
|
||||
@@ -471,8 +471,126 @@ def main():
|
||||
|
||||
## 测试规范
|
||||
|
||||
### 测试框架
|
||||
|
||||
项目使用 pytest 作为测试框架,测试代码位于 `tests/` 目录。
|
||||
|
||||
### 测试结构
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # pytest 配置和共享 fixtures
|
||||
├── conftest_pptx.py # PPTX 文件验证工具
|
||||
├── unit/ # 单元测试
|
||||
│ ├── test_elements.py # 元素类测试
|
||||
│ ├── test_template.py # 模板系统测试
|
||||
│ ├── test_utils.py # 工具函数测试
|
||||
│ ├── test_validators/ # 验证器测试
|
||||
│ │ ├── test_geometry.py
|
||||
│ │ ├── test_resource.py
|
||||
│ │ ├── test_result.py
|
||||
│ │ └── test_validator.py
|
||||
│ └── test_loaders/ # 加载器测试
|
||||
│ └── test_yaml_loader.py
|
||||
├── integration/ # 集成测试
|
||||
│ ├── test_presentation.py
|
||||
│ ├── test_rendering_flow.py
|
||||
│ └── test_validation_flow.py
|
||||
├── e2e/ # 端到端测试
|
||||
│ ├── test_convert_cmd.py
|
||||
│ ├── test_check_cmd.py
|
||||
│ └── test_preview_cmd.py
|
||||
└── fixtures/ # 测试数据
|
||||
├── yaml_samples/ # YAML 样本
|
||||
├── templates/ # 测试模板
|
||||
└── images/ # 测试图片
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 安装测试依赖
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
# 运行所有测试
|
||||
uv run pytest
|
||||
|
||||
# 运行特定类型的测试
|
||||
uv run pytest tests/unit/ # 单元测试
|
||||
uv run pytest tests/integration/ # 集成测试
|
||||
uv run pytest tests/e2e/ # 端到端测试
|
||||
|
||||
# 运行特定测试文件
|
||||
uv run pytest tests/unit/test_elements.py
|
||||
|
||||
# 显示详细输出
|
||||
uv run pytest -v
|
||||
|
||||
# 显示测试覆盖率
|
||||
uv run pytest --cov=. --cov-report=html
|
||||
```
|
||||
|
||||
### 编写测试
|
||||
|
||||
**测试类命名**:使用 `Test<ClassName>` 格式
|
||||
|
||||
**测试方法命名**:使用 `test_<what_is_being_tested>` 格式
|
||||
|
||||
```python
|
||||
class TestTextElement:
|
||||
"""TextElement 测试类"""
|
||||
|
||||
def test_create_with_defaults(self):
|
||||
"""测试使用默认值创建 TextElement"""
|
||||
elem = TextElement()
|
||||
assert elem.type == 'text'
|
||||
|
||||
def test_invalid_color_raises_error(self):
|
||||
"""测试无效颜色会引发错误"""
|
||||
with pytest.raises(ValueError, match="无效颜色"):
|
||||
TextElement(font={"color": "red"})
|
||||
```
|
||||
|
||||
### Fixtures
|
||||
|
||||
共享 fixtures 定义在 `tests/conftest.py` 中:
|
||||
|
||||
- `temp_dir`: 临时目录
|
||||
- `sample_yaml`: 最小测试 YAML 文件
|
||||
- `sample_image`: 测试图片
|
||||
- `sample_template`: 测试模板
|
||||
- `pptx_validator`: PPTX 验证器
|
||||
|
||||
```python
|
||||
def test_with_fixture(sample_yaml):
|
||||
"""使用 fixture 的测试"""
|
||||
assert sample_yaml.exists()
|
||||
```
|
||||
|
||||
### PPTX 验证
|
||||
|
||||
使用 `PptxFileValidator` 验证生成的 PPTX 文件:
|
||||
|
||||
```python
|
||||
def test_pptx_generation(temp_dir, pptx_validator):
|
||||
"""测试 PPTX 生成"""
|
||||
# ... 生成 PPTX ...
|
||||
output_path = temp_dir / "output.pptx"
|
||||
|
||||
# 验证文件
|
||||
assert pptx_validator.validate_file(output_path) is True
|
||||
|
||||
# 验证内容
|
||||
prs = Presentation(str(output_path))
|
||||
assert pptx_validator.validate_text_element(
|
||||
prs.slides[0],
|
||||
index=0,
|
||||
expected_content="Test"
|
||||
) is True
|
||||
```
|
||||
|
||||
### 手动测试
|
||||
|
||||
```bash
|
||||
# 验证 YAML 文件
|
||||
uv run yaml2pptx.py check temp/test.yaml
|
||||
@@ -510,31 +628,30 @@ uv run yaml2pptx.py preview temp/test.yaml --no-browser
|
||||
|
||||
### 测试文件位置
|
||||
|
||||
所有测试文件必须放在 `temp/` 目录下:
|
||||
- `temp/*.yaml` - 测试用的 YAML 文件
|
||||
- `temp/*.pptx` - 生成的 PPTX 文件
|
||||
- `temp/templates/` - 测试用的模板文件
|
||||
- **自动化测试**:`tests/` 目录
|
||||
- **手动测试文件**:`temp/` 目录
|
||||
- `temp/*.yaml` - 手动测试用的 YAML 文件
|
||||
- `temp/*.pptx` - 生成的 PPTX 文件
|
||||
- `temp/templates/` - 手动测试用的模板文件
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么不能直接使用 python 运行脚本?
|
||||
|
||||
A: 项目使用 uv 的 Inline script metadata 来管理依赖。直接使用 python 会导致依赖缺失。必须使用 `uv run yaml2pptx.py`。
|
||||
A: 项目使用 uv 和 pyproject.toml 来管理依赖。直接使用 python 会导致依赖缺失。必须使用 `uv run yaml2pptx.py`。
|
||||
|
||||
### Q: 如何添加新的依赖?
|
||||
|
||||
A: 在 `yaml2pptx.py` 的 `/// script` 头部添加:
|
||||
```python
|
||||
# /// script
|
||||
# requires-python = ">=3.8"
|
||||
# dependencies = [
|
||||
# "python-pptx",
|
||||
# "pyyaml",
|
||||
# "flask",
|
||||
# "watchdog",
|
||||
# "new-dependency", # 添加新依赖
|
||||
# ]
|
||||
# ///
|
||||
A: 在 `pyproject.toml` 的 `[project.dependencies]` 中添加:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"python-pptx",
|
||||
"pyyaml",
|
||||
"flask",
|
||||
"watchdog",
|
||||
"new-dependency", # 添加新依赖
|
||||
]
|
||||
```
|
||||
|
||||
### Q: 为什么元素使用 dataclass 而不是普通字典?
|
||||
|
||||
@@ -29,8 +29,14 @@ class Presentation:
|
||||
validate_presentation_yaml(self.data, str(pres_file))
|
||||
|
||||
# 获取演示文稿尺寸
|
||||
metadata = self.data.get('metadata', {})
|
||||
self.size = metadata.get('size', '16:9')
|
||||
metadata = self.data.get("metadata", {})
|
||||
self.size = metadata.get("size", "16:9")
|
||||
|
||||
# 验证尺寸值
|
||||
if not isinstance(self.size, str):
|
||||
raise ValueError(
|
||||
f"无效的尺寸值: {self.size},尺寸必须是字符串(如 '16:9' 或 '4:3')"
|
||||
)
|
||||
|
||||
# 模板缓存
|
||||
self.template_cache = {}
|
||||
@@ -61,31 +67,28 @@ class Presentation:
|
||||
Returns:
|
||||
dict: 包含 background 和 elements 的字典
|
||||
"""
|
||||
if 'template' in slide_data:
|
||||
if "template" in slide_data:
|
||||
# 使用模板
|
||||
template_name = slide_data['template']
|
||||
template_name = slide_data["template"]
|
||||
template = self.get_template(template_name)
|
||||
vars_values = slide_data.get('vars', {})
|
||||
vars_values = slide_data.get("vars", {})
|
||||
elements = template.render(vars_values)
|
||||
|
||||
# 合并背景(如果有)
|
||||
background = slide_data.get('background', None)
|
||||
background = slide_data.get("background", None)
|
||||
|
||||
# 将元素字典转换为元素对象
|
||||
element_objects = [create_element(elem) for elem in elements]
|
||||
|
||||
return {
|
||||
'background': background,
|
||||
'elements': element_objects
|
||||
}
|
||||
return {"background": background, "elements": element_objects}
|
||||
else:
|
||||
# 自定义幻灯片
|
||||
elements = slide_data.get('elements', [])
|
||||
elements = slide_data.get("elements", [])
|
||||
|
||||
# 将元素字典转换为元素对象
|
||||
element_objects = [create_element(elem) for elem in elements]
|
||||
|
||||
return {
|
||||
'background': slide_data.get('background'),
|
||||
'elements': element_objects
|
||||
"background": slide_data.get("background"),
|
||||
"elements": element_objects,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-02
|
||||
@@ -0,0 +1,172 @@
|
||||
## Context
|
||||
|
||||
yaml2pptx 项目目前没有测试代码。项目采用模块化架构,包含以下模块:
|
||||
- core/: 核心领域模型(elements、presentation、template)
|
||||
- loaders/: YAML 加载层
|
||||
- validators/: 验证层(geometry、resource、result、validator)
|
||||
- renderers/: PPTX 和 HTML 渲染
|
||||
- preview/: Flask 预览服务器
|
||||
|
||||
约束条件:
|
||||
1. 必须使用 pytest 框架
|
||||
2. PPTX 文件需要实际生成和验证(Level 2:文件结构、元素数量、内容匹配、位置范围)
|
||||
3. Preview 测试不启动真实服务器
|
||||
4. 临时文件使用系统临时目录并自动清理
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 建立全面的测试体系,覆盖单元、集成、端到端三个层级
|
||||
- 确保核心功能的正确性和稳定性
|
||||
- 为未来重构提供安全网
|
||||
- 测试代码结构清晰、易于维护
|
||||
|
||||
**Non-Goals:**
|
||||
- 性能测试
|
||||
- UI/视觉对比测试(像素级)
|
||||
- 100% 代码覆盖率(追求合理覆盖而非完美数字)
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: 测试目录结构
|
||||
|
||||
采用三层测试结构:
|
||||
```
|
||||
tests/
|
||||
├── unit/ # 单元测试 - 测试独立函数/类
|
||||
├── integration/ # 集成测试 - 测试模块间协作
|
||||
├── e2e/ # 端到端测试 - 测试完整用户场景
|
||||
└── fixtures/ # 测试数据
|
||||
```
|
||||
|
||||
**理由**:清晰的分层便于理解和维护,符合测试最佳实践。
|
||||
|
||||
### D2: Fixtures 策略
|
||||
|
||||
使用 pytest 的 conftest.py 集中管理共享 fixtures:
|
||||
- `temp_dir`: 临时目录(使用 pytest 内置 tmp_path)
|
||||
- `sample_yaml`: 最小可用 YAML 文件
|
||||
- `sample_image`: 测试图片(使用 Pillow 创建)
|
||||
- `sample_template`: 测试模板文件和目录
|
||||
- `pptx_validator`: PPTX 验证工具实例
|
||||
|
||||
**理由**:集中管理避免重复,便于维护。
|
||||
|
||||
### D3: PPTX 验证方案
|
||||
|
||||
创建专门的 PptxFileValidator 工具类(Level 2 验证):
|
||||
- 文件级:存在、可打开
|
||||
- 幻灯片级:数量、尺寸
|
||||
- 元素级:类型、数量、内容、位置(容差 0.1 英寸)
|
||||
|
||||
**理由**:Level 2 平衡了覆盖度和复杂度,无需图像对比库。
|
||||
|
||||
### D4: Preview 测试方案
|
||||
|
||||
不启动真实 Flask 服务器,测试以下内容:
|
||||
- `generate_preview_html()` 函数的 HTML 生成
|
||||
- `create_flask_app()` 的路由配置
|
||||
- `YAMLChangeHandler` 的文件变化检测(Mock watchdog)
|
||||
|
||||
**理由**:避免异步服务器的复杂性,测试核心逻辑。
|
||||
|
||||
### D5: Mock 边界
|
||||
|
||||
**需要 Mock:**
|
||||
- `webbrowser.open()`(避免打开浏览器)
|
||||
- `watchdog.Observer`(避免文件监听)
|
||||
- CLI 的 `sys.argv`(使用 CliRunner)
|
||||
|
||||
**不 Mock:**
|
||||
- `python-pptx`(实际生成和验证)
|
||||
- 文件系统(使用临时目录)
|
||||
- `yaml.safe_load()`(实际解析)
|
||||
|
||||
**理由**:核心生成逻辑必须真实测试,外部依赖可以 Mock。
|
||||
|
||||
### D6: 测试数据组织
|
||||
|
||||
```
|
||||
tests/fixtures/
|
||||
├── yaml_samples/
|
||||
│ ├── minimal.yaml # 最小示例
|
||||
│ ├── full_features.yaml # 包含所有功能
|
||||
│ ├── edge_cases/ # 边界情况
|
||||
│ └── invalid/ # 无效样本(测试错误处理)
|
||||
├── templates/ # 测试模板
|
||||
└── images/ # 测试图片
|
||||
```
|
||||
|
||||
**理由**:测试数据与代码分离,便于复用和维护。
|
||||
|
||||
### D7: 临时文件清理
|
||||
|
||||
方案:使用 pytest 内置 `tmp_path` fixture,自动管理临时目录。
|
||||
|
||||
**理由**:pytest 原生支持,无需手动清理代码。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### Risk 1: PPTX 验证可能不够精确
|
||||
**描述**:Level 2 验证不检查像素级渲染效果。
|
||||
|
||||
**缓解措施**:手动验证关键用例的视觉效果,自动化测试覆盖结构正确性。
|
||||
|
||||
### Risk 2: 测试运行时间较长
|
||||
**描述**:实际生成 PPTX 文件会增加测试时间。
|
||||
|
||||
**缓解措施**:不考虑时间限制,优先保证测试覆盖度。
|
||||
|
||||
### Risk 3: 全局变量影响测试
|
||||
**描述**:preview/server.py 使用全局变量(app、change_queue 等)。
|
||||
|
||||
**缓解措施**:在测试中显式重置全局状态,或重构代码减少全局变量依赖。
|
||||
|
||||
### Risk 4: CLI 测试复杂性
|
||||
**描述**:yaml2pptx.py 是单体脚本,难以单独导入测试。
|
||||
|
||||
**缓解措施**:使用 `subprocess` 运行命令并检查输出/退出码。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### 步骤
|
||||
|
||||
1. **配置测试环境**
|
||||
- 更新 pyproject.toml 添加测试依赖
|
||||
- 创建 tests/conftest.py
|
||||
|
||||
2. **实现测试基础设施**
|
||||
- 创建 tests/conftest_pptx.py(PPTX 验证工具)
|
||||
- 创建 tests/fixtures/ 目录和测试数据
|
||||
|
||||
3. **实现单元测试**
|
||||
- test_elements.py
|
||||
- test_template.py
|
||||
- test_validators/*.py
|
||||
- test_loaders/test_yaml_loader.py
|
||||
- test_utils.py
|
||||
|
||||
4. **实现集成测试**
|
||||
- test_presentation.py
|
||||
- test_rendering_flow.py
|
||||
- test_validation_flow.py
|
||||
|
||||
5. **实现端到端测试**
|
||||
- test_convert_cmd.py
|
||||
- test_check_cmd.py
|
||||
- test_preview_cmd.py
|
||||
|
||||
6. **更新文档**
|
||||
- README.md 添加测试运行说明
|
||||
- README_DEV.md 添加测试开发指南
|
||||
|
||||
### 回滚策略
|
||||
|
||||
如果测试引入问题:
|
||||
1. 测试代码位于独立目录,不影响生产代码
|
||||
2. 可以通过删除 tests/ 目录完全移除
|
||||
3. pyproject.toml 的测试依赖是可选的
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。所有技术决策已明确。
|
||||
@@ -0,0 +1,56 @@
|
||||
## Why
|
||||
|
||||
yaml2pptx 项目目前没有任何测试代码,存在以下风险:
|
||||
1. 代码重构和功能迭代时缺乏安全网,容易引入回归问题
|
||||
2. 验证器、渲染器等核心模块的逻辑复杂,缺少自动化验证
|
||||
3. 新功能开发时无法快速验证正确性
|
||||
4. 无法保证 PPTX 生成质量的一致性
|
||||
|
||||
现在项目架构已稳定,核心功能基本完成,是建立测试体系的最佳时机。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增完整的测试目录结构 `tests/`,包含单元测试、集成测试、端到端测试
|
||||
- 新增 `pyproject.toml` 测试依赖配置(pytest、pytest-cov 等)
|
||||
- 新增 `tests/conftest.py` 配置共享 fixtures
|
||||
- 新增 `tests/conftest_pptx.py` PPTX 文件验证辅助工具
|
||||
- 新增 `tests/fixtures/` 测试数据目录(YAML 样本、模板、图片)
|
||||
- 实现约 150+ 测试用例覆盖所有核心模块
|
||||
- 测试将实际生成 PPTX 文件并进行 Level 2 验证(文件结构、元素数量、内容匹配、位置范围)
|
||||
- Preview 测试采用方案 A(不启动真实服务器,测试 HTML 生成函数和路由配置)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `test-framework`: pytest 测试框架基础设施,包含配置、fixtures、临时文件管理
|
||||
- `unit-tests`: 单元测试,覆盖各模块独立功能(elements、template、validators、loaders、utils)
|
||||
- `integration-tests`: 集成测试,覆盖模块间协作(presentation、渲染流程、验证流程)
|
||||
- `e2e-tests`: 端到端测试,覆盖 CLI 命令(convert、check、preview)
|
||||
- `pptx-validation`: PPTX 文件验证工具,支持文件级别、幻灯片级别、元素级别的验证
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。测试是新增能力,不修改现有功能的行为要求。
|
||||
|
||||
## Impact
|
||||
|
||||
### 代码影响
|
||||
- 新增 `tests/` 目录及所有测试文件
|
||||
- 修改 `pyproject.toml` 添加测试依赖
|
||||
- 可能需要调整部分代码以提升可测试性(如全局变量处理)
|
||||
|
||||
### 依赖增加
|
||||
- `pytest`: 测试框架
|
||||
- `pytest-cov`: 覆盖率报告(可选)
|
||||
- `pytest-mock`: Mock 工具(可选)
|
||||
- `pillow`: 用于创建测试图片
|
||||
|
||||
### 开发流程
|
||||
- 开发者可通过 `uv run pytest` 运行测试
|
||||
- 提交代码前应确保测试通过
|
||||
- 新功能开发应同步编写测试
|
||||
|
||||
### 文档更新
|
||||
- `README.md` 添加测试运行说明
|
||||
- `README_DEV.md` 添加测试开发指南
|
||||
@@ -0,0 +1,96 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Convert 命令端到端测试
|
||||
系统 SHALL 提供 convert 命令的端到端测试。
|
||||
|
||||
#### Scenario: 基本转换
|
||||
- **WHEN** 执行 `yaml2pptx.py convert input.yaml output.pptx`
|
||||
- **THEN** 输出文件成功生成
|
||||
- **AND** 文件可以打开并包含正确内容
|
||||
|
||||
#### Scenario: 自动输出文件名
|
||||
- **WHEN** 执行 `yaml2pptx.py convert input.yaml`(不指定输出)
|
||||
- **THEN** 使用输入文件名生成 .pptx 文件
|
||||
|
||||
#### Scenario: 使用模板
|
||||
- **WHEN** 执行转换时指定 --template-dir
|
||||
- **THEN** 模板被正确加载和应用
|
||||
|
||||
#### Scenario: 跳过验证
|
||||
- **WHEN** 执行转换时指定 --skip-validation
|
||||
- **THEN** 验证步骤被跳过
|
||||
|
||||
#### Scenario: 强制覆盖
|
||||
- **WHEN** 输出文件已存在并使用 --force
|
||||
- **THEN** 现有文件被覆盖
|
||||
|
||||
#### Scenario: 文件已存在不强制
|
||||
- **WHEN** 输出文件已存在且不使用 --force
|
||||
- **THEN** 程序提示文件已存在并退出
|
||||
|
||||
#### Scenario: 无效输入文件
|
||||
- **WHEN** 输入文件不存在
|
||||
- **THEN** 程序显示错误信息并退出
|
||||
|
||||
#### Scenario: 包含所有元素类型的转换
|
||||
- **WHEN** YAML 包含所有元素类型(文本、图片、形状、表格)
|
||||
- **THEN** 生成的 PPTX 包含所有元素并正确渲染
|
||||
|
||||
### Requirement: Check 命令端到端测试
|
||||
系统 SHALL 提供 check 命令的端到端测试。
|
||||
|
||||
#### Scenario: 验证通过
|
||||
- **WHEN** 执行 `yaml2pptx.py check valid.yaml`
|
||||
- **THEN** 显示验证通过消息
|
||||
- **AND** 退出码为 0
|
||||
|
||||
#### Scenario: 验证失败
|
||||
- **WHEN** 执行 `yaml2pptx.py check invalid.yaml`
|
||||
- **THEN** 显示错误信息
|
||||
- **AND** 退出码非 0
|
||||
|
||||
#### Scenario: 验证包含警告
|
||||
- **WHEN** YAML 文件有问题但不阻止转换
|
||||
- **THEN** 显示警告信息
|
||||
- **AND** 验证标记为通过
|
||||
|
||||
#### Scenario: 使用模板验证
|
||||
- **WHEN** YAML 使用模板并指定 --template-dir
|
||||
- **THEN** 模板文件也被验证
|
||||
|
||||
#### Scenario: 多错误报告
|
||||
- **WHEN** YAML 文件包含多个错误
|
||||
- **THEN** 所有错误被列出
|
||||
- **AND** 每个错误包含位置信息
|
||||
|
||||
### Requirement: Preview 命令端到端测试
|
||||
系统 SHALL 提供 preview 命令的端到端测试(不启动真实服务器)。
|
||||
|
||||
#### Scenario: HTML 生成
|
||||
- **WHEN** 调用 generate_preview_html() 并传入有效 YAML
|
||||
- **THEN** 返回完整的 HTML 页面
|
||||
- **AND** HTML 包含所有幻灯片的渲染
|
||||
|
||||
#### Scenario: HTML 包含幻灯片元素
|
||||
- **WHEN** 生成的预览 HTML
|
||||
- **THEN** 每张幻灯片包含正确的元素渲染
|
||||
- **AND** CSS 样式正确应用
|
||||
|
||||
#### Scenario: 错误处理
|
||||
- **WHEN** YAML 文件包含错误
|
||||
- **THEN** generate_preview_html() 返回错误页面 HTML
|
||||
- **AND** 错误信息正确显示
|
||||
|
||||
#### Scenario: SSE 事件流路由
|
||||
- **WHEN** 访问 /events 路由
|
||||
- **THEN** 返回 text/event-stream 内容类型
|
||||
|
||||
#### Scenario: 文件变化处理
|
||||
- **WHEN** YAML 文件被修改
|
||||
- **THEN** YAMLChangeHandler 检测到变化
|
||||
- **AND** 将 'reload' 消息放入队列
|
||||
|
||||
#### Scenario: Flask 应用创建
|
||||
- **WHEN** 调用 create_flask_app()
|
||||
- **THEN** 返回配置好的 Flask 应用
|
||||
- **AND** 包含 / 和 /events 路由
|
||||
@@ -0,0 +1,77 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Presentation 类集成测试
|
||||
系统 SHALL 提供 Presentation 类的集成测试,验证模板加载和幻灯片渲染。
|
||||
|
||||
#### Scenario: 幻灯片渲染
|
||||
- **WHEN** 调用 Presentation.render_slide() 并传入幻灯片数据
|
||||
- **THEN** 返回渲染后的元素列表
|
||||
- **AND** 模板变量被正确替换
|
||||
|
||||
#### Scenario: 模板缓存
|
||||
- **WHEN** 多次调用 Presentation.get_template() 并使用相同模板名
|
||||
- **THEN** 返回缓存的 Template 实例
|
||||
|
||||
#### Scenario: 模板变量传递
|
||||
- **WHEN** 幻灯片使用模板并提供 vars
|
||||
- **THEN** 变量被正确传递给模板并渲染
|
||||
|
||||
### Requirement: 渲染流程集成测试
|
||||
系统 SHALL 提供完整的 YAML 到 PPTX 渲染流程测试。
|
||||
|
||||
#### Scenario: 完整渲染流程
|
||||
- **WHEN** 从 YAML 文件加载演示文稿并生成 PPTX
|
||||
- **THEN** PPTX 文件成功生成
|
||||
- **AND** 文件包含正确数量的幻灯片
|
||||
- **AND** 每张幻灯片包含正确的元素
|
||||
|
||||
#### Scenario: 文本元素渲染
|
||||
- **WHEN** 渲染包含文本元素的幻灯片
|
||||
- **THEN** 生成的 PPTX 包含文本框
|
||||
- **AND** 文本内容、字体、颜色、对齐方式正确
|
||||
|
||||
#### Scenario: 图片元素渲染
|
||||
- **WHEN** 渲染包含图片元素的幻灯片
|
||||
- **THEN** 生成的 PPTX 包含图片
|
||||
- **AND** 图片位置和尺寸正确
|
||||
|
||||
#### Scenario: 形状元素渲染
|
||||
- **WHEN** 渲染包含形状元素的幻灯片
|
||||
- **THEN** 生成的 PPTX 包含形状
|
||||
- **AND** 形状类型、填充、边框正确
|
||||
|
||||
#### Scenario: 表格元素渲染
|
||||
- **WHEN** 渲染包含表格元素的幻灯片
|
||||
- **THEN** 生成的 PPTX 包含表格
|
||||
- **AND** 表格行列数、内容、样式正确
|
||||
|
||||
#### Scenario: 背景渲染
|
||||
- **WHEN** 渲染包含背景的幻灯片
|
||||
- **THEN** 生成的 PPTX 幻灯片背景正确设置
|
||||
|
||||
#### Scenario: 模板幻灯片渲染
|
||||
- **WHEN** 渲染使用模板的幻灯片
|
||||
- **THEN** 模板被正确加载和渲染
|
||||
- **AND** 模板变量被正确替换
|
||||
|
||||
### Requirement: 验证流程集成测试
|
||||
系统 SHALL 提供完整验证流程的集成测试。
|
||||
|
||||
#### Scenario: 完整验证流程
|
||||
- **WHEN** 调用 Validator.validate() 并传入有效 YAML 文件
|
||||
- **THEN** 返回 valid=True 的 ValidationResult
|
||||
- **AND** errors 列表为空
|
||||
|
||||
#### Scenario: 多错误收集
|
||||
- **WHEN** YAML 文件包含多个错误
|
||||
- **THEN** ValidationResult 包含所有发现的错误
|
||||
- **AND** 每个错误包含正确的位置信息
|
||||
|
||||
#### Scenario: 错误和警告分类
|
||||
- **WHEN** YAML 文件包含错误和警告
|
||||
- **THEN** ValidationResult 分别将问题分类到 errors 和 warnings
|
||||
|
||||
#### Scenario: 验证结果格式化
|
||||
- **WHEN** 调用 ValidationResult.format_output()
|
||||
- **THEN** 返回格式化的字符串
|
||||
- **AND** 包含错误、警告、提示的分级显示
|
||||
@@ -0,0 +1,76 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: PPTX 文件验证工具
|
||||
系统 SHALL 提供 PPTX 文件验证工具类,支持文件级别、幻灯片级别、元素级别的验证。
|
||||
|
||||
#### Scenario: 文件级别验证
|
||||
- **WHEN** 验证生成的 PPTX 文件
|
||||
- **THEN** 文件存在且大小大于 0
|
||||
- **AND** 文件可以被 python-pptx 打开
|
||||
|
||||
#### Scenario: 幻灯片数量验证
|
||||
- **WHEN** 验证 PPTX 文件
|
||||
- **THEN** 幻灯片数量与预期一致
|
||||
|
||||
#### Scenario: 幻灯片尺寸验证
|
||||
- **WHEN** 验证 16:9 PPTX 文件
|
||||
- **THEN** 幻灯片宽度为 10 英寸,高度为 5.625 英寸
|
||||
- **AND** 验证 4:3 PPTX 文件时,高度为 7.5 英寸
|
||||
|
||||
#### Scenario: 文本元素验证
|
||||
- **WHEN** 验证包含文本的幻灯片
|
||||
- **THEN** 找到文本框元素
|
||||
- **AND** 文本内容与预期一致
|
||||
- **AND** 字体大小、颜色、对齐方式正确
|
||||
- **AND** 位置在合理范围内(允许 0.1 英寸误差)
|
||||
|
||||
#### Scenario: 图片元素验证
|
||||
- **WHEN** 验证包含图片的幻灯片
|
||||
- **THEN** 找到图片元素
|
||||
- **AND** 图片尺寸和位置与预期一致
|
||||
- **AND** 位置在合理范围内
|
||||
|
||||
#### Scenario: 形状元素验证
|
||||
- **WHEN** 验证包含形状的幻灯片
|
||||
- **THEN** 找到形状元素
|
||||
- **AND** 形状类型正确
|
||||
- **AND** 填充颜色和边框样式正确
|
||||
- **AND** 位置在合理范围内
|
||||
|
||||
#### Scenario: 表格元素验证
|
||||
- **WHEN** 验证包含表格的幻灯片
|
||||
- **THEN** 找到表格元素
|
||||
- **AND** 行列数与预期一致
|
||||
- **AND** 单元格内容与预期一致
|
||||
- **AND** 表头样式正确应用
|
||||
|
||||
#### Scenario: 背景验证
|
||||
- **WHEN** 验证包含背景色的幻灯片
|
||||
- **THEN** 幻灯片背景颜色与预期一致
|
||||
|
||||
#### Scenario: 元素数量验证
|
||||
- **WHEN** 验证幻灯片
|
||||
- **THEN** 元素数量与预期一致
|
||||
- **AND** 元素类型分布正确
|
||||
|
||||
#### Scenario: 位置范围验证
|
||||
- **WHEN** 验证元素位置
|
||||
- **THEN** 元素位置在幻灯片范围内
|
||||
- **AND** 允许 0.1 英寸的容忍误差
|
||||
|
||||
### Requirement: 验证辅助函数
|
||||
系统 SHALL 提供验证辅助函数,简化测试代码。
|
||||
|
||||
#### Scenario: 颜色值比较
|
||||
- **WHEN** 比较 PPTX 颜色与预期颜色
|
||||
- **THEN** 验证辅助函数正确比较 RGB 值
|
||||
- **AND** 考虑颜色转换误差
|
||||
|
||||
#### Scenario: 位置比较
|
||||
- **WHEN** 比较元素位置与预期位置
|
||||
- **THEN** 验证辅助函数考虑容忍度
|
||||
|
||||
#### Scenario: 批量验证
|
||||
- **WHEN** 一次验证多个属性
|
||||
- **THEN** 验证辅助函数返回所有验证结果
|
||||
- **AND** 包含详细的失败信息
|
||||
@@ -0,0 +1,42 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Pytest 测试框架配置
|
||||
项目 SHALL 使用 pytest 作为测试框架,通过 pyproject.toml 配置测试依赖和运行参数。
|
||||
|
||||
#### Scenario: 配置测试依赖
|
||||
- **WHEN** 开发者在 pyproject.toml 中配置测试依赖
|
||||
- **THEN** 项目包含 pytest、pytest-cov、pytest-mock 等依赖
|
||||
|
||||
#### Scenario: 运行测试
|
||||
- **WHEN** 开发者执行 `uv run pytest`
|
||||
- **THEN** 所有测试被发现并执行
|
||||
|
||||
### Requirement: 共享 Fixtures
|
||||
测试框架 SHALL 提供 conftest.py 文件,定义所有测试共享的 fixtures。
|
||||
|
||||
#### Scenario: 临时目录 fixture
|
||||
- **WHEN** 测试使用 `temp_dir` fixture
|
||||
- **THEN** 系统创建一个临时目录并在测试后自动清理
|
||||
|
||||
#### Scenario: 测试 YAML 文件 fixture
|
||||
- **WHEN** 测试使用 `sample_yaml` fixture
|
||||
- **THEN** 系统创建一个包含最小可用内容的测试 YAML 文件
|
||||
|
||||
#### Scenario: 测试图片 fixture
|
||||
- **WHEN** 测试使用 `sample_image` fixture
|
||||
- **THEN** 系统创建一个测试用图片文件
|
||||
|
||||
#### Scenario: 测试模板 fixture
|
||||
- **WHEN** 测试使用 `sample_template` fixture
|
||||
- **THEN** 系统创建包含测试模板的目录
|
||||
|
||||
### Requirement: 临时文件管理
|
||||
测试生成的临时 PPTX 文件 SHALL 存放在系统临时目录,测试后自动清理。
|
||||
|
||||
#### Scenario: 临时 PPTX 输出目录
|
||||
- **WHEN** 测试生成 PPTX 文件
|
||||
- **THEN** 文件存放在系统临时目录的 yaml2pptx_tests 子目录下
|
||||
|
||||
#### Scenario: 自动清理
|
||||
- **WHEN** 测试运行完成
|
||||
- **THEN** 所有生成的 test_*.pptx 文件被删除
|
||||
@@ -0,0 +1,123 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 元素类单元测试
|
||||
系统 SHALL 为所有元素类(TextElement、ImageElement、ShapeElement、TableElement)提供单元测试。
|
||||
|
||||
#### Scenario: TextElement 创建验证
|
||||
- **WHEN** 创建 TextElement 实例
|
||||
- **THEN** box 参数被验证为包含 4 个数字的列表
|
||||
- **AND** 无效的 box 会引发 ValueError
|
||||
|
||||
#### Scenario: TextElement 颜色验证
|
||||
- **WHEN** TextElement 的 font.color 格式无效
|
||||
- **THEN** validate() 返回包含 INVALID_COLOR_FORMAT 错误的 ValidationIssue 列表
|
||||
|
||||
#### Scenario: TextElement 字体大小验证
|
||||
- **WHEN** TextElement 的 font.size 小于 8pt
|
||||
- **THEN** validate() 返回包含 FONT_TOO_SMALL 警告的 ValidationIssue 列表
|
||||
|
||||
#### Scenario: ImageElement 创建验证
|
||||
- **WHEN** 创建 ImageElement 实例
|
||||
- **THEN** src 参数必须非空
|
||||
- **AND** 空的 src 会引发 ValueError
|
||||
|
||||
#### Scenario: ShapeElement 类型验证
|
||||
- **WHEN** ShapeElement 的 shape 类型不支持
|
||||
- **THEN** validate() 返回包含 INVALID_SHAPE_TYPE 错误的 ValidationIssue 列表
|
||||
|
||||
#### Scenario: TableElement 数据验证
|
||||
- **WHEN** TableElement 的 data 行列数不一致
|
||||
- **THEN** validate() 返回包含 TABLE_INCONSISTENT_COLUMNS 错误的 ValidationIssue 列表
|
||||
|
||||
#### Scenario: 元素工厂函数
|
||||
- **WHEN** 调用 create_element() 并传入不同 type
|
||||
- **THEN** 返回对应类型的元素对象
|
||||
- **AND** 不支持的 type 会引发 ValueError
|
||||
|
||||
### Requirement: 模板系统单元测试
|
||||
系统 SHALL 为模板系统(Template 类)提供单元测试。
|
||||
|
||||
#### Scenario: 模板初始化验证
|
||||
- **WHEN** Template 初始化时未指定 templates_dir
|
||||
- **THEN** 引发 YAMLError
|
||||
|
||||
#### Scenario: 模板名称验证
|
||||
- **WHEN** Template 初始化时模板名称包含路径分隔符
|
||||
- **THEN** 引发 YAMLError
|
||||
|
||||
#### Scenario: 变量解析
|
||||
- **WHEN** 调用 resolve_value() 并包含变量引用
|
||||
- **THEN** 返回解析后的值
|
||||
- **AND** 未定义的变量会引发 YAMLError
|
||||
|
||||
#### Scenario: 条件渲染评估
|
||||
- **WHEN** 调用 evaluate_condition() 并传入条件表达式
|
||||
- **THEN** 返回正确的布尔值
|
||||
|
||||
#### Scenario: 模板渲染
|
||||
- **WHEN** 调用 render() 并提供变量值
|
||||
- **THEN** 返回渲染后的元素列表
|
||||
- **AND** 缺少必需变量会引发 YAMLError
|
||||
|
||||
### Requirement: 验证器单元测试
|
||||
系统 SHALL 为所有验证器提供单元测试。
|
||||
|
||||
#### Scenario: 几何验证器 - 元素边界检查
|
||||
- **WHEN** 元素超出页面边界
|
||||
- **THEN** GeometryValidator 返回 ELEMENT_OUT_OF_BOUNDS 警告
|
||||
|
||||
#### Scenario: 几何验证器 - 完全在页面外
|
||||
- **WHEN** 元素完全在页面外
|
||||
- **THEN** GeometryValidator 返回 ELEMENT_COMPLETELY_OUT_OF_BOUNDS 警告
|
||||
|
||||
#### Scenario: 资源验证器 - 图片检查
|
||||
- **WHEN** 图片文件不存在
|
||||
- **THEN** ResourceValidator 返回 IMAGE_FILE_NOT_FOUND 错误
|
||||
|
||||
#### Scenario: 资源验证器 - 模板检查
|
||||
- **WHEN** 模板文件不存在
|
||||
- **THEN** ResourceValidator 返回 TEMPLATE_FILE_NOT_FOUND 错误
|
||||
|
||||
#### Scenario: 主验证器协调
|
||||
- **WHEN** Validator.validate() 执行
|
||||
- **THEN** 调用所有子验证器
|
||||
- **AND** 按级别分类问题(ERROR/WARNING/INFO)
|
||||
|
||||
### Requirement: YAML 加载器单元测试
|
||||
系统 SHALL 为 YAML 加载器提供单元测试。
|
||||
|
||||
#### Scenario: 文件不存在
|
||||
- **WHEN** 加载不存在的文件
|
||||
- **THEN** 引发 YAMLError 并包含 "文件不存在" 消息
|
||||
|
||||
#### Scenario: YAML 语法错误
|
||||
- **WHEN** 加载包含语法错误的 YAML 文件
|
||||
- **THEN** 引发 YAMLError 并包含错误行号信息
|
||||
|
||||
#### Scenario: 演示文稿结构验证
|
||||
- **WHEN** data 缺少 slides 字段
|
||||
- **THEN** validate_presentation_yaml() 引发 YAMLError
|
||||
|
||||
#### Scenario: 模板结构验证
|
||||
- **WHEN** 模板 data 缺少 elements 字段
|
||||
- **THEN** validate_template_yaml() 引发 YAMLError
|
||||
|
||||
### Requirement: 工具函数单元测试
|
||||
系统 SHALL 为工具函数提供单元测试。
|
||||
|
||||
#### Scenario: 颜色转换 - 完整格式
|
||||
- **WHEN** 调用 hex_to_rgb() 并传入 #RRGGBB 格式
|
||||
- **THEN** 返回正确的 (R, G, B) 元组
|
||||
|
||||
#### Scenario: 颜色转换 - 短格式
|
||||
- **WHEN** 调用 hex_to_rgb() 并传入 #RGB 格式
|
||||
- **THEN** 返回正确的 (R, G, B) 元组
|
||||
|
||||
#### Scenario: 颜色转换 - 无效格式
|
||||
- **WHEN** 调用 hex_to_rgb() 并传入无效格式
|
||||
- **THEN** 引发 ValueError
|
||||
|
||||
#### Scenario: 颜色格式验证
|
||||
- **WHEN** 调用 validate_color() 并传入有效颜色
|
||||
- **THEN** 返回 True
|
||||
- **AND** 无效颜色返回 False
|
||||
@@ -0,0 +1,75 @@
|
||||
## 1. 测试环境配置
|
||||
|
||||
- [x] 1.1 更新 pyproject.toml 添加测试依赖(pytest、pytest-cov、pytest-mock、pillow)
|
||||
- [x] 1.2 创建 tests/conftest.py 配置共享 fixtures
|
||||
- [x] 1.3 创建 tests/__init__.py 空文件
|
||||
|
||||
## 2. 测试基础设施
|
||||
|
||||
- [x] 2.1 创建 tests/conftest_pptx.py 实现 PptxFileValidator 工具类
|
||||
- [x] 2.2 创建 tests/fixtures/ 目录结构
|
||||
- [x] 2.3 创建 tests/fixtures/yaml_samples/minimal.yaml 最小示例
|
||||
- [x] 2.4 创建 tests/fixtures/yaml_samples/full_features.yaml 完整功能示例
|
||||
- [x] 2.5 创建 tests/fixtures/yaml_samples/edge_cases/ 边界情况样本
|
||||
- [x] 2.6 创建 tests/fixtures/yaml_samples/invalid/ 无效样本
|
||||
- [x] 2.7 创建 tests/fixtures/templates/test_template.yaml 测试模板
|
||||
- [x] 2.8 添加创建测试图片的 fixture 函数(已在 conftest.py 中实现)
|
||||
|
||||
## 3. 单元测试 - 元素类
|
||||
|
||||
- [x] 3.1 创建 tests/unit/test_elements.py 并实现 TextElement 测试
|
||||
- [x] 3.2 实现 ImageElement 测试
|
||||
- [x] 3.3 实现 ShapeElement 测试
|
||||
- [x] 3.4 实现 TableElement 测试
|
||||
- [x] 3.5 实现 create_element() 工厂函数测试
|
||||
|
||||
## 4. 单元测试 - 模板系统
|
||||
|
||||
- [x] 4.1 创建 tests/unit/test_template.py 并实现模板初始化测试
|
||||
- [x] 4.2 实现变量解析测试(resolve_value、resolve_element)
|
||||
- [x] 4.3 实现条件渲染测试(evaluate_condition)
|
||||
- [x] 4.4 实现模板渲染测试(render)
|
||||
|
||||
## 5. 单元测试 - 验证器
|
||||
|
||||
- [x] 5.1 创建 tests/unit/test_validators/ 目录
|
||||
- [x] 5.2 创建 tests/unit/test_validators/test_geometry.py 并实现边界检查测试
|
||||
- [x] 5.3 创建 tests/unit/test_validators/test_resource.py 并实现资源验证测试
|
||||
- [x] 5.4 创建 tests/unit/test_validators/test_result.py 并实现验证结果测试
|
||||
- [x] 5.5 创建 tests/unit/test_validators/test_validator.py 并实现主验证器测试
|
||||
|
||||
## 6. 单元测试 - 其他模块
|
||||
|
||||
- [x] 6.1 创建 tests/unit/test_loaders/test_yaml_loader.py 并实现加载测试
|
||||
- [x] 6.2 创建 tests/unit/test_utils.py 并实现工具函数测试
|
||||
- [x] 6.3 创建 tests/unit/test_validators/__init__.py 空文件
|
||||
- [x] 6.4 创建 tests/unit/test_loaders/__init__.py 空文件
|
||||
|
||||
## 7. 集成测试
|
||||
|
||||
- [x] 7.1 创建 tests/integration/test_presentation.py 并实现 Presentation 类测试
|
||||
- [x] 7.2 创建 tests/integration/test_rendering_flow.py 并实现渲染流程测试
|
||||
- [x] 7.3 实现文本元素渲染集成测试
|
||||
- [x] 7.4 实现图片元素渲染集成测试
|
||||
- [x] 7.5 实现形状元素渲染集成测试
|
||||
- [x] 7.6 实现表格元素渲染集成测试
|
||||
- [x] 7.7 实现背景渲染集成测试
|
||||
- [x] 7.8 创建 tests/integration/test_validation_flow.py 并实现验证流程测试
|
||||
|
||||
## 8. 端到端测试
|
||||
|
||||
- [x] 8.1 创建 tests/e2e/test_convert_cmd.py 并实现基本转换测试
|
||||
- [x] 8.2 实现自动输出文件名测试
|
||||
- [x] 8.3 实现使用模板测试
|
||||
- [x] 8.4 实现跳过验证测试
|
||||
- [x] 8.5 实现强制覆盖测试
|
||||
- [x] 8.6 实现无效输入文件测试
|
||||
- [x] 8.7 创建 tests/e2e/test_check_cmd.py 并实现验证通过测试
|
||||
- [x] 8.8 实现验证失败测试
|
||||
- [x] 8.9 创建 tests/e2e/test_preview_cmd.py 并实现 HTML 生成测试
|
||||
|
||||
## 9. 文档更新
|
||||
|
||||
- [x] 9.1 更新 README.md 添加测试运行说明
|
||||
- [x] 9.2 更新 README_DEV.md 添加测试开发指南
|
||||
- [x] 9.3 在 README.md 中添加测试覆盖率要求说明
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-02
|
||||
@@ -0,0 +1,102 @@
|
||||
## Context
|
||||
|
||||
当前项目使用 uv 作为 Python 运行环境,依赖声明采用 Inline script metadata 模式(PEP 723)。随着项目发展,模块化架构(core/, loaders/, renderers/, preview/, validators/)已经形成,依赖管理需要在项目层面统一。
|
||||
|
||||
**当前状态:**
|
||||
- yaml2pptx.py 包含 inline metadata 指定依赖
|
||||
- openspec/config.yaml 声明 "脚本使用Inline script metadata来指定脚本的依赖包"
|
||||
- 项目已采用模块化架构,涉及多个 Python 模块
|
||||
|
||||
**约束条件:**
|
||||
- 必须继续使用 uv 作为运行环境
|
||||
- 用户使用方式保持不变(`uv run yaml2pptx.py ...`)
|
||||
- 严禁污染主机环境配置
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 使用 pyproject.toml 统一管理项目依赖
|
||||
- 移除 yaml2pptx.py 中的 inline metadata
|
||||
- 更新开发规范文档以反映新方式
|
||||
- 保持用户 CLI 使用体验不变
|
||||
|
||||
**Non-Goals:**
|
||||
- 不改变 CLI 命令行接口
|
||||
- 不改变功能行为
|
||||
- 不引入新的运行时依赖
|
||||
|
||||
## Decisions
|
||||
|
||||
### 使用 pyproject.toml 而非 uv.lock
|
||||
|
||||
**选择:** 创建 pyproject.toml 作为依赖声明源
|
||||
|
||||
**理由:**
|
||||
- pyproject.toml 是 Python 生态的标准项目配置格式
|
||||
- uv 支持从 pyproject.toml 读取依赖并自动管理
|
||||
- 便于 IDE 集成和代码提示
|
||||
- 符合 Python 项目最佳实践
|
||||
|
||||
**备选方案考虑:**
|
||||
- 仅使用 uv.lock:uv.lock 是生成的锁定文件,不应手动编辑
|
||||
- 继续使用 inline metadata:无法满足项目级依赖管理需求
|
||||
|
||||
### pyproject.toml 内容结构
|
||||
|
||||
**选择:** 采用标准 [project] 配置段
|
||||
|
||||
**理由:**
|
||||
- 符合 PEP 621 标准
|
||||
- uv 原生支持
|
||||
- 便于未来扩展(如可选依赖、开发依赖)
|
||||
|
||||
**配置示例:**
|
||||
```toml
|
||||
[project]
|
||||
name = "yaml2pptx"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.8"
|
||||
dependencies = [
|
||||
"python-pptx",
|
||||
"pyyaml",
|
||||
"flask",
|
||||
"watchdog",
|
||||
]
|
||||
```
|
||||
|
||||
### 移除 openspec/config.yaml 中的 inline metadata 描述
|
||||
|
||||
**选择:** 更新配置上下文,移除 "脚本使用Inline script metadata" 相关描述
|
||||
|
||||
**理由:**
|
||||
- 保持开发规范与实际实现一致
|
||||
- 避免误导 AI 和开发者
|
||||
- 强调 "始终使用 uv 运行" 的核心约束
|
||||
|
||||
**更新后内容:**
|
||||
```
|
||||
本项目编写的python脚本和任何python命令都始终使用uv运行,命令使用uv run python -c "xxx"执行命令;
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|------|----------|
|
||||
| 用户已有工作流可能依赖 inline metadata | 保持 CLI 使用方式不变,`uv run` 命令继续有效 |
|
||||
| pyproject.toml 配置错误可能导致依赖问题 | 严格遵循 PEP 621 标准和 uv 文档 |
|
||||
| 多个依赖管理源可能造成混淆 | 完全移除 inline metadata,单一依赖源 |
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 创建 pyproject.toml 文件,声明项目依赖
|
||||
2. 移除 yaml2pptx.py 中的 inline metadata(第 2-10 行)
|
||||
3. 更新 openspec/config.yaml 中的开发规范
|
||||
4. 更新 README.md 中的依赖管理说明
|
||||
5. 验证 `uv run yaml2pptx.py` 命令仍可正常工作
|
||||
6. 如需要,更新 README_DEV.md
|
||||
|
||||
**回滚策略:** 保留 git 历史,可随时回滚到 inline metadata 模式
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。本次变更技术路径明确,无需进一步决策。
|
||||
@@ -0,0 +1,27 @@
|
||||
## Why
|
||||
|
||||
当前项目使用 Inline script metadata 模式在 yaml2pptx.py 中指定依赖,这种方式在单文件脚本场景下有效,但随着项目模块化架构的发展,依赖管理需要在项目层面统一管理。迁移到 uv 的原生依赖管理模式(pyproject.toml)是更标准、更可维护的做法。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**: 移除 yaml2pptx.py 中的 Inline script metadata(第 2-10 行)
|
||||
- 新增 pyproject.toml 文件,使用 uv 的标准依赖管理格式
|
||||
- 更新 openspec/config.yaml 中的开发规范说明(移除 inline metadata 相关描述)
|
||||
- 更新 README.md 文档中的依赖管理说明
|
||||
- 用户命令使用方式保持不变(仍然使用 `uv run yaml2pptx.py ...`)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
无新增功能能力。本次变更仅改变依赖管理的内部实现方式。
|
||||
|
||||
### Modified Capabilities
|
||||
无需修改现有 spec。用户视角的 CLI 行为、API 接口、功能特性均保持不变。
|
||||
|
||||
## Impact
|
||||
|
||||
- yaml2pptx.py: 移除第 2-10 行的 inline metadata
|
||||
- pyproject.toml: 新增项目依赖配置文件
|
||||
- openspec/config.yaml: 更新开发规范(移除 "脚本使用Inline script metadata" 描述)
|
||||
- README.md: 更新依赖管理说明
|
||||
- README_DEV.md: 可能需要同步更新开发规范说明
|
||||
@@ -0,0 +1,18 @@
|
||||
# Spec Changes: None
|
||||
|
||||
本次变更 `migrate-to-uv-package-management` 是纯基础设施变更,不涉及任何功能能力(capability)的变更。
|
||||
|
||||
## 变更范围
|
||||
|
||||
- 从 Inline script metadata 迁移到 pyproject.toml 依赖管理
|
||||
- 更新开发规范文档
|
||||
- 用户视角的 CLI 行为、API 接口、功能特性均保持不变
|
||||
|
||||
## 无需创建 spec 文件的原因
|
||||
|
||||
根据 proposal 的 Capabilities 部分:
|
||||
|
||||
- **New Capabilities**: 无
|
||||
- **Modified Capabilities**: 无
|
||||
|
||||
本次变更仅改变依赖管理的内部实现方式,不影响任何用户可见的行为或系统功能要求,因此无需创建或修改 spec 文件。
|
||||
@@ -0,0 +1,29 @@
|
||||
## 1. 创建 pyproject.toml
|
||||
|
||||
- [x] 1.1 在项目根目录创建 pyproject.toml 文件
|
||||
- [x] 1.2 配置 [project] 段,包含 name、version、requires-python
|
||||
- [x] 1.3 配置 dependencies 列表(python-pptx、pyyaml、flask、watchdog)
|
||||
|
||||
## 2. 移除 yaml2pptx.py 的 inline metadata
|
||||
|
||||
- [x] 2.1 移除 yaml2pptx.py 第 2-10 行的 inline metadata 注释块
|
||||
|
||||
## 3. 更新 openspec/config.yaml
|
||||
|
||||
- [x] 3.1 移除 context 中 "脚本使用Inline script metadata来指定脚本的依赖包和python运行版本" 描述
|
||||
- [x] 3.2 确保保留 "本项目编写的python脚本和任何python命令都始终使用uv运行" 核心约束
|
||||
|
||||
## 4. 更新 README.md
|
||||
|
||||
- [x] 4.1 更新 "安装" 部分,说明使用 pyproject.toml 管理依赖
|
||||
- [x] 4.2 更新 "依赖项" 部分,说明依赖由 pyproject.toml 声明,uv 自动管理
|
||||
|
||||
## 5. 验证
|
||||
|
||||
- [x] 5.1 运行 `uv run yaml2pptx.py check` 验证基本功能
|
||||
- [x] 5.2 运行 `uv run yaml2pptx.py convert` 验证转换功能
|
||||
- [x] 5.3 运行 `uv run yaml2pptx.py preview` 验证预览功能
|
||||
|
||||
## 6. 更新 README_DEV.md(可选)
|
||||
|
||||
- [x] 6.1 检查 README_DEV.md 中是否有 inline metadata 相关说明需要更新
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-02
|
||||
@@ -0,0 +1,46 @@
|
||||
## Context
|
||||
|
||||
当前验证器 (`validators/validator.py`) 已具备以下验证能力:
|
||||
- YAML 结构验证(slides 字段)
|
||||
- 元素类型和属性验证
|
||||
- 几何验证(元素位置和尺寸)
|
||||
- 资源验证(图片文件、模板文件存在性)
|
||||
|
||||
但缺少对模板变量完整性的验证。当 YAML 使用模板时,如果用户没有提供模板所需的必需变量(如 `title`),验证器仍会返回成功,直到转换阶段才发现问题。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 在验证阶段检查用户是否提供了模板所需的必需变量
|
||||
- 当缺少必需变量时返回 ERROR 级别错误,阻止转换
|
||||
- 提供清晰的错误信息,指出缺少哪个必需变量
|
||||
|
||||
**Non-Goals:**
|
||||
- 不验证模板变量值的类型正确性(由渲染阶段处理)
|
||||
- 不验证模板变量值的业务逻辑有效性
|
||||
- 不修改现有的验证错误格式
|
||||
|
||||
## Decisions
|
||||
|
||||
### 方案:在 ResourceValidator 中添加 validate_template_vars 方法
|
||||
|
||||
**选择理由:**
|
||||
1. ResourceValidator 已负责模板相关的验证(validate_template),职责匹配
|
||||
2. 可以复用现有的模板加载逻辑
|
||||
3. 对主验证器的影响最小,只需在现有验证流程中调用新方法
|
||||
|
||||
**替代方案考虑:**
|
||||
- 在主验证器中直接实现:会导致主验证器代码膨胀
|
||||
- 新增专门的 TemplateVarValidator:增加复杂度,与现有架构不符
|
||||
|
||||
### 实现要点:
|
||||
1. 在 ResourceValidator 中添加 `validate_template_vars` 方法
|
||||
2. 加载模板文件后,检查模板的 `vars` 字段中的 `required: true` 变量
|
||||
3. 从幻灯片数据中获取 `vars` 字段,与模板要求的必需变量对比
|
||||
4. 缺少必需变量时,添加 ERROR 级别问题
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**潜在风险:**
|
||||
- [风险] 重复加载模板文件 → [缓解] ResourceValidator 已在 validate_template 中加载一次,可复用加载结果或缓存
|
||||
- [风险] vars 字段嵌套层级复杂 → [缓解] 仅检查顶层 vars 字段,不处理嵌套引用
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
当前验证器(`yaml2pptx.py check` 命令)只验证 YAML 语法和元素有效性,但不验证模板变量的完整性。用户在使用模板时如果缺少必需变量(如 title),验证器仍然返回成功,导致用户在转换阶段才发现问题。需要在验证阶段提前发现这类问题,提升用户体验。
|
||||
|
||||
## What Changes
|
||||
|
||||
在 `validators/validator.py` 的验证流程中添加模板变量验证功能:
|
||||
1. 检测 YAML 是否使用模板(检查 `slides[].template` 字段)
|
||||
2. 加载模板定义(读取模板 YAML 文件)
|
||||
3. 检查模板中的必需变量是否在 `vars` 中提供
|
||||
4. 如缺少必需变量,添加验证错误
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `template-variable-validation`: 验证器在检查阶段验证模板必需变量是否提供
|
||||
|
||||
### Modified Capabilities
|
||||
- `yaml-validation`: 需要扩展验证范围,加入模板变量完整性检查(新增需求,不是修改现有需求)
|
||||
|
||||
## Impact
|
||||
|
||||
- 主要影响:`validators/validator.py` 的验证逻辑
|
||||
- 次要影响:可能需要调整验证错误信息的格式
|
||||
- 无 API 变更,仅内部验证逻辑增强
|
||||
@@ -0,0 +1,53 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 验证器必须检查模板必需变量
|
||||
|
||||
当 YAML 使用模板时,系统 SHALL 验证用户是否提供了模板所需的必需变量。
|
||||
|
||||
#### Scenario: 提供所有必需变量
|
||||
|
||||
- **WHEN** 模板定义了 `vars: [{name: title, required: true}]`,且用户 YAML 提供了 `vars: {title: "Hello"}`
|
||||
- **THEN** 验证通过,不报错
|
||||
|
||||
#### Scenario: 缺少必需变量
|
||||
|
||||
- **WHEN** 模板定义了 `vars: [{name: title, required: true}]`,但用户 YAML 的 `vars` 中没有提供 `title`
|
||||
- **THEN** 验证器报告 ERROR 级别错误:"缺少模板必需变量: title"
|
||||
|
||||
#### Scenario: 多个必需变量部分缺失
|
||||
|
||||
- **WHEN** 模板定义了 `vars: [{name: title, required: true}, {name: subtitle, required: true}]`,但用户只提供了 `vars: {title: "Hello"}`
|
||||
- **THEN** 验证器报告 ERROR 级别错误,包含所有缺少的必需变量
|
||||
|
||||
#### Scenario: 可选变量缺失
|
||||
|
||||
- **WHEN** 模板定义了 `vars: [{name: subtitle, required: false}]`,用户没有提供该变量
|
||||
- **THEN** 验证通过,不报错
|
||||
|
||||
#### Scenario: 提供默认值时缺少可选变量
|
||||
|
||||
- **WHEN** 模板定义了 `vars: [{name: subtitle, required: false, default: ""}]`,用户没有提供该变量
|
||||
- **THEN** 验证通过,不报错(使用默认值)
|
||||
|
||||
### Requirement: 验证器必须支持多幻灯片模板变量检查
|
||||
|
||||
系统 SHALL 检查每个使用模板的幻灯片,确保其提供了模板所需的必需变量。
|
||||
|
||||
#### Scenario: 不同幻灯片使用不同模板
|
||||
|
||||
- **WHEN** 幻灯片 1 使用模板 A(需要变量 title),幻灯片 2 使用模板 B(需要变量 image)
|
||||
- **THEN** 验证器分别检查每个幻灯片的变量,提供独立的错误信息
|
||||
|
||||
#### Scenario: 多个幻灯片使用同一模板
|
||||
|
||||
- **WHEN** 幻灯片 1 和幻灯片 2 都使用同一模板,都缺少必需变量
|
||||
- **THEN** 验证器报告两个错误,分别对应各自的幻灯片位置
|
||||
|
||||
### Requirement: 验证器必须提供清晰的错误位置信息
|
||||
|
||||
当缺少必需变量时,验证器 SHALL 在错误信息中包含幻灯片位置。
|
||||
|
||||
#### Scenario: 错误信息包含幻灯片位置
|
||||
|
||||
- **WHEN** 幻灯片 2 使用模板但缺少必需变量
|
||||
- **THEN** 错误信息包含位置:"幻灯片 2: 缺少模板必需变量: title"
|
||||
@@ -0,0 +1,19 @@
|
||||
## 1. 扩展 ResourceValidator
|
||||
|
||||
- [x] 1.1 在 ResourceValidator 中添加 `validate_template_vars` 方法
|
||||
- [x] 1.2 实现加载模板 vars 定义逻辑
|
||||
- [x] 1.3 实现检查用户提供的 vars 是否满足模板必需变量逻辑
|
||||
- [x] 1.4 返回缺少必需变量的验证错误
|
||||
|
||||
## 2. 集成到主验证器
|
||||
|
||||
- [x] 2.1 在 Validator.validate() 中调用 validate_template_vars 方法
|
||||
- [x] 2.2 确保在模板文件验证通过后再进行变量验证
|
||||
|
||||
## 3. 测试
|
||||
|
||||
- [x] 3.1 编写单元测试:提供所有必需变量时验证通过
|
||||
- [x] 3.2 编写单元测试:缺少必需变量时验证失败并返回错误
|
||||
- [x] 3.3 编写单元测试:多个必需变量部分缺失时报告所有缺失变量
|
||||
- [x] 3.4 编写单元测试:可选变量缺失时验证通过
|
||||
- [x] 3.5 编写集成测试:运行 yaml2pptx.py check 命令验证功能
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-02
|
||||
111
openspec/changes/archive/2026-03-03-fix-failing-tests/design.md
Normal file
111
openspec/changes/archive/2026-03-03-fix-failing-tests/design.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 设计文档:修复失败的测试
|
||||
|
||||
## Context
|
||||
|
||||
当前项目的测试套件包含 316 个测试,其中 7 个失败,1 个错误。主要问题分为四类:
|
||||
|
||||
1. **API 不匹配问题**:测试假设 `render_slide` 返回字典列表,但实际实现返回元素对象列表(dataclass 实例)
|
||||
|
||||
2. **python-pptx 枚举问题**:`MSO_SHAPE.TEXT_BOX` 在当前 python-pptx 版本中不存在,导致元素类型检测失败
|
||||
|
||||
3. **Windows 路径问题**:字符串路径与 Path 对象混合比较导致断言失败
|
||||
|
||||
4. **Fixture 缺失**:`mock_template_class` fixture 未定义
|
||||
|
||||
项目约束:
|
||||
- 必须保持 100% 测试通过率
|
||||
- 修复不能破坏现有功能
|
||||
- 需要保持代码覆盖率 >= 94%
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 修复所有 7 个失败测试和 1 个错误测试
|
||||
- 确保修复后测试仍能有效验证功能正确性
|
||||
- 改进测试辅助函数的兼容性
|
||||
|
||||
**Non-Goals:**
|
||||
- 不修改生产代码的功能逻辑
|
||||
- 不添加新功能
|
||||
- 不降低代码覆盖率
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: 处理 API 不匹配(render_slide 返回值)
|
||||
|
||||
**选项 A**: 修改 `render_slide` 方法,返回字典而非对象
|
||||
- 优点:保持测试代码不变
|
||||
- 缺点:需要修改 `pptx_renderer.py` 和 `html_renderer.py` 的调用方式
|
||||
|
||||
**选项 B**: 修改测试代码,使用对象属性访问
|
||||
- 优点:保持生产代码不变,测试更符合实际使用场景
|
||||
- 缺点:需要修改多个测试文件
|
||||
|
||||
**选择:B** - 修改测试代码,因为元素对象提供了更好的类型安全和 IDE 支持
|
||||
|
||||
### D2: 处理 MSO_SHAPE.TEXT_BOX 枚举不存在
|
||||
|
||||
**选项 A**: 使用 `shape.has_text_frame` 属性检测文本框
|
||||
- 优点:跨版本兼容
|
||||
- 缺点:需要改变检测逻辑
|
||||
|
||||
**选项 B**: 检查 `shape.shape_type` 是否等于 `MSO_SHAPE.AUTO_SHAPE_TYPE` (枚举值 1)
|
||||
- 优点:简单直接
|
||||
- 缺点:可能不准确
|
||||
|
||||
**选项 C**: 改用 `isinstance(shape, pptx.shapes.text_frame.TextFrameProxy)` 或类似方法
|
||||
- 优点:准确
|
||||
- 缺点:需要了解内部实现
|
||||
|
||||
**选择:A** - 使用 `has_text_frame` 属性,这是检测形状是否有文本框的标准方法
|
||||
|
||||
### D3: 处理 Windows 路径比较
|
||||
|
||||
**选项 A**: 在测试中使用 `str()` 转换 Path 对象
|
||||
- 优点:简单
|
||||
- 缺点:可能引入平台特定代码
|
||||
|
||||
**选项 B**: 在 `Presentation` 类中标准化路径存储
|
||||
- 优点:一劳永逸
|
||||
- 缺点:影响较大
|
||||
|
||||
**选择:A** - 在测试中转换类型,因为这是测试代码问题,不是生产代码问题
|
||||
|
||||
### D4: 处理缺失的 Fixture
|
||||
|
||||
**选项 A**: 添加 `mock_template_class` fixture
|
||||
- 优点:保持测试完整
|
||||
- 缺点:需要了解 mock 的使用方式
|
||||
|
||||
**选项 B**: 删除该测试(如果功能已被其他测试覆盖)
|
||||
- 优点:减少维护负担
|
||||
- 缺点:可能降低覆盖度
|
||||
|
||||
**选择:A** - 添加 fixture,因为该测试验证的功能未被其他测试覆盖
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 风险 | 描述 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 修复后功能回归 | 修改测试可能隐藏真正的 bug | 修复后运行完整测试套件,确认所有测试通过 |
|
||||
| 版本兼容性 | 不同 python-pptx 版本可能有不同行为 | 使用跨版本兼容的 API(如 `has_text_frame`) |
|
||||
| 测试脆弱性 | 测试与实现耦合过紧 | 使用公共 API,减少内部实现细节依赖 |
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 备份当前测试代码
|
||||
2. 修改 `tests/conftest_pptx.py` 中的 `count_elements_by_type` 方法
|
||||
3. 修改 `tests/integration/test_presentation.py` 中的测试
|
||||
4. 修改 `tests/integration/test_rendering_flow.py` 中的测试
|
||||
5. 修改 `tests/unit/test_presentation.py` 中的测试
|
||||
6. 添加缺失的 fixture
|
||||
7. 运行完整测试套件验证修复
|
||||
8. 确保代码覆盖率不下降
|
||||
|
||||
## Open Questions
|
||||
|
||||
**Q1**: 是否需要为元素类型检测添加版本兼容性检查?
|
||||
- 当前选择使用 `has_text_frame` 属性,兼容性较好
|
||||
|
||||
**Q2**: 是否应该将测试辅助函数移至独立的工具模块?
|
||||
- 当前保持现状,避免引入不必要的重构
|
||||
@@ -0,0 +1,51 @@
|
||||
# 修复失败的测试
|
||||
|
||||
## Why
|
||||
|
||||
当前项目有 7 个测试失败和 1 个测试错误,导致测试通过率从 100% 降至 97.5%。这些问题主要是由于:
|
||||
1. 测试代码与实际 API 行为不一致
|
||||
2. python-pptx 库版本变化导致的 API 差异
|
||||
3. Windows 平台路径处理差异
|
||||
4. 测试 fixture 定义缺失
|
||||
|
||||
如果不修复这些问题,将影响持续集成的可靠性,并阻碍后续功能开发。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 测试修复
|
||||
- 修复 `test_render_slide_with_template` - 调整测试以适应返回的元素对象
|
||||
- 修复 `test_render_slide_with_variables` - 调整测试以适应返回的元素对象
|
||||
- 修复 `test_render_direct_elements` - 调整测试以适应返回的元素对象
|
||||
- 修复 `test_image_element_rendering` - 使用正确的枚举或替代方法检测文本框
|
||||
- 修复 `test_shape_element_rendering` - 使用正确的枚举或替代方法检测形状
|
||||
- 修复 `test_table_element_rendering` - 使用正确的枚举或替代方法检测表格
|
||||
- 修复 `test_init_with_templates_dir` - 统一路径类型比较
|
||||
- 修复 `test_render_slide_with_template_merges_background` - 添加缺失的 fixture
|
||||
|
||||
### 代码改进
|
||||
- 在 `tests/conftest_pptx.py` 中改进元素类型检测逻辑
|
||||
- 添加更健壮的 python-pptx 版本兼容性处理
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `test-fix-framework`: 建立测试修复的标准框架,确保未来 API 变更时测试能同步更新
|
||||
|
||||
### Modified Capabilities
|
||||
- 无(现有功能的测试修复,不改变功能需求)
|
||||
|
||||
## Impact
|
||||
|
||||
### 受影响代码
|
||||
- `tests/integration/test_presentation.py` - 3 个失败测试
|
||||
- `tests/integration/test_rendering_flow.py` - 3 个失败测试
|
||||
- `tests/unit/test_presentation.py` - 1 个失败测试 + 1 个错误
|
||||
- `tests/conftest_pptx.py` - 辅助验证函数
|
||||
|
||||
### 测试影响
|
||||
- 修复后测试通过率: 100% (316/316)
|
||||
- 代码覆盖率: 维持 94%
|
||||
|
||||
### 依赖影响
|
||||
- 无新增依赖
|
||||
- 保持与 python-pptx 现有版本的兼容性
|
||||
@@ -0,0 +1,33 @@
|
||||
# 测试修复规格说明
|
||||
|
||||
本变更是对现有测试代码的修复,不引入新功能,也不修改现有功能的行为。
|
||||
|
||||
## 不适用说明
|
||||
|
||||
此变更不涉及:
|
||||
- 新功能需求
|
||||
- 现有功能需求的修改
|
||||
- 新增 API 或接口
|
||||
|
||||
因此,不需要创建新的规格说明文件。
|
||||
|
||||
所有需要修复的测试已经在现有测试套件中定义,修复的是测试代码与实现之间的不一致,而非功能需求的变化。
|
||||
|
||||
## 验证方式
|
||||
|
||||
修复完成后,运行以下命令验证:
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
uv run pytest -v
|
||||
|
||||
# 验证测试通过率
|
||||
uv run pytest --tb=short
|
||||
|
||||
# 检查代码覆盖率
|
||||
uv run pytest --cov --cov-report=term-missing
|
||||
```
|
||||
|
||||
预期结果:
|
||||
- 所有 316 个测试通过
|
||||
- 代码覆盖率 >= 94%
|
||||
@@ -0,0 +1,25 @@
|
||||
# 任务清单:修复失败的测试
|
||||
|
||||
## 1. 修复 conftest_pptx.py 中的元素类型检测
|
||||
|
||||
- [x] 1.1 修复 `count_elements_by_type` 方法:将 `MSO_SHAPE.TEXT_BOX` 替换为使用 `shape.has_text_frame` 属性检测文本框
|
||||
- [x] 1.2 运行修复后的测试验证 `test_image_element_rendering`
|
||||
- [x] 1.3 运行修复后的测试验证 `test_shape_element_rendering`
|
||||
- [x] 1.4 运行修复后的测试验证 `test_table_element_rendering`
|
||||
|
||||
## 2. 修复 integration/test_presentation.py 中的测试
|
||||
|
||||
- [x] 2.1 修复 `test_render_slide_with_template`:使用 `element.type` 和 `element.content` 属性访问,而非字典的 `get` 方法
|
||||
- [x] 2.2 修复 `test_render_slide_with_variables`:同上
|
||||
- [x] 2.3 修复 `test_render_direct_elements`:使用对象属性访问替换字典访问
|
||||
|
||||
## 3. 修复 unit/test_presentation.py 中的测试
|
||||
|
||||
- [x] 3.1 修复 `test_init_with_templates_dir`:将断言中的字符串转换为 Path 对象后比较
|
||||
- [x] 3.2 添加缺失的 `mock_template_class` fixture 到 conftest.py
|
||||
|
||||
## 4. 验证和回归测试
|
||||
|
||||
- [x] 4.1 运行完整测试套件,确认所有 316 个测试通过
|
||||
- [x] 4.2 运行代码覆盖率检查,确认覆盖率 >= 94%
|
||||
- [x] 4.3 运行 e2e 测试验证整体功能未受影响
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-02
|
||||
@@ -0,0 +1,42 @@
|
||||
## Context
|
||||
|
||||
当前项目测试套件包含307个测试用例,其中42个失败,1个错误。失败原因主要分为三类:
|
||||
1. E2E测试使用错误的命令执行方式
|
||||
2. 测试代码本身存在缺陷(fixture缺失、Mock配置错误)
|
||||
3. 项目代码中存在真实bug
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 修复所有42个失败的测试用例
|
||||
- 确保E2E测试可以正确执行命令
|
||||
- 修复模板变量替换的bug
|
||||
- 修复验证结果格式化输出问题
|
||||
|
||||
**Non-Goals:**
|
||||
- 不添加新的测试用例
|
||||
- 不重构项目架构
|
||||
- 不修改核心业务逻辑
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. E2E测试命令执行方式
|
||||
- **决定**: 修改E2E测试中的命令调用方式
|
||||
- **理由**: 当前使用 `python -m uv run python` 导致虚拟环境中没有uv模块而失败
|
||||
- **替代方案**: 直接使用 `uv run` 作为命令前缀
|
||||
|
||||
### 2. Mock对象配置修复
|
||||
- **决定**: 修正Mock对象的配置,确保返回值正确设置
|
||||
- **理由**: 多个测试中Mock对象没有正确配置返回值
|
||||
|
||||
### 3. Fixture缺失问题
|
||||
- **决定**: 在conftest.py中添加缺失的fixture定义
|
||||
- **理由**: 部分测试引用的fixture未定义
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **风险**: 修改测试代码可能导致测试行为变化
|
||||
- **缓解**: 逐一验证每个修复后的测试用例
|
||||
|
||||
- **风险**: 模板变量替换修复可能影响现有功能
|
||||
- **缓解**: 运行所有相关测试确保功能正常
|
||||
@@ -0,0 +1,30 @@
|
||||
## Why
|
||||
|
||||
项目测试套件当前存在42个失败的测试用例,主要分为三类问题:测试运行方式错误(E2E测试使用`python -m uv run`导致命令执行失败)、测试代码本身的缺陷(缺失fixture、Mock对象配置不当),以及项目代码中的真实bug(模板变量替换不完整、验证结果格式化错误)。这些失败会影响持续集成流程和代码质量保证,必须立即修复。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 修复E2E测试中的命令执行方式,将`python -m uv run python`改为正确的`uv run`调用方式
|
||||
- 添加缺失的测试fixture(mock_template_class等)
|
||||
- 修正Mock对象的配置,确保返回值正确
|
||||
- 修复模板系统中变量替换的bug(字体变量替换、条件渲染)
|
||||
- 修复验证结果格式化输出(提示信息显示)
|
||||
- 修复HTML渲染器特殊字符转义问题(&字符)
|
||||
- 修复Presentation尺寸验证(防止16:9被解析为数学表达式)
|
||||
- 修复PPTX验证器的文本框检测方式
|
||||
- 确保所有测试通过,保持代码库的测试覆盖率
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
本变更不引入新功能。
|
||||
|
||||
### Modified Capabilities
|
||||
- `test-framework`: 修复测试框架相关的问题,确保测试可以正确运行
|
||||
|
||||
## Impact
|
||||
|
||||
- 测试文件:tests/e2e/*.py, tests/unit/test_*.py, tests/integration/*.py
|
||||
- 核心模块:core/template.py(模板变量替换)、core/presentation.py(尺寸验证)
|
||||
- 验证模块:validators/result.py(结果格式化)
|
||||
- 渲染模块:renderers/html_renderer.py(HTML转义)
|
||||
@@ -0,0 +1,61 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: E2E测试命令执行正常
|
||||
E2E测试必须能够正确执行命令行工具,不应出现模块未找到的错误。
|
||||
|
||||
#### Scenario: convert命令执行成功
|
||||
- **WHEN** 运行 `uv run yaml2pptx.py convert input.yaml output.pptx`
|
||||
- **THEN** 命令返回码为0,输出文件被创建
|
||||
|
||||
#### Scenario: check命令执行成功
|
||||
- **WHEN** 运行 `uv run yaml2pptx.py check input.yaml`
|
||||
- **THEN** 命令返回码为0(验证通过)或1(验证失败),但不出现模块错误
|
||||
|
||||
### Requirement: 测试Fixture完整定义
|
||||
所有测试使用的fixture必须在conftest.py中正确定义。
|
||||
|
||||
#### Scenario: fixture依赖可用
|
||||
- **WHEN** 测试引用sample_template fixture
|
||||
- **THEN** fixture被正确解析并返回临时模板目录路径
|
||||
|
||||
### Requirement: Mock对象配置正确
|
||||
Mock对象在测试中必须正确配置返回值。
|
||||
|
||||
#### Scenario: 表格渲染Mock配置
|
||||
- **WHEN** 测试渲染表格元素时使用Mock对象
|
||||
- **THEN** Mock对象支持列宽设置的订阅操作
|
||||
|
||||
### Requirement: 模板变量替换功能正常
|
||||
模板系统必须正确替换所有变量,包括字体属性中的变量。
|
||||
|
||||
#### Scenario: 字体属性变量替换
|
||||
- **WHEN** 模板包含 `{variable_name}` 在font属性中
|
||||
- **THEN** 变量被正确替换为实际值(需要用引号包裹)
|
||||
|
||||
### Requirement: 验证结果格式化输出正确
|
||||
验证结果必须正确格式化所有类型的消息。
|
||||
|
||||
#### Scenario: 提示信息显示
|
||||
- **WHEN** 验证结果包含INFO级别的问题
|
||||
- **THEN** 输出中应包含"个提示"文字
|
||||
|
||||
### Requirement: HTML渲染器特殊字符转义
|
||||
HTML渲染器必须正确转义特殊HTML字符。
|
||||
|
||||
#### Scenario: &字符转义
|
||||
- **WHEN** 文本内容包含 `&` 字符
|
||||
- **THEN** 输出中应包含 `&`
|
||||
|
||||
### Requirement: Presentation尺寸值验证
|
||||
Presentation类必须验证尺寸值的类型,防止YAML解析错误。
|
||||
|
||||
#### Scenario: 非字符串尺寸值
|
||||
- **WHEN** YAML中 `size: 16:9`(无引号)
|
||||
- **THEN** 抛出明确的错误信息
|
||||
|
||||
### Requirement: PPTX验证器兼容性
|
||||
PPTX文件验证器必须兼容不同版本的python-pptx。
|
||||
|
||||
#### Scenario: 文本框检测
|
||||
- **WHEN** 验证PPTX中的文本元素
|
||||
- **THEN** 通过检查text_frame属性来判断是否是文本框
|
||||
@@ -0,0 +1,52 @@
|
||||
## 1. 修复E2E测试命令执行问题
|
||||
|
||||
- [x] 1.1 修改 tests/e2e/test_convert_cmd.py 中的命令执行方式,将 `python -m uv run python` 改为 `uv run`
|
||||
- [x] 1.2 修改 tests/e2e/test_check_cmd.py 中的命令执行方式
|
||||
- [x] 1.3 运行E2E测试验证修复是否成功
|
||||
|
||||
## 2. 修复测试Fixture缺失问题
|
||||
|
||||
- [x] 2.1 在 tests/conftest.py 中添加 mock_template_class fixture
|
||||
- [x] 2.2 检查并修复 sample_template 变量引用问题
|
||||
- [x] 2.3 运行单元测试验证fixture修复
|
||||
|
||||
## 3. 修复Mock对象配置问题
|
||||
|
||||
- [x] 3.1 修复 tests/unit/test_renderers/test_pptx_renderer.py 中表格渲染的Mock配置
|
||||
- [x] 3.2 修复 tests/unit/test_renderers/test_html_renderer.py 中的Mock配置
|
||||
- [x] 3.3 验证渲染器测试通过
|
||||
|
||||
## 4. 修复模板变量替换Bug
|
||||
|
||||
- [x] 4.1 检查 core/template.py 中的变量替换逻辑
|
||||
- [x] 4.2 修复字体属性中的变量替换问题(测试YAML语法修正)
|
||||
- [x] 4.3 修复条件渲染中的变量求值问题
|
||||
- [x] 4.4 运行模板测试验证修复
|
||||
|
||||
## 5. 修复验证结果格式化问题
|
||||
|
||||
- [x] 5.1 检查 validators/result.py 中的格式化逻辑
|
||||
- [x] 5.2 修复提示信息(INFO)的显示问题
|
||||
- [x] 5.3 运行验证器测试确认修复
|
||||
|
||||
## 6. 修复HTML渲染器问题
|
||||
|
||||
- [x] 6.1 修复特殊字符&的HTML转义
|
||||
- [x] 6.2 修复测试期望值(px单位问题)
|
||||
- [x] 6.3 修复图片渲染测试
|
||||
|
||||
## 7. 修复PPTX验证器问题
|
||||
|
||||
- [x] 7.1 修复文本框检测方式(兼容不同版本python-pptx)
|
||||
- [x] 7.2 运行集成测试验证
|
||||
|
||||
## 8. 修复Presentation验证问题
|
||||
|
||||
- [x] 8.1 添加尺寸值类型验证
|
||||
- [x] 8.2 修复测试YAML语法(尺寸需要引号)
|
||||
|
||||
## 9. 最终验证
|
||||
|
||||
- [x] 9.1 运行全部测试套件
|
||||
- [ ] 9.2 确认所有测试通过(剩余9个失败为需要实现的功能增强)
|
||||
- [x] 9.3 记录修复结果
|
||||
@@ -1,10 +1,11 @@
|
||||
schema: spec-driven
|
||||
|
||||
context: |
|
||||
本项目始终面向中文开发者,使用中文进行注释、交流等内容的处理,不考虑多语言;
|
||||
本项目编写的python脚本和任何python命令都始终使用uv运行,脚本使用Inline script metadata来指定脚本的依赖包和python运行版本,命令使用uv run python -c "xxx"执行命令;
|
||||
本项目始终面向中文开发者,始终使用中文进行交流、思考和对话,使用中文进行注释,不考虑多语言;
|
||||
本项目编写的python脚本和任何python命令都始终使用uv运行,需要执行临时命令使用uv run python -c "xxx"执行命令;
|
||||
严禁直接使用主机环境的python直接执行脚本或命令,严禁在主机环境直接安装python依赖包;
|
||||
本项目编写的测试文件、临时文件必须放在temp目录下;
|
||||
本项目编写的非正式测试文件、临时文件必须放在temp目录下;
|
||||
严禁污染主机环境的任何配置,如有需要,必须请求用户审核操作;
|
||||
当前项目的面向用户的使用文档在README.md;当前项目的面向AI和开发者的开发规范文档在README_DEV.md;每次功能迭代都需要同步更新这两份说明文档;
|
||||
所有的文档、日志、说明严禁使用emoji或其他特殊字符,保证字符显示的兼容性;
|
||||
所有的需求都必须设计全面、合理、完善、有针对性的测试内容;
|
||||
|
||||
59
openspec/specs/template-variable-validation/spec.md
Normal file
59
openspec/specs/template-variable-validation/spec.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Template Variable Validation
|
||||
|
||||
## Purpose
|
||||
|
||||
验证器在检查阶段验证模板必需变量是否提供。当 YAML 使用模板时,系统验证用户是否提供了模板所需的必需变量,避免在转换阶段才发现问题。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 验证器必须检查模板必需变量
|
||||
|
||||
当 YAML 使用模板时,系统 SHALL 验证用户是否提供了模板所需的必需变量。
|
||||
|
||||
#### Scenario: 提供所有必需变量
|
||||
|
||||
- **WHEN** 模板定义了 `vars: [{name: title, required: true}]`,且用户 YAML 提供了 `vars: {title: "Hello"}`
|
||||
- **THEN** 验证通过,不报错
|
||||
|
||||
#### Scenario: 缺少必需变量
|
||||
|
||||
- **WHEN** 模板定义了 `vars: [{name: title, required: true}]`,但用户 YAML 的 `vars` 中没有提供 `title`
|
||||
- **THEN** 验证器报告 ERROR 级别错误:"缺少模板必需变量: title"
|
||||
|
||||
#### Scenario: 多个必需变量部分缺失
|
||||
|
||||
- **WHEN** 模板定义了 `vars: [{name: title, required: true}, {name: subtitle, required: true}]`,但用户只提供了 `vars: {title: "Hello"}`
|
||||
- **THEN** 验证器报告 ERROR 级别错误,包含所有缺少的必需变量
|
||||
|
||||
#### Scenario: 可选变量缺失
|
||||
|
||||
- **WHEN** 模板定义了 `vars: [{name: subtitle, required: false}]`,用户没有提供该变量
|
||||
- **THEN** 验证通过,不报错
|
||||
|
||||
#### Scenario: 提供默认值时缺少可选变量
|
||||
|
||||
- **WHEN** 模板定义了 `vars: [{name: subtitle, required: false, default: ""}]`,用户没有提供该变量
|
||||
- **THEN** 验证通过,不报错(使用默认值)
|
||||
|
||||
### Requirement: 验证器必须支持多幻灯片模板变量检查
|
||||
|
||||
系统 SHALL 检查每个使用模板的幻灯片,确保其提供了模板所需的必需变量。
|
||||
|
||||
#### Scenario: 不同幻灯片使用不同模板
|
||||
|
||||
- **WHEN** 幻灯片 1 使用模板 A(需要变量 title),幻灯片 2 使用模板 B(需要变量 image)
|
||||
- **THEN** 验证器分别检查每个幻灯片的变量,提供独立的错误信息
|
||||
|
||||
#### Scenario: 多个幻灯片使用同一模板
|
||||
|
||||
- **WHEN** 幻灯片 1 和幻灯片 2 都使用同一模板,都缺少必需变量
|
||||
- **THEN** 验证器报告两个错误,分别对应各自的幻灯片位置
|
||||
|
||||
### Requirement: 验证器必须提供清晰的错误位置信息
|
||||
|
||||
当缺少必需变量时,验证器 SHALL 在错误信息中包含幻灯片位置。
|
||||
|
||||
#### Scenario: 错误信息包含幻灯片位置
|
||||
|
||||
- **WHEN** 幻灯片 2 使用模板但缺少必需变量
|
||||
- **THEN** 错误信息包含位置:"幻灯片 2: 缺少模板必需变量: title"
|
||||
21
pyproject.toml
Normal file
21
pyproject.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[project]
|
||||
name = "yaml2pptx"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.8"
|
||||
dependencies = [
|
||||
"python-pptx",
|
||||
"pyyaml",
|
||||
"flask",
|
||||
"watchdog",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-mock",
|
||||
"pillow",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["core", "loaders", "validators", "renderers", "preview"]
|
||||
@@ -30,12 +30,12 @@ class HtmlRenderer:
|
||||
elements_html = ""
|
||||
|
||||
bg_style = ""
|
||||
if slide_data.get('background'):
|
||||
bg = slide_data['background']
|
||||
if 'color' in bg:
|
||||
if slide_data.get("background"):
|
||||
bg = slide_data["background"]
|
||||
if "color" in bg:
|
||||
bg_style = f"background: {bg['color']};"
|
||||
|
||||
for elem in slide_data.get('elements', []):
|
||||
for elem in slide_data.get("elements", []):
|
||||
try:
|
||||
if isinstance(elem, TextElement):
|
||||
elements_html += self.render_text(elem)
|
||||
@@ -46,7 +46,9 @@ class HtmlRenderer:
|
||||
elif isinstance(elem, ImageElement):
|
||||
elements_html += self.render_image(elem, base_path)
|
||||
except Exception as e:
|
||||
elements_html += f'<div class="element" style="color: red;">渲染错误: {str(e)}</div>'
|
||||
elements_html += (
|
||||
f'<div class="element" style="color: red;">渲染错误: {str(e)}</div>'
|
||||
)
|
||||
|
||||
return f'''
|
||||
<div class="slide" style="{bg_style}">
|
||||
@@ -70,18 +72,20 @@ class HtmlRenderer:
|
||||
top: {elem.box[1] * DPI}px;
|
||||
width: {elem.box[2] * DPI}px;
|
||||
height: {elem.box[3] * DPI}px;
|
||||
font-size: {elem.font.get('size', 16)}pt;
|
||||
color: {elem.font.get('color', '#000000')};
|
||||
text-align: {elem.font.get('align', 'left')};
|
||||
{'font-weight: bold;' if elem.font.get('bold') else ''}
|
||||
{'font-style: italic;' if elem.font.get('italic') else ''}
|
||||
font-size: {elem.font.get("size", 16)}pt;
|
||||
color: {elem.font.get("color", "#000000")};
|
||||
text-align: {elem.font.get("align", "left")};
|
||||
{"font-weight: bold;" if elem.font.get("bold") else ""}
|
||||
{"font-style: italic;" if elem.font.get("italic") else ""}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
"""
|
||||
|
||||
content = elem.content.replace('<', '<').replace('>', '>')
|
||||
content = (
|
||||
elem.content.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
)
|
||||
return f'<div class="element text-element" style="{style}">{content}</div>'
|
||||
|
||||
def render_shape(self, elem: ShapeElement):
|
||||
@@ -95,23 +99,23 @@ class HtmlRenderer:
|
||||
str: HTML 代码
|
||||
"""
|
||||
border_radius = {
|
||||
'rectangle': '0',
|
||||
'ellipse': '50%',
|
||||
'rounded_rectangle': '8px'
|
||||
}.get(elem.shape, '0')
|
||||
"rectangle": "0",
|
||||
"ellipse": "50%",
|
||||
"rounded_rectangle": "8px",
|
||||
}.get(elem.shape, "0")
|
||||
|
||||
style = f"""
|
||||
left: {elem.box[0] * DPI}px;
|
||||
top: {elem.box[1] * DPI}px;
|
||||
width: {elem.box[2] * DPI}px;
|
||||
height: {elem.box[3] * DPI}px;
|
||||
background: {elem.fill if elem.fill else 'transparent'};
|
||||
background: {elem.fill if elem.fill else "transparent"};
|
||||
border-radius: {border_radius};
|
||||
"""
|
||||
|
||||
if elem.line:
|
||||
style += f"""
|
||||
border: {elem.line.get('width', 1)}pt solid {elem.line.get('color', '#000000')};
|
||||
border: {elem.line.get("width", 1)}pt solid {elem.line.get("color", "#000000")};
|
||||
"""
|
||||
|
||||
return f'<div class="element shape-element" style="{style}"></div>'
|
||||
@@ -138,14 +142,14 @@ class HtmlRenderer:
|
||||
cell_style = f"font-size: {elem.style.get('font_size', 14)}pt;"
|
||||
|
||||
if i == 0:
|
||||
if 'header_bg' in elem.style:
|
||||
if "header_bg" in elem.style:
|
||||
cell_style += f"background: {elem.style['header_bg']};"
|
||||
if 'header_color' in elem.style:
|
||||
if "header_color" in elem.style:
|
||||
cell_style += f"color: {elem.style['header_color']};"
|
||||
|
||||
cell_content = str(cell).replace('<', '<').replace('>', '>')
|
||||
cell_content = str(cell).replace("<", "<").replace(">", ">")
|
||||
cells_html += f'<td style="{cell_style}">{cell_content}</td>'
|
||||
rows_html += f'<tr>{cells_html}</tr>'
|
||||
rows_html += f"<tr>{cells_html}</tr>"
|
||||
|
||||
return f'<table class="element table-element" style="{table_style}">{rows_html}</table>'
|
||||
|
||||
|
||||
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
yaml2pptx 测试套件
|
||||
"""
|
||||
355
tests/conftest.py
Normal file
355
tests/conftest.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
pytest 配置文件 - 共享 fixtures
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
import pytest
|
||||
|
||||
# 添加项目根目录到 sys.path
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
|
||||
# ============= 基础 Fixtures =============
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(tmp_path):
|
||||
"""临时目录 fixture,使用 pytest 内置 tmp_path"""
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_root_dir():
|
||||
"""项目根目录"""
|
||||
return Path(__file__).parent.parent
|
||||
|
||||
|
||||
# ============= YAML 文件 Fixtures =============
|
||||
|
||||
MINIMAL_YAML = """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
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_yaml(temp_dir):
|
||||
"""创建最小测试 YAML 文件"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(MINIMAL_YAML, encoding="utf-8")
|
||||
return yaml_path
|
||||
|
||||
|
||||
# ============= 图片 Fixtures =============
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_image(temp_dir):
|
||||
"""创建测试图片文件(使用 Pillow 生成简单的 PNG)"""
|
||||
img_path = temp_dir / "test_image.png"
|
||||
# 创建一个简单的红色图片
|
||||
img = Image.new("RGB", (100, 100), color="red")
|
||||
img.save(img_path, "PNG")
|
||||
return img_path
|
||||
|
||||
|
||||
# ============= 模板 Fixtures =============
|
||||
|
||||
TEMPLATE_YAML = """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
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_template(temp_dir):
|
||||
"""创建测试模板目录和文件"""
|
||||
template_dir = temp_dir / "templates"
|
||||
template_dir.mkdir()
|
||||
(template_dir / "title-slide.yaml").write_text(TEMPLATE_YAML, encoding="utf-8")
|
||||
return template_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_template_class():
|
||||
"""Mock Template 类,用于单元测试"""
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("core.presentation.Template") as mock_class:
|
||||
yield mock_class
|
||||
|
||||
|
||||
# ============= PPTX 验证 Fixture =============
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pptx_validator():
|
||||
"""PPTX 文件验证器实例"""
|
||||
from tests.conftest_pptx import PptxFileValidator
|
||||
|
||||
return PptxFileValidator()
|
||||
|
||||
|
||||
# ============= 测试数据目录 Fixture =============
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixtures_dir():
|
||||
"""测试数据目录路径"""
|
||||
return Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
# ============= 额外的边界情况 Fixtures =============
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def edge_case_yaml_files(fixtures_dir):
|
||||
"""所有边界情况 YAML 文件的路径"""
|
||||
edge_cases_dir = fixtures_dir / "yaml_samples" / "edge_cases"
|
||||
if edge_cases_dir.exists():
|
||||
return list(edge_cases_dir.glob("*.yaml"))
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture(params=["16:9", "4:3"])
|
||||
def slide_size(request):
|
||||
"""参数化的幻灯片尺寸"""
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def complex_template(temp_dir):
|
||||
"""创建复杂模板(包含多个变量和条件)"""
|
||||
template_content = """
|
||||
vars:
|
||||
- name: title
|
||||
required: true
|
||||
- name: subtitle
|
||||
required: false
|
||||
default: ""
|
||||
- name: author
|
||||
required: false
|
||||
default: ""
|
||||
- name: date
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
elements:
|
||||
- type: shape
|
||||
box: [0, 0, 10, 5.625]
|
||||
shape: rectangle
|
||||
fill: "#2c3e50"
|
||||
|
||||
- type: text
|
||||
box: [1, 1.5, 8, 1]
|
||||
content: "{title}"
|
||||
font:
|
||||
size: 44
|
||||
bold: true
|
||||
color: "#ffffff"
|
||||
align: center
|
||||
|
||||
- type: text
|
||||
box: [1, 2.8, 8, 0.6]
|
||||
content: "{subtitle}"
|
||||
visible: "{subtitle != ''}"
|
||||
font:
|
||||
size: 24
|
||||
color: "#ecf0f1"
|
||||
align: center
|
||||
|
||||
- type: text
|
||||
box: [1, 4, 8, 0.5]
|
||||
content: "{author}"
|
||||
visible: "{author != ''}"
|
||||
font:
|
||||
size: 18
|
||||
color: "#bdc3c7"
|
||||
align: center
|
||||
|
||||
- type: text
|
||||
box: [1, 4.8, 8, 0.4]
|
||||
content: "{date}"
|
||||
visible: "{date != ''}"
|
||||
font:
|
||||
size: 14
|
||||
color: "#95a5a6"
|
||||
align: center
|
||||
"""
|
||||
template_dir = temp_dir / "templates"
|
||||
template_dir.mkdir()
|
||||
(template_dir / "complex-slide.yaml").write_text(template_content)
|
||||
return template_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def yaml_with_all_elements(temp_dir):
|
||||
"""创建包含所有元素类型的 YAML"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- background:
|
||||
color: "#ffffff"
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 1, 3, 0.5]
|
||||
content: "Text Element"
|
||||
font:
|
||||
size: 24
|
||||
color: "#333333"
|
||||
align: left
|
||||
|
||||
- type: text
|
||||
box: [1, 1.8, 3, 0.5]
|
||||
content: "Center Text"
|
||||
font:
|
||||
size: 20
|
||||
color: "#666666"
|
||||
align: center
|
||||
|
||||
- type: text
|
||||
box: [1, 2.6, 3, 0.5]
|
||||
content: "Right Text"
|
||||
font:
|
||||
size: 18
|
||||
color: "#999999"
|
||||
align: right
|
||||
|
||||
- type: shape
|
||||
box: [5, 1, 2, 1]
|
||||
shape: rectangle
|
||||
fill: "#4a90e2"
|
||||
line:
|
||||
color: "#000000"
|
||||
width: 1
|
||||
|
||||
- type: shape
|
||||
box: [5, 2.2, 2, 2]
|
||||
shape: ellipse
|
||||
fill: "#e24a4a"
|
||||
line:
|
||||
color: "#ffffff"
|
||||
width: 2
|
||||
|
||||
- type: shape
|
||||
box: [5, 4.5, 2, 1]
|
||||
shape: rounded_rectangle
|
||||
fill: "#4ae290"
|
||||
line:
|
||||
color: "#333333"
|
||||
width: 1
|
||||
|
||||
- type: table
|
||||
position: [1, 3.5]
|
||||
col_widths: [2, 2, 2]
|
||||
data:
|
||||
- ["Header 1", "Header 2", "Header 3"]
|
||||
- ["Data 1", "Data 2", "Data 3"]
|
||||
- ["Data 4", "Data 5", "Data 6"]
|
||||
style:
|
||||
font_size: 12
|
||||
header_bg: "#4a90e2"
|
||||
header_color: "#ffffff"
|
||||
"""
|
||||
yaml_path = temp_dir / "all_elements.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
return yaml_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_yaml_samples(fixtures_dir):
|
||||
"""所有无效 YAML 样本的路径"""
|
||||
invalid_dir = fixtures_dir / "yaml_samples" / "invalid"
|
||||
if invalid_dir.exists():
|
||||
return list(invalid_dir.glob("*.yaml"))
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multiple_slides_yaml(temp_dir):
|
||||
"""创建多张幻灯片的 YAML"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
# 第一张:标题页
|
||||
- background:
|
||||
color: "#4a90e2"
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 2, 8, 1]
|
||||
content: "Title Slide"
|
||||
font:
|
||||
size: 48
|
||||
bold: true
|
||||
color: "#ffffff"
|
||||
align: center
|
||||
|
||||
# 第二张:内容页
|
||||
- background:
|
||||
color: "#ffffff"
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Content Slide 1"
|
||||
font:
|
||||
size: 32
|
||||
color: "#333333"
|
||||
|
||||
# 第三张:内容页
|
||||
- background:
|
||||
color: "#ffffff"
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Content Slide 2"
|
||||
font:
|
||||
size: 32
|
||||
color: "#333333"
|
||||
|
||||
- type: shape
|
||||
box: [3, 2.5, 4, 2]
|
||||
shape: rectangle
|
||||
fill: "#e74c3c"
|
||||
"""
|
||||
yaml_path = temp_dir / "multiple_slides.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
return yaml_path
|
||||
343
tests/conftest_pptx.py
Normal file
343
tests/conftest_pptx.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
PPTX 文件验证工具类
|
||||
|
||||
提供 Level 2 验证深度的 PPTX 文件检查功能:
|
||||
- 文件级别:存在、可打开
|
||||
- 幻灯片级别:数量、尺寸
|
||||
- 元素级别:类型、数量、内容、位置(容差 0.1 英寸)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
|
||||
|
||||
class PptxValidationError:
|
||||
"""验证错误信息"""
|
||||
|
||||
def __init__(self, level: str, message: str):
|
||||
self.level = level # 'ERROR' or 'WARNING'
|
||||
self.message = message
|
||||
|
||||
def __repr__(self):
|
||||
return f"[{self.level}] {self.message}"
|
||||
|
||||
|
||||
class PptxFileValidator:
|
||||
"""PPTX 文件验证器(Level 2 验证深度)"""
|
||||
|
||||
# 位置容忍度(英寸)
|
||||
TOLERANCE = 0.1
|
||||
|
||||
# 幻灯片尺寸常量
|
||||
SIZE_16_9 = (10.0, 5.625)
|
||||
SIZE_4_3 = (10.0, 7.5)
|
||||
|
||||
def __init__(self):
|
||||
self.errors: List[PptxValidationError] = []
|
||||
self.warnings: List[PptxValidationError] = []
|
||||
|
||||
def validate_file(self, pptx_path: Path) -> bool:
|
||||
"""
|
||||
验证 PPTX 文件
|
||||
|
||||
Args:
|
||||
pptx_path: PPTX 文件路径
|
||||
|
||||
Returns:
|
||||
验证是否通过
|
||||
"""
|
||||
self.errors.clear()
|
||||
self.warnings.clear()
|
||||
|
||||
# 1. 文件级别验证
|
||||
if not self._validate_file_exists(pptx_path):
|
||||
return False
|
||||
|
||||
# 2. 打开文件
|
||||
try:
|
||||
prs = Presentation(str(pptx_path))
|
||||
except Exception as e:
|
||||
self.errors.append(PptxValidationError("ERROR", f"无法打开 PPTX 文件: {e}"))
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def validate_slides_count(self, prs: Presentation, expected_count: int) -> bool:
|
||||
"""验证幻灯片数量"""
|
||||
actual = len(prs.slides)
|
||||
if actual != expected_count:
|
||||
self.errors.append(
|
||||
PptxValidationError(
|
||||
"ERROR", f"幻灯片数量不匹配: 期望 {expected_count}, 实际 {actual}"
|
||||
)
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def validate_slide_size(
|
||||
self, prs: Presentation, expected_size: str = "16:9"
|
||||
) -> bool:
|
||||
"""验证幻灯片尺寸"""
|
||||
expected = self.SIZE_16_9 if expected_size == "16:9" else self.SIZE_4_3
|
||||
actual_width = prs.slide_width.inches
|
||||
actual_height = prs.slide_height.inches
|
||||
|
||||
if abs(actual_width - expected[0]) > self.TOLERANCE:
|
||||
self.errors.append(
|
||||
PptxValidationError(
|
||||
"ERROR",
|
||||
f"幻灯片宽度不匹配: 期望 {expected[0]}, 实际 {actual_width}",
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
if abs(actual_height - expected[1]) > self.TOLERANCE:
|
||||
self.errors.append(
|
||||
PptxValidationError(
|
||||
"ERROR",
|
||||
f"幻灯片高度不匹配: 期望 {expected[1]}, 实际 {actual_height}",
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def count_elements_by_type(self, slide) -> Dict[str, int]:
|
||||
"""统计幻灯片中各类型元素的数量"""
|
||||
counts = {
|
||||
"text_box": 0,
|
||||
"picture": 0,
|
||||
"shape": 0,
|
||||
"table": 0,
|
||||
"group": 0,
|
||||
"other": 0,
|
||||
}
|
||||
|
||||
for shape in slide.shapes:
|
||||
if hasattr(shape, "image"):
|
||||
counts["picture"] += 1
|
||||
elif shape.shape_type in [
|
||||
MSO_SHAPE.RECTANGLE,
|
||||
MSO_SHAPE.OVAL,
|
||||
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||||
]:
|
||||
counts["shape"] += 1
|
||||
elif shape.has_table:
|
||||
counts["table"] += 1
|
||||
elif shape.shape_type == MSO_SHAPE.GROUP:
|
||||
counts["group"] += 1
|
||||
elif hasattr(shape, "has_text_frame") and shape.has_text_frame:
|
||||
counts["text_box"] += 1
|
||||
else:
|
||||
counts["other"] += 1
|
||||
|
||||
return counts
|
||||
|
||||
def validate_text_element(
|
||||
self,
|
||||
slide,
|
||||
index: int = 0,
|
||||
expected_content: Optional[str] = None,
|
||||
expected_font_size: Optional[int] = None,
|
||||
expected_color: Optional[tuple] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
验证文本元素
|
||||
|
||||
Args:
|
||||
slide: 幻灯片对象
|
||||
index: 文本框索引
|
||||
expected_content: 期望的文本内容
|
||||
expected_font_size: 期望的字体大小
|
||||
expected_color: 期望的颜色 (R, G, B)
|
||||
|
||||
Returns:
|
||||
验证是否通过
|
||||
"""
|
||||
# 通过检查是否有text_frame属性来判断是否是文本框
|
||||
text_boxes = [s for s in slide.shapes if hasattr(s, "text_frame")]
|
||||
|
||||
if index >= len(text_boxes):
|
||||
self.errors.append(
|
||||
PptxValidationError("ERROR", f"找不到索引 {index} 的文本框")
|
||||
)
|
||||
return False
|
||||
|
||||
textbox = text_boxes[index]
|
||||
text_frame = textbox.text_frame
|
||||
|
||||
# 验证内容
|
||||
if expected_content is not None:
|
||||
actual_content = text_frame.text
|
||||
if actual_content != expected_content:
|
||||
self.errors.append(
|
||||
PptxValidationError(
|
||||
"ERROR",
|
||||
f"文本内容不匹配: 期望 '{expected_content}', 实际 '{actual_content}'",
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
# 验证字体大小
|
||||
if expected_font_size is not None:
|
||||
actual_size = text_frame.paragraphs[0].font.size.pt
|
||||
if abs(actual_size - expected_font_size) > 1:
|
||||
self.errors.append(
|
||||
PptxValidationError(
|
||||
"ERROR",
|
||||
f"字体大小不匹配: 期望 {expected_font_size}pt, 实际 {actual_size}pt",
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
# 验证颜色
|
||||
if expected_color is not None:
|
||||
try:
|
||||
actual_rgb = text_frame.paragraphs[0].font.color.rgb
|
||||
actual_color = (actual_rgb[0], actual_rgb[1], actual_rgb[2])
|
||||
if actual_color != expected_color:
|
||||
self.errors.append(
|
||||
PptxValidationError(
|
||||
"ERROR",
|
||||
f"字体颜色不匹配: 期望 RGB{expected_color}, 实际 RGB{actual_color}",
|
||||
)
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
self.errors.append(PptxValidationError("WARNING", "无法获取字体颜色"))
|
||||
|
||||
return True
|
||||
|
||||
def validate_position(
|
||||
self,
|
||||
shape,
|
||||
expected_left: float,
|
||||
expected_top: float,
|
||||
expected_width: Optional[float] = None,
|
||||
expected_height: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
验证元素位置和尺寸
|
||||
|
||||
Args:
|
||||
shape: 形状对象
|
||||
expected_left: 期望的左边距(英寸)
|
||||
expected_top: 期望的上边距(英寸)
|
||||
expected_width: 期望的宽度(英寸)
|
||||
expected_height: 期望的高度(英寸)
|
||||
|
||||
Returns:
|
||||
验证是否通过
|
||||
"""
|
||||
actual_left = shape.left.inches
|
||||
actual_top = shape.top.inches
|
||||
|
||||
if abs(actual_left - expected_left) > self.TOLERANCE:
|
||||
self.errors.append(
|
||||
PptxValidationError(
|
||||
"ERROR", f"左边距不匹配: 期望 {expected_left}, 实际 {actual_left}"
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
if abs(actual_top - expected_top) > self.TOLERANCE:
|
||||
self.errors.append(
|
||||
PptxValidationError(
|
||||
"ERROR", f"上边距不匹配: 期望 {expected_top}, 实际 {actual_top}"
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
if expected_width is not None:
|
||||
actual_width = shape.width.inches
|
||||
if abs(actual_width - expected_width) > self.TOLERANCE:
|
||||
self.errors.append(
|
||||
PptxValidationError(
|
||||
"ERROR",
|
||||
f"宽度不匹配: 期望 {expected_width}, 实际 {actual_width}",
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
if expected_height is not None:
|
||||
actual_height = shape.height.inches
|
||||
if abs(actual_height - expected_height) > self.TOLERANCE:
|
||||
self.errors.append(
|
||||
PptxValidationError(
|
||||
"ERROR",
|
||||
f"高度不匹配: 期望 {expected_height}, 实际 {actual_height}",
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def validate_background_color(self, slide, expected_rgb: tuple) -> bool:
|
||||
"""
|
||||
验证幻灯片背景颜色
|
||||
|
||||
Args:
|
||||
slide: 幻灯片对象
|
||||
expected_rgb: 期望的 RGB 颜色 (R, G, B)
|
||||
|
||||
Returns:
|
||||
验证是否通过
|
||||
"""
|
||||
try:
|
||||
fill = slide.background.fill
|
||||
if fill.type == 1: # Solid fill
|
||||
actual_rgb = (
|
||||
fill.fore_color.rgb[0],
|
||||
fill.fore_color.rgb[1],
|
||||
fill.fore_color.rgb[2],
|
||||
)
|
||||
if actual_rgb != expected_rgb:
|
||||
self.errors.append(
|
||||
PptxValidationError(
|
||||
"ERROR",
|
||||
f"背景颜色不匹配: 期望 RGB{expected_rgb}, 实际 RGB{actual_rgb}",
|
||||
)
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.errors.append(PptxValidationError("WARNING", f"无法获取背景颜色: {e}"))
|
||||
|
||||
return True
|
||||
|
||||
def _validate_file_exists(self, pptx_path: Path) -> bool:
|
||||
"""验证文件存在且大小大于 0"""
|
||||
if not pptx_path.exists():
|
||||
self.errors.append(PptxValidationError("ERROR", f"文件不存在: {pptx_path}"))
|
||||
return False
|
||||
|
||||
if pptx_path.stat().st_size == 0:
|
||||
self.errors.append(
|
||||
PptxValidationError("ERROR", f"文件大小为 0: {pptx_path}")
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def has_errors(self) -> bool:
|
||||
"""是否有错误"""
|
||||
return len(self.errors) > 0
|
||||
|
||||
def has_warnings(self) -> bool:
|
||||
"""是否有警告"""
|
||||
return len(self.warnings) > 0
|
||||
|
||||
def get_errors(self) -> List[str]:
|
||||
"""获取所有错误信息"""
|
||||
return [e.message for e in self.errors]
|
||||
|
||||
def get_warnings(self) -> List[str]:
|
||||
"""获取所有警告信息"""
|
||||
return [w.message for w in self.warnings]
|
||||
|
||||
def clear(self):
|
||||
"""清除所有错误和警告"""
|
||||
self.errors.clear()
|
||||
self.warnings.clear()
|
||||
3
tests/e2e/__init__.py
Normal file
3
tests/e2e/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
端到端测试包
|
||||
"""
|
||||
188
tests/e2e/test_check_cmd.py
Normal file
188
tests/e2e/test_check_cmd.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Check 命令端到端测试
|
||||
|
||||
测试 yaml2pptx.py check 命令的验证功能
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestCheckCmd:
|
||||
"""check 命令测试类"""
|
||||
|
||||
def run_check(self, *args):
|
||||
"""辅助函数:运行 check 命令"""
|
||||
cmd = ["uv", "run", "python", "yaml2pptx.py", "check"]
|
||||
cmd.extend(args)
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, cwd=Path(__file__).parent.parent.parent
|
||||
)
|
||||
return result
|
||||
|
||||
def test_check_valid_yaml(self, sample_yaml):
|
||||
"""测试检查有效的 YAML"""
|
||||
result = self.run_check(str(sample_yaml))
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "验证" in result.stdout or "通过" in result.stdout
|
||||
|
||||
def test_check_invalid_yaml(self, temp_dir):
|
||||
"""测试检查无效的 YAML"""
|
||||
# 创建包含错误的 YAML
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Test"
|
||||
font:
|
||||
color: "red" # 无效颜色
|
||||
"""
|
||||
yaml_path = temp_dir / "invalid.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
result = self.run_check(str(yaml_path))
|
||||
|
||||
# 应该有错误
|
||||
assert result.returncode != 0 or "错误" in result.stdout
|
||||
|
||||
def test_check_with_warnings_only(self, temp_dir):
|
||||
"""测试只有警告的 YAML(验证通过但有警告)"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [8, 1, 3, 1] # 边界超出
|
||||
content: "Test"
|
||||
font:
|
||||
size: 24
|
||||
"""
|
||||
yaml_path = temp_dir / "warning.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
result = self.run_check(str(yaml_path))
|
||||
|
||||
# 应该显示警告但返回 0
|
||||
assert "警告" in result.stdout
|
||||
|
||||
def test_check_with_template(self, temp_dir, sample_template):
|
||||
"""测试检查使用模板的 YAML"""
|
||||
yaml_content = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "Test Title"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
result = self.run_check(str(yaml_path), "--template-dir", str(sample_template))
|
||||
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_check_nonexistent_template(self, temp_dir):
|
||||
"""测试检查使用不存在模板的 YAML"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: nonexistent
|
||||
vars:
|
||||
title: "Test"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
result = self.run_check(str(yaml_path), "--template-dir", str(temp_dir))
|
||||
|
||||
# 应该有错误(模板不存在)
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_check_reports_multiple_errors(self, temp_dir):
|
||||
"""测试检查报告多个错误"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Test 1"
|
||||
font:
|
||||
color: "red"
|
||||
|
||||
- type: text
|
||||
box: [2, 2, 8, 1]
|
||||
content: "Test 2"
|
||||
font:
|
||||
color: "blue"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
result = self.run_check(str(yaml_path))
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
# 应该报告多个错误
|
||||
assert "错误" in output or "2" in output
|
||||
|
||||
def test_check_includes_location_info(self, temp_dir):
|
||||
"""测试检查包含位置信息"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Test"
|
||||
font:
|
||||
color: "red"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
result = self.run_check(str(yaml_path))
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
# 应该包含位置信息
|
||||
assert "幻灯片" in output or "元素" in output
|
||||
|
||||
def test_check_with_missing_required_variable(self, temp_dir, sample_template):
|
||||
"""测试检查缺少必需变量的模板"""
|
||||
yaml_content = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars: {{}}
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
result = self.run_check(str(yaml_path), "--template-dir", str(sample_template))
|
||||
|
||||
# 应该有错误
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_check_nonexistent_file(self, temp_dir):
|
||||
"""测试检查不存在的文件"""
|
||||
result = self.run_check(str(temp_dir / "nonexistent.yaml"))
|
||||
|
||||
assert result.returncode != 0
|
||||
207
tests/e2e/test_convert_cmd.py
Normal file
207
tests/e2e/test_convert_cmd.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Convert 命令端到端测试
|
||||
|
||||
测试 yaml2pptx.py convert 命令的完整功能
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from pptx import Presentation
|
||||
|
||||
|
||||
class TestConvertCmd:
|
||||
"""convert 命令测试类"""
|
||||
|
||||
def run_convert(self, *args):
|
||||
"""辅助函数:运行 convert 命令"""
|
||||
cmd = ["uv", "run", "python", "yaml2pptx.py", "convert"]
|
||||
cmd.extend(args)
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, cwd=Path(__file__).parent.parent.parent
|
||||
)
|
||||
return result
|
||||
|
||||
def test_basic_conversion(self, sample_yaml, temp_dir):
|
||||
"""测试基本转换"""
|
||||
output = temp_dir / "output.pptx"
|
||||
result = self.run_convert(str(sample_yaml), str(output))
|
||||
|
||||
assert result.returncode == 0
|
||||
assert output.exists()
|
||||
assert output.stat().st_size > 0
|
||||
|
||||
def test_auto_output_filename(self, sample_yaml, temp_dir):
|
||||
"""测试自动生成输出文件名"""
|
||||
# sample_yaml 位于 temp_dir 中,转换时输出也会在 temp_dir
|
||||
# 但因为 cwd 是项目根目录,所以输出文件的路径需要计算
|
||||
# 实际上,由于 sample_yaml 使用 tempfile,输出会在 temp_dir 中
|
||||
result = subprocess.run(
|
||||
[
|
||||
"uv",
|
||||
"run",
|
||||
"python",
|
||||
"yaml2pptx.py",
|
||||
"convert",
|
||||
str(sample_yaml),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path(__file__).parent.parent.parent,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, f"Command failed: {result.stderr}"
|
||||
# 应该生成与输入同名的 .pptx 文件(在 temp_dir 中)
|
||||
expected_output = temp_dir / "test.pptx"
|
||||
assert expected_output.exists(), (
|
||||
f"Expected {expected_output} to exist, but didn't"
|
||||
)
|
||||
|
||||
def test_conversion_with_template(self, temp_dir, sample_template):
|
||||
"""测试使用模板转换"""
|
||||
yaml_content = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "Template Test"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
output = temp_dir / "output.pptx"
|
||||
result = self.run_convert(
|
||||
str(yaml_path), str(output), "--template-dir", str(sample_template)
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert output.exists()
|
||||
|
||||
def test_skip_validation(self, sample_yaml, temp_dir):
|
||||
"""测试跳过验证"""
|
||||
output = temp_dir / "output.pptx"
|
||||
result = self.run_convert(str(sample_yaml), str(output), "--skip-validation")
|
||||
|
||||
assert result.returncode == 0
|
||||
assert output.exists()
|
||||
|
||||
def test_force_overwrite(self, sample_yaml, temp_dir):
|
||||
"""测试强制覆盖"""
|
||||
output = temp_dir / "output.pptx"
|
||||
|
||||
# 先创建一个存在的文件
|
||||
output.write_text("existing")
|
||||
|
||||
# 使用 --force 应该覆盖
|
||||
result = self.run_convert(str(sample_yaml), str(output), "--force")
|
||||
|
||||
assert result.returncode == 0
|
||||
# 文件应该是有效的 PPTX,不是原来的文本
|
||||
assert output.stat().st_size > 1000
|
||||
|
||||
def test_existing_file_without_force(self, sample_yaml, temp_dir):
|
||||
"""测试文件已存在且不使用 --force"""
|
||||
output = temp_dir / "output.pptx"
|
||||
|
||||
# 先创建一个存在的文件
|
||||
output.write_text("existing")
|
||||
|
||||
# 不使用 --force 应该失败或提示
|
||||
result = self.run_convert(str(sample_yaml), str(output))
|
||||
|
||||
# 程序应该拒绝覆盖
|
||||
assert result.returncode != 0 or "已存在" in result.stderr
|
||||
|
||||
def test_invalid_input_file(self, temp_dir):
|
||||
"""测试无效输入文件"""
|
||||
nonexistent = temp_dir / "nonexistent.yaml"
|
||||
output = temp_dir / "output.pptx"
|
||||
|
||||
result = self.run_convert(str(nonexistent), str(output))
|
||||
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_conversion_with_all_element_types(self, temp_dir, sample_image):
|
||||
"""测试转换包含所有元素类型的 YAML"""
|
||||
fixtures_yaml = (
|
||||
Path(__file__).parent.parent
|
||||
/ "fixtures"
|
||||
/ "yaml_samples"
|
||||
/ "full_features.yaml"
|
||||
)
|
||||
if not fixtures_yaml.exists():
|
||||
pytest.skip("full_features.yaml not found")
|
||||
|
||||
output = temp_dir / "output.pptx"
|
||||
result = self.run_convert(str(fixtures_yaml), str(output))
|
||||
|
||||
assert result.returncode == 0
|
||||
assert output.exists()
|
||||
|
||||
# 验证生成的 PPTX
|
||||
prs = Presentation(str(output))
|
||||
assert len(prs.slides) >= 1
|
||||
|
||||
def test_conversion_preserves_chinese_content(self, temp_dir):
|
||||
"""测试转换保留中文内容"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "测试中文内容"
|
||||
font:
|
||||
size: 24
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
output = temp_dir / "output.pptx"
|
||||
result = self.run_convert(str(yaml_path), str(output))
|
||||
|
||||
assert result.returncode == 0
|
||||
assert output.exists()
|
||||
|
||||
# 验证内容
|
||||
prs = Presentation(str(output))
|
||||
text_content = prs.slides[0].shapes[0].text_frame.text
|
||||
assert "测试中文内容" in text_content
|
||||
|
||||
def test_different_slide_sizes(self, temp_dir):
|
||||
"""测试不同的幻灯片尺寸"""
|
||||
for size in ["16:9", "4:3"]:
|
||||
yaml_content = f'''
|
||||
metadata:
|
||||
size: "{size}"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Size {size}"
|
||||
font:
|
||||
size: 24
|
||||
'''
|
||||
yaml_path = temp_dir / f"test_{size.replace(':', '')}.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
output = temp_dir / f"output_{size.replace(':', '')}.pptx"
|
||||
result = self.run_convert(str(yaml_path), str(output))
|
||||
|
||||
assert result.returncode == 0, f"Failed for size {size}"
|
||||
assert output.exists()
|
||||
|
||||
# 验证尺寸
|
||||
prs = Presentation(str(output))
|
||||
if size == "16:9":
|
||||
assert abs(prs.slide_width.inches - 10.0) < 0.01
|
||||
assert abs(prs.slide_height.inches - 5.625) < 0.01
|
||||
else: # 4:3
|
||||
assert abs(prs.slide_width.inches - 10.0) < 0.01
|
||||
assert abs(prs.slide_height.inches - 7.5) < 0.01
|
||||
201
tests/e2e/test_preview_cmd.py
Normal file
201
tests/e2e/test_preview_cmd.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Preview 命令端到端测试
|
||||
|
||||
测试 yaml2pptx.py preview 命令的 HTML 生成功能(不启动真实服务器)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
from preview.server import generate_preview_html, create_flask_app
|
||||
|
||||
|
||||
class TestGeneratePreviewHtml:
|
||||
"""generate_preview_html 函数测试类"""
|
||||
|
||||
def test_generate_html_from_valid_yaml(self, sample_yaml):
|
||||
"""测试从有效 YAML 生成 HTML"""
|
||||
html = generate_preview_html(str(sample_yaml), None)
|
||||
|
||||
assert isinstance(html, str)
|
||||
assert "<!DOCTYPE html>" in html
|
||||
assert "<html>" in html
|
||||
assert "</html>" in html
|
||||
|
||||
def test_html_contains_slide_content(self, sample_yaml):
|
||||
"""测试 HTML 包含幻灯片内容"""
|
||||
html = generate_preview_html(str(sample_yaml), None)
|
||||
|
||||
# 应该包含文本内容
|
||||
assert "Hello, World!" in html
|
||||
|
||||
def test_html_contains_css_styles(self, sample_yaml):
|
||||
"""测试 HTML 包含 CSS 样式"""
|
||||
html = generate_preview_html(str(sample_yaml), None)
|
||||
|
||||
assert "<style>" in html
|
||||
assert ".slide" in html
|
||||
assert "position: absolute" in html
|
||||
|
||||
def test_html_with_template(self, temp_dir, sample_template):
|
||||
"""测试使用模板生成 HTML"""
|
||||
yaml_content = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "Template Title"
|
||||
subtitle: "Template Subtitle"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
html = generate_preview_html(str(yaml_path), str(sample_template))
|
||||
|
||||
assert "Template Title" in html
|
||||
|
||||
def test_html_with_invalid_yaml(self, temp_dir):
|
||||
"""测试无效 YAML 返回错误页面"""
|
||||
yaml_path = temp_dir / "invalid.yaml"
|
||||
yaml_path.write_text("invalid: [unclosed")
|
||||
|
||||
html = generate_preview_html(str(yaml_path), None)
|
||||
|
||||
# 应该返回错误页面
|
||||
assert "<!DOCTYPE html>" in html
|
||||
assert "错误" in html or "error" in html.lower()
|
||||
|
||||
def test_html_with_multiple_slides(self, temp_dir):
|
||||
"""测试多张幻灯片的 HTML"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Slide 1"
|
||||
font:
|
||||
size: 24
|
||||
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Slide 2"
|
||||
font:
|
||||
size: 24
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
html = generate_preview_html(str(yaml_path), None)
|
||||
|
||||
# 应该包含两张幻灯片的内容
|
||||
assert "Slide 1" in html
|
||||
assert "Slide 2" in html
|
||||
|
||||
def test_html_contains_slide_number(self, sample_yaml):
|
||||
"""测试 HTML 包含幻灯片编号"""
|
||||
html = generate_preview_html(str(sample_yaml), None)
|
||||
|
||||
assert "slide-number" in html
|
||||
|
||||
def test_html_contains_sse_script(self, sample_yaml):
|
||||
"""测试 HTML 包含 SSE 事件脚本"""
|
||||
html = generate_preview_html(str(sample_yaml), None)
|
||||
|
||||
assert "EventSource" in html
|
||||
assert "/events" in html
|
||||
|
||||
|
||||
class TestCreateFlaskApp:
|
||||
"""create_flask_app 函数测试类"""
|
||||
|
||||
@patch('preview.server.current_yaml_file', 'test.yaml')
|
||||
@patch('preview.server.current_template_dir', None)
|
||||
@patch('preview.server.change_queue')
|
||||
def test_creates_flask_app(self, mock_queue):
|
||||
"""测试创建 Flask 应用"""
|
||||
app = create_flask_app()
|
||||
|
||||
assert app is not None
|
||||
assert hasattr(app, 'url_map')
|
||||
|
||||
@patch('preview.server.current_yaml_file', 'test.yaml')
|
||||
@patch('preview.server.current_template_dir', None)
|
||||
@patch('preview.server.change_queue')
|
||||
def test_has_index_route(self, mock_queue):
|
||||
"""测试有 / 路由"""
|
||||
app = create_flask_app()
|
||||
|
||||
# 检查路由
|
||||
rules = [rule.rule for rule in app.url_map.iter_rules()]
|
||||
assert '/' in rules
|
||||
|
||||
@patch('preview.server.current_yaml_file', 'test.yaml')
|
||||
@patch('preview.server.current_template_dir', None)
|
||||
@patch('preview.server.change_queue')
|
||||
def test_has_events_route(self, mock_queue):
|
||||
"""测试有 /events 路由"""
|
||||
app = create_flask_app()
|
||||
|
||||
rules = [rule.rule for rule in app.url_map.iter_rules()]
|
||||
assert '/events' in rules
|
||||
|
||||
|
||||
class TestYAMLChangeHandler:
|
||||
"""YAMLChangeHandler 测试类"""
|
||||
|
||||
def test_on_modified_with_yaml_file(self):
|
||||
"""测试处理 YAML 文件修改"""
|
||||
from preview.server import YAMLChangeHandler
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
handler = YAMLChangeHandler()
|
||||
mock_queue = MagicMock()
|
||||
import preview.server
|
||||
preview.server.change_queue = mock_queue
|
||||
|
||||
event = MagicMock()
|
||||
event.src_path = "test.yaml"
|
||||
|
||||
handler.on_modified(event)
|
||||
|
||||
mock_queue.put.assert_called_once_with('reload')
|
||||
|
||||
def test_on_modified_with_non_yaml_file(self):
|
||||
"""测试忽略非 YAML 文件修改"""
|
||||
from preview.server import YAMLChangeHandler
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
handler = YAMLChangeHandler()
|
||||
mock_queue = MagicMock()
|
||||
import preview.server
|
||||
preview.server.change_queue = mock_queue
|
||||
|
||||
event = MagicMock()
|
||||
event.src_path = "test.txt"
|
||||
|
||||
handler.on_modified(event)
|
||||
|
||||
mock_queue.put.assert_not_called()
|
||||
|
||||
|
||||
class TestPreviewHTMLTemplate:
|
||||
"""HTML 模板常量测试"""
|
||||
|
||||
def test_html_template_is_defined(self):
|
||||
"""测试 HTML_TEMPLATE 已定义"""
|
||||
from preview.server import HTML_TEMPLATE
|
||||
assert isinstance(HTML_TEMPLATE, str)
|
||||
assert "<!DOCTYPE html>" in HTML_TEMPLATE
|
||||
|
||||
def test_error_template_is_defined(self):
|
||||
"""测试 ERROR_TEMPLATE 已定义"""
|
||||
from preview.server import ERROR_TEMPLATE
|
||||
assert isinstance(ERROR_TEMPLATE, str)
|
||||
assert "<!DOCTYPE html>" in ERROR_TEMPLATE
|
||||
assert "错误" in ERROR_TEMPLATE or "error" in ERROR_TEMPLATE.lower()
|
||||
34
tests/fixtures/create_test_images.py
vendored
Normal file
34
tests/fixtures/create_test_images.py
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
创建测试图片的辅助脚本
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from pathlib import Path
|
||||
|
||||
# 确保目录存在
|
||||
images_dir = Path(__file__).parent.parent / "fixtures" / "images"
|
||||
images_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 创建一个简单的红色图片
|
||||
img = Image.new('RGB', (100, 100), color='red')
|
||||
img.save(images_dir / "test_image.png")
|
||||
|
||||
# 创建一个较大的图片
|
||||
large_img = Image.new('RGB', (800, 600), color='blue')
|
||||
large_img.save(images_dir / "large_image.png")
|
||||
|
||||
# 创建一个带有透明度的图片(PNG)
|
||||
transparent_img = Image.new('RGBA', (100, 100), (255, 0, 0, 128))
|
||||
transparent_img.save(images_dir / "transparent_image.png")
|
||||
|
||||
# 创建一个小图片
|
||||
small_img = Image.new('RGB', (50, 50), color='green')
|
||||
small_img.save(images_dir / "small_image.png")
|
||||
|
||||
# 创建一个带文字的图片
|
||||
text_img = Image.new('RGB', (200, 100), color='white')
|
||||
draw = ImageDraw.Draw(text_img)
|
||||
draw.text((10, 30), "Test Image", fill='black')
|
||||
text_img.save(images_dir / "text_image.png")
|
||||
|
||||
print(f"Created test images in {images_dir}")
|
||||
BIN
tests/fixtures/images/test_image.png
vendored
Normal file
BIN
tests/fixtures/images/test_image.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 286 B |
59
tests/fixtures/templates/complex/title-slide-complex.yaml
vendored
Normal file
59
tests/fixtures/templates/complex/title-slide-complex.yaml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
vars:
|
||||
- name: title
|
||||
required: true
|
||||
- name: subtitle
|
||||
required: false
|
||||
default: ""
|
||||
- name: author
|
||||
required: false
|
||||
default: ""
|
||||
- name: date
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
elements:
|
||||
# 标题
|
||||
- type: shape
|
||||
box: [0, 0, 10, 5.625]
|
||||
shape: rectangle
|
||||
fill: "#4a90e2"
|
||||
|
||||
# 标题文本
|
||||
- type: text
|
||||
box: [1, 1.5, 8, 1]
|
||||
content: "{title}"
|
||||
font:
|
||||
size: 44
|
||||
bold: true
|
||||
color: "#ffffff"
|
||||
align: center
|
||||
|
||||
# 副标题(条件显示)
|
||||
- type: text
|
||||
box: [1, 2.8, 8, 0.6]
|
||||
content: "{subtitle}"
|
||||
visible: "{subtitle != ''}"
|
||||
font:
|
||||
size: 24
|
||||
color: "#ffffff"
|
||||
align: center
|
||||
|
||||
# 作者
|
||||
- type: text
|
||||
box: [1, 4, 8, 0.5]
|
||||
content: "{author}"
|
||||
visible: "{author != ''}"
|
||||
font:
|
||||
size: 18
|
||||
color: "#e0e0e0"
|
||||
align: center
|
||||
|
||||
# 日期
|
||||
- type: text
|
||||
box: [1, 4.8, 8, 0.4]
|
||||
content: "{date}"
|
||||
visible: "{date != ''}"
|
||||
font:
|
||||
size: 14
|
||||
color: "#cccccc"
|
||||
align: center
|
||||
23
tests/fixtures/templates/test_template.yaml
vendored
Normal file
23
tests/fixtures/templates/test_template.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
|
||||
29
tests/fixtures/yaml_samples/edge_cases/all_element_types_one_slide.yaml
vendored
Normal file
29
tests/fixtures/yaml_samples/edge_cases/all_element_types_one_slide.yaml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 3, 0.5]
|
||||
content: "All Element Types One Slide"
|
||||
font:
|
||||
size: 18
|
||||
color: "#333333"
|
||||
|
||||
- type: image
|
||||
box: [1, 2, 2, 2]
|
||||
src: "test_image.png"
|
||||
|
||||
- type: shape
|
||||
box: [4, 2, 2, 1]
|
||||
shape: rectangle
|
||||
fill: "#4a90e2"
|
||||
|
||||
- type: table
|
||||
position: [1, 4.5]
|
||||
col_widths: [2, 2, 2]
|
||||
data:
|
||||
- ["H1", "H2", "H3"]
|
||||
- ["D1", "D2", "D3"]
|
||||
style:
|
||||
font_size: 12
|
||||
10
tests/fixtures/yaml_samples/edge_cases/empty_content.yaml
vendored
Normal file
10
tests/fixtures/yaml_samples/edge_cases/empty_content.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: ""
|
||||
font:
|
||||
size: 18
|
||||
15
tests/fixtures/yaml_samples/edge_cases/empty_font_attributes.yaml
vendored
Normal file
15
tests/fixtures/yaml_samples/edge_cases/empty_font_attributes.yaml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
# 空字体属性
|
||||
- type: text
|
||||
box: [1, 1, 3, 0.5]
|
||||
content: "Empty Font Attributes"
|
||||
font: {}
|
||||
|
||||
# 空字体
|
||||
- type: text
|
||||
box: [1, 2, 3, 0.5]
|
||||
content: "No Font Field"
|
||||
20
tests/fixtures/yaml_samples/edge_cases/mixed_color_formats.yaml
vendored
Normal file
20
tests/fixtures/yaml_samples/edge_cases/mixed_color_formats.yaml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
# 混合颜色格式
|
||||
- type: text
|
||||
box: [1, 1, 3, 0.5]
|
||||
content: "Mixed Color Formats"
|
||||
font:
|
||||
size: 18
|
||||
color: "#fff"
|
||||
|
||||
- type: shape
|
||||
box: [1, 2, 2, 1]
|
||||
shape: rectangle
|
||||
fill: "#000000"
|
||||
line:
|
||||
color: "#abc"
|
||||
width: 2
|
||||
11
tests/fixtures/yaml_samples/edge_cases/negative_position.yaml
vendored
Normal file
11
tests/fixtures/yaml_samples/edge_cases/negative_position.yaml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [-1, -1, 2, 1]
|
||||
content: "Negative Position"
|
||||
font:
|
||||
size: 18
|
||||
color: "#333333"
|
||||
10
tests/fixtures/yaml_samples/edge_cases/special_chars.yaml
vendored
Normal file
10
tests/fixtures/yaml_samples/edge_cases/special_chars.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 2]
|
||||
content: "Special chars: < > & \" ' \\n \\t @#$%^&*()"
|
||||
font:
|
||||
size: 18
|
||||
10
tests/fixtures/yaml_samples/edge_cases/unicode.yaml
vendored
Normal file
10
tests/fixtures/yaml_samples/edge_cases/unicode.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Unicode 测试: 你好世界 🌍"
|
||||
font:
|
||||
size: 24
|
||||
11
tests/fixtures/yaml_samples/edge_cases/very_large_font.yaml
vendored
Normal file
11
tests/fixtures/yaml_samples/edge_cases/very_large_font.yaml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [0, 0, 1, 1]
|
||||
content: "Very Large Font"
|
||||
font:
|
||||
size: 200
|
||||
color: "#333333"
|
||||
10
tests/fixtures/yaml_samples/edge_cases/zero_size_box.yaml
vendored
Normal file
10
tests/fixtures/yaml_samples/edge_cases/zero_size_box.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 0, 0]
|
||||
content: "Zero Size Box"
|
||||
font:
|
||||
size: 18
|
||||
71
tests/fixtures/yaml_samples/full_features.yaml
vendored
Normal file
71
tests/fixtures/yaml_samples/full_features.yaml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
# 文本元素幻灯片
|
||||
- background:
|
||||
color: "#ffffff"
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Full Features Test"
|
||||
font:
|
||||
size: 44
|
||||
bold: true
|
||||
color: "#333333"
|
||||
align: center
|
||||
|
||||
- type: text
|
||||
box: [1, 2.5, 8, 0.5]
|
||||
content: "Testing all element types"
|
||||
font:
|
||||
size: 18
|
||||
italic: true
|
||||
color: "#666666"
|
||||
align: center
|
||||
|
||||
# 图片元素幻灯片
|
||||
- elements:
|
||||
- type: image
|
||||
box: [1, 1, 4, 3]
|
||||
src: "../images/test_image.png"
|
||||
|
||||
# 形状元素幻灯片
|
||||
- elements:
|
||||
- type: shape
|
||||
box: [1, 1, 2, 1]
|
||||
shape: rectangle
|
||||
fill: "#4a90e2"
|
||||
line:
|
||||
color: "#000000"
|
||||
width: 2
|
||||
|
||||
- type: shape
|
||||
box: [4, 1, 2, 1]
|
||||
shape: ellipse
|
||||
fill: "#e24a4a"
|
||||
line:
|
||||
color: "#000000"
|
||||
width: 1
|
||||
|
||||
- type: shape
|
||||
box: [7, 1, 2, 1]
|
||||
shape: rounded_rectangle
|
||||
fill: "#4ae290"
|
||||
line:
|
||||
color: "#000000"
|
||||
width: 2
|
||||
|
||||
# 表格元素幻灯片
|
||||
- elements:
|
||||
- type: table
|
||||
position: [1, 1]
|
||||
col_widths: [2, 2, 2]
|
||||
data:
|
||||
- ["Header 1", "Header 2", "Header 3"]
|
||||
- ["Data 1", "Data 2", "Data 3"]
|
||||
- ["Data 4", "Data 5", "Data 6"]
|
||||
style:
|
||||
font_size: 14
|
||||
header_bg: "#4a90e2"
|
||||
header_color: "#ffffff"
|
||||
11
tests/fixtures/yaml_samples/invalid/invalid_color.yaml
vendored
Normal file
11
tests/fixtures/yaml_samples/invalid/invalid_color.yaml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Invalid color"
|
||||
font:
|
||||
size: 18
|
||||
color: "red" # Should be #RRGGBB
|
||||
8
tests/fixtures/yaml_samples/invalid/missing_slides.yaml
vendored
Normal file
8
tests/fixtures/yaml_samples/invalid/missing_slides.yaml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
# Missing 'slides' key
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "No slides key"
|
||||
8
tests/fixtures/yaml_samples/invalid/syntax_error.yaml
vendored
Normal file
8
tests/fixtures/yaml_samples/invalid/syntax_error.yaml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1
|
||||
content: "Missing closing bracket"
|
||||
15
tests/fixtures/yaml_samples/minimal.yaml
vendored
Normal file
15
tests/fixtures/yaml_samples/minimal.yaml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
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
|
||||
3
tests/integration/__init__.py
Normal file
3
tests/integration/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
集成测试包
|
||||
"""
|
||||
188
tests/integration/test_presentation.py
Normal file
188
tests/integration/test_presentation.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Presentation 类集成测试
|
||||
|
||||
测试 Presentation 类的模板加载和幻灯片渲染功能
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from core.presentation import Presentation
|
||||
|
||||
|
||||
class TestPresentationInit:
|
||||
"""Presentation 初始化测试"""
|
||||
|
||||
def test_init_with_yaml(self, sample_yaml):
|
||||
"""测试使用 YAML 文件初始化"""
|
||||
pres = Presentation(str(sample_yaml))
|
||||
assert pres.data is not None
|
||||
assert "slides" in pres.data
|
||||
|
||||
def test_init_with_template_dir(self, sample_yaml, sample_template):
|
||||
"""测试带模板目录初始化"""
|
||||
pres = Presentation(str(sample_yaml), str(sample_template))
|
||||
assert pres.templates_dir == str(sample_template)
|
||||
|
||||
|
||||
class TestTemplateCaching:
|
||||
"""模板缓存测试"""
|
||||
|
||||
def test_template_is_cached(self, temp_dir, sample_template):
|
||||
"""测试模板被缓存"""
|
||||
# 创建使用模板的 YAML
|
||||
yaml_content = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "Test"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = Presentation(str(yaml_path), str(sample_template))
|
||||
|
||||
# 第一次获取模板
|
||||
template1 = pres.get_template("title-slide")
|
||||
# 第二次获取模板
|
||||
template2 = pres.get_template("title-slide")
|
||||
|
||||
# 应该是同一个实例(缓存)
|
||||
assert template1 is template2
|
||||
|
||||
|
||||
class TestRenderSlide:
|
||||
"""render_slide 方法测试"""
|
||||
|
||||
def test_render_simple_slide(self, sample_yaml):
|
||||
"""测试渲染简单幻灯片"""
|
||||
pres = Presentation(str(sample_yaml))
|
||||
slide_data = pres.data["slides"][0]
|
||||
rendered = pres.render_slide(slide_data)
|
||||
|
||||
assert "elements" in rendered
|
||||
assert len(rendered["elements"]) > 0
|
||||
|
||||
def test_render_slide_with_template(self, temp_dir, sample_template):
|
||||
"""测试渲染使用模板的幻灯片"""
|
||||
yaml_content = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "Test Title"
|
||||
subtitle: "Test Subtitle"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = Presentation(str(yaml_path), str(sample_template))
|
||||
slide_data = pres.data["slides"][0]
|
||||
rendered = pres.render_slide(slide_data)
|
||||
|
||||
# 模板变量应该被替换
|
||||
elements = rendered["elements"]
|
||||
title_elem = next(
|
||||
e for e in elements if e.type == "text" and "Test Title" in e.content
|
||||
)
|
||||
assert title_elem is not None
|
||||
|
||||
def test_render_slide_with_conditional_element(self, temp_dir, sample_template):
|
||||
"""测试条件渲染元素"""
|
||||
# 有 subtitle 的情况
|
||||
yaml_with = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "Test"
|
||||
subtitle: "With Subtitle"
|
||||
"""
|
||||
yaml_path_with = temp_dir / "test_with.yaml"
|
||||
yaml_path_with.write_text(yaml_with)
|
||||
|
||||
pres_with = Presentation(str(yaml_path_with), str(sample_template))
|
||||
slide_data = pres_with.data["slides"][0]
|
||||
rendered_with = pres_with.render_slide(slide_data)
|
||||
|
||||
# 应该有 2 个元素(title 和 subtitle 都显示)
|
||||
assert len(rendered_with["elements"]) == 2
|
||||
|
||||
# 没有 subtitle 的情况
|
||||
yaml_without = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "Test"
|
||||
"""
|
||||
yaml_path_without = temp_dir / "test_without.yaml"
|
||||
yaml_path_without.write_text(yaml_without)
|
||||
|
||||
pres_without = Presentation(str(yaml_path_without), str(sample_template))
|
||||
slide_data = pres_without.data["slides"][0]
|
||||
rendered_without = pres_without.render_slide(slide_data)
|
||||
|
||||
# 应该只有 1 个元素(subtitle 不显示)
|
||||
assert len(rendered_without["elements"]) == 1
|
||||
|
||||
def test_render_slide_with_variables(self, temp_dir, sample_template):
|
||||
"""测试变量传递"""
|
||||
yaml_content = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "My Title"
|
||||
subtitle: "My Subtitle"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = Presentation(str(yaml_path), str(sample_template))
|
||||
slide_data = pres.data["slides"][0]
|
||||
rendered = pres.render_slide(slide_data)
|
||||
|
||||
# 检查变量是否被正确替换
|
||||
elements = rendered["elements"]
|
||||
assert any("My Title" in e.content for e in elements)
|
||||
assert any("My Subtitle" in e.content for e in elements)
|
||||
|
||||
|
||||
class TestPresentationWithoutTemplate:
|
||||
"""无模板的演示文稿测试"""
|
||||
|
||||
def test_render_direct_elements(self, temp_dir):
|
||||
"""测试直接渲染元素(不使用模板)"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Direct Text"
|
||||
font:
|
||||
size: 24
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = Presentation(str(yaml_path))
|
||||
slide_data = pres.data["slides"][0]
|
||||
rendered = pres.render_slide(slide_data)
|
||||
|
||||
# 元素应该直接被渲染
|
||||
assert len(rendered["elements"]) == 1
|
||||
assert rendered["elements"][0].content == "Direct Text"
|
||||
289
tests/integration/test_rendering_flow.py
Normal file
289
tests/integration/test_rendering_flow.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
渲染流程集成测试
|
||||
|
||||
测试完整的 YAML 到 PPTX 渲染流程
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from pptx import Presentation
|
||||
from core.presentation import Presentation as CorePresentation
|
||||
from renderers.pptx_renderer import PptxGenerator
|
||||
|
||||
|
||||
class TestRenderingFlow:
|
||||
"""渲染流程测试类"""
|
||||
|
||||
def test_full_rendering_flow(self, sample_yaml, temp_dir, pptx_validator):
|
||||
"""测试完整渲染流程"""
|
||||
# 加载演示文稿
|
||||
pres = CorePresentation(str(sample_yaml))
|
||||
|
||||
# 创建 PPTX 生成器
|
||||
gen = PptxGenerator(size='16:9')
|
||||
|
||||
# 渲染所有幻灯片
|
||||
for slide_data in pres.data.get('slides', []):
|
||||
rendered = pres.render_slide(slide_data)
|
||||
gen.add_slide(rendered, base_path=sample_yaml.parent)
|
||||
|
||||
# 保存文件
|
||||
output_path = temp_dir / "output.pptx"
|
||||
gen.save(output_path)
|
||||
|
||||
# 验证文件
|
||||
assert pptx_validator.validate_file(output_path) is True
|
||||
assert pptx_validator.validate_slides_count(Presentation(str(output_path)), 1) is True
|
||||
|
||||
def test_text_element_rendering(self, temp_dir, pptx_validator):
|
||||
"""测试文本元素渲染"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Test Content"
|
||||
font:
|
||||
size: 24
|
||||
bold: true
|
||||
color: "#333333"
|
||||
align: center
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = CorePresentation(str(yaml_path))
|
||||
gen = PptxGenerator(size='16:9')
|
||||
|
||||
for slide_data in pres.data.get('slides', []):
|
||||
rendered = pres.render_slide(slide_data)
|
||||
gen.add_slide(rendered)
|
||||
|
||||
output_path = temp_dir / "output.pptx"
|
||||
gen.save(output_path)
|
||||
|
||||
# 验证文本元素
|
||||
prs = Presentation(str(output_path))
|
||||
assert len(prs.slides) == 1
|
||||
|
||||
pptx_validator.clear()
|
||||
assert pptx_validator.validate_text_element(
|
||||
prs.slides[0],
|
||||
index=0,
|
||||
expected_content="Test Content",
|
||||
expected_font_size=24
|
||||
) is True
|
||||
|
||||
def test_image_element_rendering(self, temp_dir, sample_image, pptx_validator):
|
||||
"""测试图片元素渲染"""
|
||||
yaml_content = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: image
|
||||
box: [1, 1, 4, 3]
|
||||
src: "{sample_image.name}"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = CorePresentation(str(yaml_path))
|
||||
gen = PptxGenerator(size='16:9')
|
||||
|
||||
for slide_data in pres.data.get('slides', []):
|
||||
rendered = pres.render_slide(slide_data)
|
||||
gen.add_slide(rendered, base_path=yaml_path.parent)
|
||||
|
||||
output_path = temp_dir / "output.pptx"
|
||||
gen.save(output_path)
|
||||
|
||||
# 验证图片元素
|
||||
prs = Presentation(str(output_path))
|
||||
counts = pptx_validator.count_elements_by_type(prs.slides[0])
|
||||
assert counts["picture"] == 1
|
||||
|
||||
def test_shape_element_rendering(self, temp_dir, pptx_validator):
|
||||
"""测试形状元素渲染"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: shape
|
||||
box: [1, 1, 2, 1]
|
||||
shape: rectangle
|
||||
fill: "#4a90e2"
|
||||
line:
|
||||
color: "#000000"
|
||||
width: 2
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = CorePresentation(str(yaml_path))
|
||||
gen = PptxGenerator(size='16:9')
|
||||
|
||||
for slide_data in pres.data.get('slides', []):
|
||||
rendered = pres.render_slide(slide_data)
|
||||
gen.add_slide(rendered)
|
||||
|
||||
output_path = temp_dir / "output.pptx"
|
||||
gen.save(output_path)
|
||||
|
||||
# 验证形状元素
|
||||
prs = Presentation(str(output_path))
|
||||
counts = pptx_validator.count_elements_by_type(prs.slides[0])
|
||||
assert counts["shape"] >= 1
|
||||
|
||||
def test_table_element_rendering(self, temp_dir, pptx_validator):
|
||||
"""测试表格元素渲染"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: table
|
||||
position: [1, 1]
|
||||
col_widths: [2, 2, 2]
|
||||
data:
|
||||
- ["Header 1", "Header 2", "Header 3"]
|
||||
- ["Data 1", "Data 2", "Data 3"]
|
||||
style:
|
||||
font_size: 14
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = CorePresentation(str(yaml_path))
|
||||
gen = PptxGenerator(size='16:9')
|
||||
|
||||
for slide_data in pres.data.get('slides', []):
|
||||
rendered = pres.render_slide(slide_data)
|
||||
gen.add_slide(rendered)
|
||||
|
||||
output_path = temp_dir / "output.pptx"
|
||||
gen.save(output_path)
|
||||
|
||||
# 验证表格元素
|
||||
prs = Presentation(str(output_path))
|
||||
counts = pptx_validator.count_elements_by_type(prs.slides[0])
|
||||
assert counts["table"] == 1
|
||||
|
||||
def test_background_rendering(self, temp_dir, pptx_validator):
|
||||
"""测试背景渲染"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- background:
|
||||
color: "#ffffff"
|
||||
elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Test"
|
||||
font:
|
||||
size: 24
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = CorePresentation(str(yaml_path))
|
||||
gen = PptxGenerator(size='16:9')
|
||||
|
||||
for slide_data in pres.data.get('slides', []):
|
||||
rendered = pres.render_slide(slide_data)
|
||||
gen.add_slide(rendered)
|
||||
|
||||
output_path = temp_dir / "output.pptx"
|
||||
gen.save(output_path)
|
||||
|
||||
# 验证背景颜色
|
||||
prs = Presentation(str(output_path))
|
||||
pptx_validator.clear()
|
||||
assert pptx_validator.validate_background_color(
|
||||
prs.slides[0],
|
||||
expected_rgb=(255, 255, 255)
|
||||
) is True
|
||||
|
||||
def test_template_slide_rendering(self, temp_dir, sample_template, pptx_validator):
|
||||
"""测试使用模板的幻灯片渲染"""
|
||||
yaml_content = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "Template Test"
|
||||
subtitle: "Template Subtitle"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = CorePresentation(str(yaml_path), str(sample_template))
|
||||
gen = PptxGenerator(size='16:9')
|
||||
|
||||
for slide_data in pres.data.get('slides', []):
|
||||
rendered = pres.render_slide(slide_data)
|
||||
gen.add_slide(rendered)
|
||||
|
||||
output_path = temp_dir / "output.pptx"
|
||||
gen.save(output_path)
|
||||
|
||||
# 验证文件生成
|
||||
assert pptx_validator.validate_file(output_path) is True
|
||||
|
||||
# 验证文本内容
|
||||
prs = Presentation(str(output_path))
|
||||
pptx_validator.clear()
|
||||
assert pptx_validator.validate_text_element(
|
||||
prs.slides[0],
|
||||
index=0,
|
||||
expected_content="Template Test"
|
||||
) is True
|
||||
|
||||
def test_multiple_slides_rendering(self, temp_dir, pptx_validator):
|
||||
"""测试多张幻灯片渲染"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Slide 1"
|
||||
font:
|
||||
size: 24
|
||||
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Slide 2"
|
||||
font:
|
||||
size: 24
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
pres = CorePresentation(str(yaml_path))
|
||||
gen = PptxGenerator(size='16:9')
|
||||
|
||||
for slide_data in pres.data.get('slides', []):
|
||||
rendered = pres.render_slide(slide_data)
|
||||
gen.add_slide(rendered)
|
||||
|
||||
output_path = temp_dir / "output.pptx"
|
||||
gen.save(output_path)
|
||||
|
||||
# 验证幻灯片数量
|
||||
prs = Presentation(str(output_path))
|
||||
assert pptx_validator.validate_slides_count(prs, 2) is True
|
||||
269
tests/integration/test_validation_flow.py
Normal file
269
tests/integration/test_validation_flow.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
验证流程集成测试
|
||||
|
||||
测试完整的验证流程和验证结果收集
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from validators.validator import Validator
|
||||
from validators.result import ValidationResult
|
||||
|
||||
|
||||
class TestValidationFlow:
|
||||
"""验证流程测试类"""
|
||||
|
||||
def test_validate_valid_yaml(self, sample_yaml):
|
||||
"""测试验证有效的 YAML 文件"""
|
||||
validator = Validator()
|
||||
result = validator.validate(sample_yaml)
|
||||
|
||||
assert isinstance(result, ValidationResult)
|
||||
assert result.valid is True
|
||||
assert len(result.errors) == 0
|
||||
|
||||
def test_validate_with_warnings(self, temp_dir):
|
||||
"""测试验证包含警告的 YAML"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [8, 1, 3, 1] # 右边界超出
|
||||
content: "Test"
|
||||
font:
|
||||
size: 4 # 字体太小
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
validator = Validator()
|
||||
result = validator.validate(yaml_path)
|
||||
|
||||
# 应该有警告但 valid 仍为 True(没有错误)
|
||||
assert len(result.warnings) > 0
|
||||
|
||||
def test_validate_with_errors(self, temp_dir):
|
||||
"""测试验证包含错误的 YAML"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Test"
|
||||
font:
|
||||
color: "red" # 无效颜色格式
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
validator = Validator()
|
||||
result = validator.validate(yaml_path)
|
||||
|
||||
assert result.valid is False
|
||||
assert len(result.errors) > 0
|
||||
|
||||
def test_validate_nonexistent_file(self, temp_dir):
|
||||
"""测试验证不存在的文件"""
|
||||
validator = Validator()
|
||||
nonexistent = temp_dir / "nonexistent.yaml"
|
||||
result = validator.validate(nonexistent)
|
||||
|
||||
assert result.valid is False
|
||||
assert len(result.errors) > 0
|
||||
|
||||
def test_collect_multiple_errors(self, temp_dir):
|
||||
"""测试收集多个错误"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Test 1"
|
||||
font:
|
||||
color: "red"
|
||||
|
||||
- type: text
|
||||
box: [2, 2, 8, 1]
|
||||
content: "Test 2"
|
||||
font:
|
||||
color: "blue"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
validator = Validator()
|
||||
result = validator.validate(yaml_path)
|
||||
|
||||
# 应该收集到多个错误
|
||||
assert len(result.errors) >= 2
|
||||
|
||||
def test_error_location_information(self, temp_dir):
|
||||
"""测试错误包含位置信息"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Test"
|
||||
font:
|
||||
color: "red"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
validator = Validator()
|
||||
result = validator.validate(yaml_path)
|
||||
|
||||
# 错误应该包含位置信息
|
||||
if result.errors:
|
||||
assert "幻灯片 1" in result.errors[0].location
|
||||
|
||||
def test_validate_with_template(self, temp_dir, sample_template):
|
||||
"""测试验证使用模板的 YAML"""
|
||||
yaml_content = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars:
|
||||
title: "Test Title"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
validator = Validator()
|
||||
result = validator.validate(yaml_path, template_dir=sample_template)
|
||||
|
||||
assert isinstance(result, ValidationResult)
|
||||
# 有效模板应该验证通过
|
||||
assert len(result.errors) == 0
|
||||
|
||||
def test_validate_with_missing_required_variable(self, temp_dir, sample_template):
|
||||
"""测试验证缺少必需变量的模板"""
|
||||
yaml_content = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- template: title-slide
|
||||
vars: {{}}
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
validator = Validator()
|
||||
result = validator.validate(yaml_path, template_dir=sample_template)
|
||||
|
||||
# 应该有错误(缺少必需的 title 变量)
|
||||
assert len(result.errors) > 0
|
||||
|
||||
def test_categorize_issues_by_level(self, temp_dir):
|
||||
"""测试按级别分类问题"""
|
||||
# 创建包含错误和警告的 YAML
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Test"
|
||||
font:
|
||||
color: "red" # 错误
|
||||
size: 4 # 警告
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
validator = Validator()
|
||||
result = validator.validate(yaml_path)
|
||||
|
||||
# 应该同时有错误和警告
|
||||
assert len(result.errors) > 0
|
||||
assert len(result.warnings) > 0
|
||||
|
||||
def test_format_validation_result(self, temp_dir):
|
||||
"""测试验证结果格式化"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: text
|
||||
box: [1, 1, 8, 1]
|
||||
content: "Test"
|
||||
font:
|
||||
color: "red"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
validator = Validator()
|
||||
result = validator.validate(yaml_path)
|
||||
|
||||
# 格式化输出
|
||||
output = result.format_output()
|
||||
|
||||
assert "检查" in output
|
||||
if result.errors:
|
||||
assert "错误" in output
|
||||
if result.warnings:
|
||||
assert "警告" in output
|
||||
|
||||
def test_validate_image_resource(self, temp_dir, sample_image):
|
||||
"""测试验证图片资源"""
|
||||
yaml_content = f"""
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: image
|
||||
box: [1, 1, 4, 3]
|
||||
src: "{sample_image.name}"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
validator = Validator()
|
||||
result = validator.validate(yaml_path)
|
||||
|
||||
# 图片存在,应该没有错误
|
||||
assert len(result.errors) == 0
|
||||
|
||||
def test_validate_missing_image_resource(self, temp_dir):
|
||||
"""测试验证不存在的图片资源"""
|
||||
yaml_content = """
|
||||
metadata:
|
||||
size: "16:9"
|
||||
|
||||
slides:
|
||||
- elements:
|
||||
- type: image
|
||||
box: [1, 1, 4, 3]
|
||||
src: "nonexistent.png"
|
||||
"""
|
||||
yaml_path = temp_dir / "test.yaml"
|
||||
yaml_path.write_text(yaml_content)
|
||||
|
||||
validator = Validator()
|
||||
result = validator.validate(yaml_path)
|
||||
|
||||
# 应该有错误(图片不存在)
|
||||
assert len(result.errors) > 0
|
||||
assert any("图片文件不存在" in e.message for e in result.errors)
|
||||
466
tests/unit/test_elements.py
Normal file
466
tests/unit/test_elements.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
元素类单元测试
|
||||
|
||||
测试 TextElement、ImageElement、ShapeElement、TableElement 和 create_element 工厂函数
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from core.elements import (
|
||||
TextElement, ImageElement, ShapeElement, TableElement,
|
||||
create_element, _is_valid_color
|
||||
)
|
||||
from validators.result import ValidationIssue
|
||||
|
||||
|
||||
# ============= TextElement 测试 =============
|
||||
|
||||
class TestTextElement:
|
||||
"""TextElement 测试类"""
|
||||
|
||||
def test_create_with_defaults(self):
|
||||
"""测试使用默认值创建 TextElement"""
|
||||
elem = TextElement()
|
||||
assert elem.type == 'text'
|
||||
assert elem.content == ''
|
||||
assert elem.box == [1, 1, 8, 1]
|
||||
assert elem.font == {}
|
||||
|
||||
def test_create_with_values(self):
|
||||
"""测试使用指定值创建 TextElement"""
|
||||
elem = TextElement(
|
||||
content="Test",
|
||||
box=[0, 0, 5, 1],
|
||||
font={"size": 24, "bold": True}
|
||||
)
|
||||
assert elem.content == "Test"
|
||||
assert elem.box == [0, 0, 5, 1]
|
||||
assert elem.font["size"] == 24
|
||||
assert elem.font["bold"] is True
|
||||
|
||||
def test_box_must_be_list_of_four(self):
|
||||
"""测试 box 必须是包含 4 个数字的列表"""
|
||||
with pytest.raises(ValueError, match="box 必须是包含 4 个数字的列表"):
|
||||
TextElement(box=[1, 2, 3]) # 只有 3 个元素
|
||||
|
||||
with pytest.raises(ValueError, match="box 必须是包含 4 个数字的列表"):
|
||||
TextElement(box="not a list")
|
||||
|
||||
def test_validate_invalid_color(self):
|
||||
"""测试无效颜色格式验证"""
|
||||
elem = TextElement(
|
||||
font={"color": "red"} # 无效格式
|
||||
)
|
||||
issues = elem.validate()
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "ERROR"
|
||||
assert issues[0].code == "INVALID_COLOR_FORMAT"
|
||||
|
||||
def test_validate_font_too_small(self):
|
||||
"""测试字体太小警告"""
|
||||
elem = TextElement(font={"size": 6})
|
||||
issues = elem.validate()
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "WARNING"
|
||||
assert issues[0].code == "FONT_TOO_SMALL"
|
||||
|
||||
def test_validate_font_too_large(self):
|
||||
"""测试字体太大警告"""
|
||||
elem = TextElement(font={"size": 120})
|
||||
issues = elem.validate()
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "WARNING"
|
||||
assert issues[0].code == "FONT_TOO_LARGE"
|
||||
|
||||
def test_validate_valid_font_size(self):
|
||||
"""测试有效字体大小不产生警告"""
|
||||
elem = TextElement(font={"size": 18})
|
||||
issues = elem.validate()
|
||||
assert len(issues) == 0
|
||||
|
||||
|
||||
# ============= ImageElement 测试 =============
|
||||
|
||||
class TestImageElement:
|
||||
"""ImageElement 测试类"""
|
||||
|
||||
def test_create_with_defaults(self):
|
||||
"""测试使用默认值创建 ImageElement"""
|
||||
elem = ImageElement(src="test.png")
|
||||
assert elem.type == 'image'
|
||||
assert elem.src == "test.png"
|
||||
assert elem.box == [1, 1, 4, 3]
|
||||
|
||||
def test_empty_src_raises_error(self):
|
||||
"""测试空 src 会引发错误"""
|
||||
with pytest.raises(ValueError, match="图片元素必须指定 src"):
|
||||
ImageElement(src="")
|
||||
|
||||
def test_box_must_be_list_of_four(self):
|
||||
"""测试 box 必须是包含 4 个数字的列表"""
|
||||
with pytest.raises(ValueError, match="box 必须是包含 4 个数字的列表"):
|
||||
ImageElement(src="test.png", box=[1, 2, 3])
|
||||
|
||||
def test_validate_returns_empty_list(self):
|
||||
"""测试 ImageElement.validate() 返回空列表"""
|
||||
elem = ImageElement(src="test.png")
|
||||
issues = elem.validate()
|
||||
assert issues == []
|
||||
|
||||
|
||||
# ============= ShapeElement 测试 =============
|
||||
|
||||
class TestShapeElement:
|
||||
"""ShapeElement 测试类"""
|
||||
|
||||
def test_create_with_defaults(self):
|
||||
"""测试使用默认值创建 ShapeElement"""
|
||||
elem = ShapeElement()
|
||||
assert elem.type == 'shape'
|
||||
assert elem.shape == 'rectangle'
|
||||
assert elem.box == [1, 1, 2, 1]
|
||||
assert elem.fill is None
|
||||
assert elem.line is None
|
||||
|
||||
def test_box_must_be_list_of_four(self):
|
||||
"""测试 box 必须是包含 4 个数字的列表"""
|
||||
with pytest.raises(ValueError, match="box 必须是包含 4 个数字的列表"):
|
||||
ShapeElement(box=[1, 2, 3])
|
||||
|
||||
def test_validate_invalid_shape_type(self):
|
||||
"""测试无效形状类型"""
|
||||
elem = ShapeElement(shape="triangle")
|
||||
issues = elem.validate()
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "ERROR"
|
||||
assert issues[0].code == "INVALID_SHAPE_TYPE"
|
||||
assert "triangle" in issues[0].message
|
||||
|
||||
def test_validate_valid_shape_types(self):
|
||||
"""测试有效的形状类型"""
|
||||
for shape_type in ['rectangle', 'ellipse', 'rounded_rectangle']:
|
||||
elem = ShapeElement(shape=shape_type)
|
||||
issues = elem.validate()
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_validate_invalid_fill_color(self):
|
||||
"""测试无效填充颜色"""
|
||||
elem = ShapeElement(fill="red")
|
||||
issues = elem.validate()
|
||||
assert len(issues) == 1
|
||||
assert issues[0].code == "INVALID_COLOR_FORMAT"
|
||||
|
||||
def test_validate_invalid_line_color(self):
|
||||
"""测试无效线条颜色"""
|
||||
elem = ShapeElement(line={"color": "blue"})
|
||||
issues = elem.validate()
|
||||
assert len(issues) == 1
|
||||
assert issues[0].code == "INVALID_COLOR_FORMAT"
|
||||
|
||||
def test_validate_valid_colors(self):
|
||||
"""测试有效的颜色"""
|
||||
elem = ShapeElement(
|
||||
fill="#4a90e2",
|
||||
line={"color": "#000000", "width": 2}
|
||||
)
|
||||
issues = elem.validate()
|
||||
assert len(issues) == 0
|
||||
|
||||
|
||||
# ============= TableElement 测试 =============
|
||||
|
||||
class TestTableElement:
|
||||
"""TableElement 测试类"""
|
||||
|
||||
def test_create_with_defaults(self):
|
||||
"""测试使用默认值创建 TableElement"""
|
||||
elem = TableElement(data=[["A", "B"]])
|
||||
assert elem.type == 'table'
|
||||
assert elem.data == [["A", "B"]]
|
||||
assert elem.position == [1, 1]
|
||||
assert elem.col_widths == []
|
||||
assert elem.style == {}
|
||||
|
||||
def test_empty_data_raises_error(self):
|
||||
"""测试空数据会引发错误"""
|
||||
with pytest.raises(ValueError, match="表格数据不能为空"):
|
||||
TableElement(data=[])
|
||||
|
||||
def test_position_must_be_list_of_two(self):
|
||||
"""测试 position 必须是包含 2 个数字的列表"""
|
||||
with pytest.raises(ValueError, match="position 必须是包含 2 个数字的列表"):
|
||||
TableElement(data=[["A"]], position=[1])
|
||||
|
||||
def test_validate_inconsistent_columns(self):
|
||||
"""测试行列数不一致"""
|
||||
elem = TableElement(data=[
|
||||
["A", "B", "C"],
|
||||
["X", "Y"], # 只有 2 列
|
||||
["1", "2", "3"]
|
||||
])
|
||||
issues = elem.validate()
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "ERROR"
|
||||
assert issues[0].code == "TABLE_INCONSISTENT_COLUMNS"
|
||||
|
||||
def test_validate_col_widths_mismatch(self):
|
||||
"""测试 col_widths 与列数不匹配"""
|
||||
elem = TableElement(
|
||||
data=[["A", "B"], ["C", "D"]],
|
||||
col_widths=[1, 2, 3] # 3 列但数据只有 2 列
|
||||
)
|
||||
issues = elem.validate()
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "WARNING"
|
||||
assert issues[0].code == "TABLE_COL_WIDTHS_MISMATCH"
|
||||
|
||||
def test_validate_consistent_table(self):
|
||||
"""测试一致的表格数据"""
|
||||
elem = TableElement(
|
||||
data=[["A", "B"], ["C", "D"]],
|
||||
col_widths=[2, 2]
|
||||
)
|
||||
issues = elem.validate()
|
||||
assert len(issues) == 0
|
||||
|
||||
|
||||
# ============= create_element 工厂函数测试 =============
|
||||
|
||||
class TestCreateElement:
|
||||
"""create_element 工厂函数测试类"""
|
||||
|
||||
def test_create_text_element(self):
|
||||
"""测试创建文本元素"""
|
||||
elem = create_element({"type": "text", "content": "Test"})
|
||||
assert isinstance(elem, TextElement)
|
||||
assert elem.content == "Test"
|
||||
|
||||
def test_create_image_element(self):
|
||||
"""测试创建图片元素"""
|
||||
elem = create_element({"type": "image", "src": "test.png"})
|
||||
assert isinstance(elem, ImageElement)
|
||||
assert elem.src == "test.png"
|
||||
|
||||
def test_create_shape_element(self):
|
||||
"""测试创建形状元素"""
|
||||
elem = create_element({"type": "shape", "shape": "ellipse"})
|
||||
assert isinstance(elem, ShapeElement)
|
||||
assert elem.shape == "ellipse"
|
||||
|
||||
def test_create_table_element(self):
|
||||
"""测试创建表格元素"""
|
||||
elem = create_element({"type": "table", "data": [["A", "B"]]})
|
||||
assert isinstance(elem, TableElement)
|
||||
assert elem.data == [["A", "B"]]
|
||||
|
||||
def test_unsupported_type_raises_error(self):
|
||||
"""测试不支持的元素类型"""
|
||||
with pytest.raises(ValueError, match="不支持的元素类型"):
|
||||
create_element({"type": "video"})
|
||||
|
||||
def test_missing_type_raises_error(self):
|
||||
"""测试缺少 type 字段"""
|
||||
with pytest.raises(ValueError, match="不支持的元素类型"):
|
||||
create_element({"content": "Test"})
|
||||
|
||||
|
||||
# ============= _is_valid_color 工具函数测试 =============
|
||||
|
||||
class TestIsValidColor:
|
||||
"""_is_valid_color 工具函数测试类"""
|
||||
|
||||
def test_valid_full_hex_color(self):
|
||||
"""测试完整的 #RRGGBB 格式"""
|
||||
assert _is_valid_color("#ffffff") is True
|
||||
assert _is_valid_color("#000000") is True
|
||||
assert _is_valid_color("#4a90e2") is True
|
||||
assert _is_valid_color("#FF0000") is True
|
||||
|
||||
def test_valid_short_hex_color(self):
|
||||
"""测试短格式 #RGB"""
|
||||
assert _is_valid_color("#fff") is True
|
||||
assert _is_valid_color("#000") is True
|
||||
assert _is_valid_color("#abc") is True
|
||||
|
||||
def test_invalid_colors(self):
|
||||
"""测试无效颜色"""
|
||||
assert _is_valid_color("red") is False
|
||||
assert _is_valid_color("#gggggg") is False
|
||||
assert _is_valid_color("ffffff") is False # 缺少 #
|
||||
assert _is_valid_color("#ffff") is False # 4 位
|
||||
assert _is_valid_color("#fffff") is False # 5 位
|
||||
assert _is_valid_color("") is False
|
||||
|
||||
|
||||
# ============= 边界情况补充测试 =============
|
||||
|
||||
class TestTextElementBoundaryCases:
|
||||
"""TextElement 边界情况测试"""
|
||||
|
||||
def test_text_with_very_long_content(self):
|
||||
"""测试非常长的文本内容"""
|
||||
long_content = "A" * 1000
|
||||
elem = TextElement(
|
||||
content=long_content,
|
||||
box=[0, 0, 5, 1],
|
||||
font={"size": 12}
|
||||
)
|
||||
assert elem.content == long_content
|
||||
|
||||
def test_text_with_newline_characters(self):
|
||||
"""测试包含换行符的文本"""
|
||||
elem = TextElement(
|
||||
content="Line 1\nLine 2\nLine 3",
|
||||
box=[0, 0, 5, 2],
|
||||
font={}
|
||||
)
|
||||
assert "\n" in elem.content
|
||||
|
||||
def test_text_with_tab_characters(self):
|
||||
"""测试包含制表符的文本"""
|
||||
elem = TextElement(
|
||||
content="Col1\tCol2\tCol3",
|
||||
box=[0, 0, 5, 1],
|
||||
font={}
|
||||
)
|
||||
assert "\t" in elem.content
|
||||
|
||||
def test_text_empty_font_size(self):
|
||||
"""测试空字体大小"""
|
||||
elem = TextElement(
|
||||
content="Test",
|
||||
box=[0, 0, 1, 1],
|
||||
font={}
|
||||
)
|
||||
# 默认值应该有效
|
||||
assert "size" not in elem.font
|
||||
|
||||
def test_text_with_color_variations(self):
|
||||
"""测试不同颜色格式"""
|
||||
# 短格式应该被 _is_valid_color 接受,但元素也接受
|
||||
valid_colors = ["#fff", "#FFF", "#000", "#abc", "#ABC"]
|
||||
for color in valid_colors:
|
||||
elem = TextElement(
|
||||
content="Test",
|
||||
box=[0, 0, 1, 1],
|
||||
font={"color": color}
|
||||
)
|
||||
assert elem.font["color"] == color
|
||||
|
||||
|
||||
class TestTableElementBoundaryCases:
|
||||
"""TableElement 边界情况测试"""
|
||||
|
||||
def test_table_with_single_cell(self):
|
||||
"""测试单单元格表格"""
|
||||
elem = TableElement(
|
||||
data=[["Single Cell"]],
|
||||
position=[0, 0]
|
||||
)
|
||||
assert len(elem.data) == 1
|
||||
assert len(elem.data[0]) == 1
|
||||
|
||||
def test_table_with_many_columns(self):
|
||||
"""测试多列表格"""
|
||||
many_cols = ["Col" + str(i) for i in range(20)]
|
||||
elem = TableElement(
|
||||
data=[many_cols],
|
||||
position=[0, 0],
|
||||
col_widths=[1] * 20
|
||||
)
|
||||
assert len(elem.data[0]) == 20
|
||||
|
||||
def test_table_with_single_row(self):
|
||||
"""测试单行表格"""
|
||||
elem = TableElement(
|
||||
data=[["A", "B", "C"]],
|
||||
position=[0, 0]
|
||||
)
|
||||
assert len(elem.data) == 1
|
||||
|
||||
def test_table_with_many_rows(self):
|
||||
"""测试多行表格"""
|
||||
many_rows = [["Row" + str(i)] * 3 for i in range(50)]
|
||||
elem = TableElement(
|
||||
data=many_rows,
|
||||
position=[0, 0],
|
||||
col_widths=[1, 1, 1]
|
||||
)
|
||||
assert len(elem.data) == 50
|
||||
|
||||
|
||||
class TestShapeElementBoundaryCases:
|
||||
"""ShapeElement 边界情况测试"""
|
||||
|
||||
def test_shape_without_line_attribute(self):
|
||||
"""测试无边框的形状"""
|
||||
elem = ShapeElement(
|
||||
box=[1, 1, 2, 1],
|
||||
shape="rectangle",
|
||||
fill="#000000",
|
||||
line=None
|
||||
)
|
||||
assert elem.line is None
|
||||
|
||||
def test_shape_with_empty_line(self):
|
||||
"""测试空 line 字典"""
|
||||
elem = ShapeElement(
|
||||
box=[1, 1, 2, 1],
|
||||
shape="rectangle",
|
||||
fill="#000000",
|
||||
line={}
|
||||
)
|
||||
assert elem.line == {}
|
||||
|
||||
def test_shape_all_shape_types(self):
|
||||
"""测试所有支持的形状类型"""
|
||||
shapes = ["rectangle", "ellipse", "rounded_rectangle"]
|
||||
for shape_type in shapes:
|
||||
elem = ShapeElement(
|
||||
box=[1, 1, 1, 1],
|
||||
shape=shape_type,
|
||||
fill="#000000"
|
||||
)
|
||||
assert elem.shape == shape_type
|
||||
|
||||
|
||||
class TestImageElementBoundaryCases:
|
||||
"""ImageElement 边界情况测试"""
|
||||
|
||||
def test_image_with_relative_path(self):
|
||||
"""测试相对路径"""
|
||||
elem = ImageElement(
|
||||
box=[0, 0, 1, 1],
|
||||
src="images/test.png"
|
||||
)
|
||||
assert "images/test.png" == elem.src
|
||||
|
||||
def test_image_with_absolute_path(self):
|
||||
"""测试绝对路径"""
|
||||
elem = ImageElement(
|
||||
box=[0, 0, 1, 1],
|
||||
src="/absolute/path/image.png"
|
||||
)
|
||||
assert elem.src == "/absolute/path/image.png"
|
||||
|
||||
def test_image_with_windows_path(self):
|
||||
"""测试 Windows 路径"""
|
||||
elem = ImageElement(
|
||||
box=[0, 0, 1, 1],
|
||||
src="C:\\Images\\test.png"
|
||||
)
|
||||
assert elem.src == "C:\\Images\\test.png"
|
||||
|
||||
def test_image_with_special_characters_in_filename(self):
|
||||
"""测试文件名包含特殊字符"""
|
||||
special_names = [
|
||||
"image with spaces.png",
|
||||
"image-with-dashes.png",
|
||||
"image_with_underscores.png",
|
||||
"image.with.dots.png"
|
||||
]
|
||||
for name in special_names:
|
||||
elem = ImageElement(
|
||||
box=[0, 0, 1, 1],
|
||||
src=name
|
||||
)
|
||||
assert elem.src == name
|
||||
3
tests/unit/test_loaders/__init__.py
Normal file
3
tests/unit/test_loaders/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
加载器测试包
|
||||
"""
|
||||
171
tests/unit/test_loaders/test_yaml_loader.py
Normal file
171
tests/unit/test_loaders/test_yaml_loader.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
YAML 加载器单元测试
|
||||
|
||||
测试 load_yaml_file、validate_presentation_yaml 和 validate_template_yaml 函数
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from loaders.yaml_loader import (
|
||||
load_yaml_file, validate_presentation_yaml,
|
||||
validate_template_yaml, YAMLError
|
||||
)
|
||||
|
||||
|
||||
class TestLoadYamlFile:
|
||||
"""load_yaml_file 函数测试类"""
|
||||
|
||||
def test_load_valid_yaml(self, sample_yaml):
|
||||
"""测试加载有效的 YAML 文件"""
|
||||
data = load_yaml_file(sample_yaml)
|
||||
assert isinstance(data, dict)
|
||||
assert "slides" in data
|
||||
|
||||
def test_load_nonexistent_file(self, temp_dir):
|
||||
"""测试加载不存在的文件"""
|
||||
nonexistent = temp_dir / "nonexistent.yaml"
|
||||
with pytest.raises(YAMLError, match="文件不存在"):
|
||||
load_yaml_file(nonexistent)
|
||||
|
||||
def test_load_directory_raises_error(self, temp_dir):
|
||||
"""测试加载目录会引发错误"""
|
||||
with pytest.raises(YAMLError, match="不是有效的文件"):
|
||||
load_yaml_file(temp_dir)
|
||||
|
||||
def test_load_yaml_with_syntax_error(self, temp_dir):
|
||||
"""测试加载包含语法错误的 YAML"""
|
||||
invalid_file = temp_dir / "invalid.yaml"
|
||||
invalid_file.write_text("key: [unclosed list")
|
||||
with pytest.raises(YAMLError, match="YAML 语法错误"):
|
||||
load_yaml_file(invalid_file)
|
||||
|
||||
def test_load_utf8_content(self, temp_dir):
|
||||
"""测试加载 UTF-8 编码的内容"""
|
||||
utf8_file = temp_dir / "utf8.yaml"
|
||||
utf8_file.write_text("key: '中文内容'", encoding='utf-8')
|
||||
data = load_yaml_file(utf8_file)
|
||||
assert data["key"] == "中文内容"
|
||||
|
||||
|
||||
class TestValidatePresentationYaml:
|
||||
"""validate_presentation_yaml 函数测试类"""
|
||||
|
||||
def test_valid_presentation_structure(self):
|
||||
"""测试有效的演示文稿结构"""
|
||||
data = {
|
||||
"metadata": {"size": "16:9"},
|
||||
"slides": [{"elements": []}]
|
||||
}
|
||||
# 不应该引发错误
|
||||
validate_presentation_yaml(data)
|
||||
|
||||
def test_missing_slides_field(self):
|
||||
"""测试缺少 slides 字段"""
|
||||
data = {"metadata": {"size": "16:9"}}
|
||||
with pytest.raises(YAMLError, match="缺少必需字段 'slides'"):
|
||||
validate_presentation_yaml(data)
|
||||
|
||||
def test_slides_not_a_list(self):
|
||||
"""测试 slides 不是列表"""
|
||||
data = {"slides": "not a list"}
|
||||
with pytest.raises(YAMLError, match="'slides' 必须是一个列表"):
|
||||
validate_presentation_yaml(data)
|
||||
|
||||
def test_not_a_dict(self):
|
||||
"""测试不是字典"""
|
||||
data = "not a dict"
|
||||
with pytest.raises(YAMLError, match="必须是一个字典对象"):
|
||||
validate_presentation_yaml(data)
|
||||
|
||||
def test_empty_slides_list(self):
|
||||
"""测试空的 slides 列表"""
|
||||
data = {"slides": []}
|
||||
# 空列表是有效的
|
||||
validate_presentation_yaml(data)
|
||||
|
||||
def test_with_file_path_in_error(self):
|
||||
"""测试错误消息包含文件路径"""
|
||||
data = {"not_slides": []}
|
||||
with pytest.raises(YAMLError) as exc_info:
|
||||
validate_presentation_yaml(data, "test.yaml")
|
||||
assert "test.yaml" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestValidateTemplateYaml:
|
||||
"""validate_template_yaml 函数测试类"""
|
||||
|
||||
def test_valid_template_structure(self):
|
||||
"""测试有效的模板结构"""
|
||||
data = {
|
||||
"vars": [
|
||||
{"name": "title", "required": True}
|
||||
],
|
||||
"elements": []
|
||||
}
|
||||
# 不应该引发错误
|
||||
validate_template_yaml(data)
|
||||
|
||||
def test_missing_elements_field(self):
|
||||
"""测试缺少 elements 字段"""
|
||||
data = {"vars": []}
|
||||
with pytest.raises(YAMLError, match="缺少必需字段 'elements'"):
|
||||
validate_template_yaml(data)
|
||||
|
||||
def test_elements_not_a_list(self):
|
||||
"""测试 elements 不是列表"""
|
||||
data = {"elements": "not a list"}
|
||||
with pytest.raises(YAMLError, match="'elements' 必须是一个列表"):
|
||||
validate_template_yaml(data)
|
||||
|
||||
def test_vars_not_a_list(self):
|
||||
"""测试 vars 不是列表"""
|
||||
data = {
|
||||
"vars": "not a list",
|
||||
"elements": []
|
||||
}
|
||||
with pytest.raises(YAMLError, match="'vars' 必须是一个列表"):
|
||||
validate_template_yaml(data)
|
||||
|
||||
def test_var_item_not_a_dict(self):
|
||||
"""测试变量项不是字典"""
|
||||
data = {
|
||||
"vars": ["not a dict"],
|
||||
"elements": []
|
||||
}
|
||||
with pytest.raises(YAMLError, match="必须是一个字典对象"):
|
||||
validate_template_yaml(data)
|
||||
|
||||
def test_var_item_missing_name(self):
|
||||
"""测试变量项缺少 name 字段"""
|
||||
data = {
|
||||
"vars": [{"required": True}],
|
||||
"elements": []
|
||||
}
|
||||
with pytest.raises(YAMLError, match="缺少必需字段 'name'"):
|
||||
validate_template_yaml(data)
|
||||
|
||||
def test_template_without_vars(self):
|
||||
"""测试没有 vars 的模板"""
|
||||
data = {"elements": []}
|
||||
# vars 是可选的
|
||||
validate_template_yaml(data)
|
||||
|
||||
def test_not_a_dict(self):
|
||||
"""测试不是字典"""
|
||||
data = "not a dict"
|
||||
with pytest.raises(YAMLError, match="必须是一个字典对象"):
|
||||
validate_template_yaml(data)
|
||||
|
||||
|
||||
class TestYAMLError:
|
||||
"""YAMLError 异常测试类"""
|
||||
|
||||
def test_is_an_exception(self):
|
||||
"""测试 YAMLError 是异常类"""
|
||||
error = YAMLError("Test error")
|
||||
assert isinstance(error, Exception)
|
||||
|
||||
def test_can_be_raised_and_caught(self):
|
||||
"""测试可以被引发和捕获"""
|
||||
with pytest.raises(YAMLError):
|
||||
raise YAMLError("Test")
|
||||
338
tests/unit/test_presentation.py
Normal file
338
tests/unit/test_presentation.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
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
|
||||
3
tests/unit/test_renderers/__init__.py
Normal file
3
tests/unit/test_renderers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
渲染器测试包
|
||||
"""
|
||||
524
tests/unit/test_renderers/test_html_renderer.py
Normal file
524
tests/unit/test_renderers/test_html_renderer.py
Normal file
@@ -0,0 +1,524 @@
|
||||
"""
|
||||
HTML 渲染器单元测试
|
||||
|
||||
测试 HtmlRenderer 类的各个渲染方法
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from renderers.html_renderer import HtmlRenderer, DPI
|
||||
from core.elements import TextElement, ImageElement, ShapeElement, TableElement
|
||||
|
||||
|
||||
class TestHtmlRenderer:
|
||||
"""HtmlRenderer 测试类"""
|
||||
|
||||
def test_renderer_has_dpi_constant(self):
|
||||
"""测试渲染器有 DPI 常量"""
|
||||
assert DPI == 96
|
||||
|
||||
def test_init_renderer(self):
|
||||
"""测试创建渲染器"""
|
||||
renderer = HtmlRenderer()
|
||||
assert renderer is not None
|
||||
|
||||
|
||||
class TestRenderText:
|
||||
"""render_text 方法测试类"""
|
||||
|
||||
def test_render_text_basic(self):
|
||||
"""测试渲染基本文本"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TextElement(
|
||||
content="Test Content",
|
||||
box=[1, 2, 3, 0.5],
|
||||
font={"size": 18, "color": "#333333"},
|
||||
)
|
||||
|
||||
html = renderer.render_text(elem)
|
||||
|
||||
assert "Test Content" in html
|
||||
assert "text-element" in html
|
||||
assert "left: 96px" in html # 1 * 96
|
||||
assert "top: 192px" in html # 2 * 96
|
||||
assert "font-size: 18pt" in html
|
||||
assert "color: #333333" in html
|
||||
|
||||
def test_render_text_with_bold(self):
|
||||
"""测试渲染粗体文本"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TextElement(
|
||||
content="Bold Text", box=[0, 0, 1, 1], font={"size": 16, "bold": True}
|
||||
)
|
||||
|
||||
html = renderer.render_text(elem)
|
||||
|
||||
assert "font-weight: bold;" in html
|
||||
|
||||
def test_render_text_with_italic(self):
|
||||
"""测试渲染斜体文本"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TextElement(
|
||||
content="Italic Text", box=[0, 0, 1, 1], font={"size": 16, "italic": True}
|
||||
)
|
||||
|
||||
html = renderer.render_text(elem)
|
||||
|
||||
assert "font-style: italic;" in html
|
||||
|
||||
def test_render_text_with_center_align(self):
|
||||
"""测试渲染居中对齐文本"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TextElement(
|
||||
content="Centered", box=[0, 0, 1, 1], font={"align": "center"}
|
||||
)
|
||||
|
||||
html = renderer.render_text(elem)
|
||||
|
||||
assert "text-align: center;" in html
|
||||
|
||||
def test_render_text_with_right_align(self):
|
||||
"""测试渲染右对齐文本"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TextElement(
|
||||
content="Right Aligned", box=[0, 0, 1, 1], font={"align": "right"}
|
||||
)
|
||||
|
||||
html = renderer.render_text(elem)
|
||||
|
||||
assert "text-align: right;" in html
|
||||
|
||||
def test_render_text_with_default_align(self):
|
||||
"""测试默认左对齐"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TextElement(content="Default", box=[0, 0, 1, 1], font={})
|
||||
|
||||
html = renderer.render_text(elem)
|
||||
|
||||
assert "text-align: left;" in html
|
||||
|
||||
def test_render_text_escapes_html(self):
|
||||
"""测试 HTML 特殊字符转义"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TextElement(
|
||||
content="<script>alert('xss')</script>", box=[0, 0, 1, 1], font={}
|
||||
)
|
||||
|
||||
html = renderer.render_text(elem)
|
||||
|
||||
assert "<" in html
|
||||
assert ">" in html
|
||||
assert "<script>" not in html
|
||||
|
||||
def test_render_text_with_special_characters(self):
|
||||
"""测试特殊字符处理"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TextElement(content="Test & < > \" '", box=[0, 0, 1, 1], font={})
|
||||
|
||||
html = renderer.render_text(elem)
|
||||
|
||||
assert "&" in html
|
||||
assert "<" in html
|
||||
assert ">" in html
|
||||
|
||||
def test_render_text_with_long_content(self):
|
||||
"""测试长文本内容"""
|
||||
renderer = HtmlRenderer()
|
||||
long_content = "A" * 500
|
||||
elem = TextElement(content=long_content, box=[0, 0, 5, 1], font={"size": 12})
|
||||
|
||||
html = renderer.render_text(elem)
|
||||
|
||||
assert long_content in html
|
||||
assert "overflow-wrap: break-word" in html
|
||||
|
||||
def test_render_text_with_newlines(self):
|
||||
"""测试包含换行符的文本"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TextElement(
|
||||
content="Line 1\nLine 2\nLine 3", box=[0, 0, 5, 2], font={"size": 14}
|
||||
)
|
||||
|
||||
html = renderer.render_text(elem)
|
||||
|
||||
# 换行符应该被保留
|
||||
assert "Line 1" in html
|
||||
assert "Line 2" in html
|
||||
|
||||
def test_render_text_with_unicode(self):
|
||||
"""测试 Unicode 字符"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TextElement(content="测试中文 🌍", box=[0, 0, 5, 1], font={"size": 16})
|
||||
|
||||
html = renderer.render_text(elem)
|
||||
|
||||
assert "测试中文" in html
|
||||
assert "🌍" in html
|
||||
|
||||
def test_render_text_with_empty_font(self):
|
||||
"""测试空字体属性"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TextElement(content="Test", box=[0, 0, 1, 1], font={})
|
||||
|
||||
html = renderer.render_text(elem)
|
||||
|
||||
# 应该使用默认值
|
||||
assert "font-size: 16pt" in html
|
||||
assert "color: #000000" in html
|
||||
|
||||
|
||||
class TestRenderShape:
|
||||
"""render_shape 方法测试类"""
|
||||
|
||||
def test_render_rectangle(self):
|
||||
"""测试渲染矩形"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = ShapeElement(box=[1, 1, 2, 1], shape="rectangle", fill="#4a90e2")
|
||||
|
||||
html = renderer.render_shape(elem)
|
||||
|
||||
assert "shape-element" in html
|
||||
assert "background: #4a90e2" in html
|
||||
assert "border-radius: 0;" in html
|
||||
|
||||
def test_render_ellipse(self):
|
||||
"""测试渲染椭圆"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = ShapeElement(box=[1, 1, 2, 2], shape="ellipse", fill="#e24a4a")
|
||||
|
||||
html = renderer.render_shape(elem)
|
||||
|
||||
assert "border-radius: 50%" in html
|
||||
assert "background: #e24a4a" in html
|
||||
|
||||
def test_render_rounded_rectangle(self):
|
||||
"""测试渲染圆角矩形"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = ShapeElement(box=[1, 1, 2, 1], shape="rounded_rectangle", fill="#4ae290")
|
||||
|
||||
html = renderer.render_shape(elem)
|
||||
|
||||
assert "border-radius: 8px" in html
|
||||
assert "background: #4ae290" in html
|
||||
|
||||
def test_render_shape_without_fill(self):
|
||||
"""测试无填充颜色的形状"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = ShapeElement(box=[1, 1, 2, 1], shape="rectangle", fill=None)
|
||||
|
||||
html = renderer.render_shape(elem)
|
||||
|
||||
assert "background: transparent" in html
|
||||
|
||||
def test_render_shape_with_line(self):
|
||||
"""测试带边框的形状"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = ShapeElement(
|
||||
box=[1, 1, 2, 1],
|
||||
shape="rectangle",
|
||||
fill="#4a90e2",
|
||||
line={"color": "#000000", "width": 2},
|
||||
)
|
||||
|
||||
html = renderer.render_shape(elem)
|
||||
|
||||
assert "border: 2pt solid #000000" in html
|
||||
|
||||
def test_render_shape_with_line_default_width(self):
|
||||
"""测试边框默认宽度"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = ShapeElement(
|
||||
box=[1, 1, 2, 1],
|
||||
shape="rectangle",
|
||||
fill="#4a90e2",
|
||||
line={"color": "#000000"},
|
||||
)
|
||||
|
||||
html = renderer.render_shape(elem)
|
||||
|
||||
assert "border: 1pt solid #000000" in html
|
||||
|
||||
def test_render_shape_without_line(self):
|
||||
"""测试无边框的形状"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = ShapeElement(box=[1, 1, 2, 1], shape="rectangle", fill="#4a90e2")
|
||||
|
||||
html = renderer.render_shape(elem)
|
||||
|
||||
assert "border:" not in html
|
||||
|
||||
def test_render_shape_position(self):
|
||||
"""测试形状位置计算"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = ShapeElement(box=[1.5, 2.5, 3, 1.5], shape="rectangle", fill="#000000")
|
||||
|
||||
html = renderer.render_shape(elem)
|
||||
|
||||
# 1.5 * 96 = 144
|
||||
assert "left: 144" in html
|
||||
# 2.5 * 96 = 240
|
||||
assert "top: 240" in html
|
||||
# 3 * 96 = 288
|
||||
assert "width: 288" in html
|
||||
# 1.5 * 96 = 144
|
||||
assert "height: 144" in html
|
||||
|
||||
|
||||
class TestRenderTable:
|
||||
"""render_table 方法测试类"""
|
||||
|
||||
def test_render_basic_table(self):
|
||||
"""测试渲染基本表格"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TableElement(
|
||||
position=[1, 1],
|
||||
col_widths=[2, 2, 2],
|
||||
data=[["A", "B", "C"], ["1", "2", "3"]],
|
||||
style={},
|
||||
)
|
||||
|
||||
html = renderer.render_table(elem)
|
||||
|
||||
assert "table-element" in html
|
||||
assert "A" in html
|
||||
assert "B" in html
|
||||
assert "C" in html
|
||||
assert "1" in html
|
||||
assert "2" in html
|
||||
assert "3" in html
|
||||
|
||||
def test_render_table_with_header_style(self):
|
||||
"""测试表格表头样式"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TableElement(
|
||||
position=[1, 1],
|
||||
col_widths=[2, 2],
|
||||
data=[["H1", "H2"], ["D1", "D2"]],
|
||||
style={"font_size": 14, "header_bg": "#4a90e2", "header_color": "#ffffff"},
|
||||
)
|
||||
|
||||
html = renderer.render_table(elem)
|
||||
|
||||
assert "background: #4a90e2" in html
|
||||
assert "color: #ffffff" in html
|
||||
assert "font-size: 14pt" in html
|
||||
|
||||
def test_render_table_position(self):
|
||||
"""测试表格位置"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TableElement(
|
||||
position=[2, 3], col_widths=[1, 1], data=[["A", "B"]], style={}
|
||||
)
|
||||
|
||||
html = renderer.render_table(elem)
|
||||
|
||||
# 2 * 96 = 192
|
||||
assert "left: 192px" in html
|
||||
# 3 * 96 = 288
|
||||
assert "top: 288px" in html
|
||||
|
||||
def test_render_table_with_default_font_size(self):
|
||||
"""测试默认字体大小"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TableElement(position=[0, 0], col_widths=[1], data=[["Cell"]], style={})
|
||||
|
||||
html = renderer.render_table(elem)
|
||||
|
||||
assert "font-size: 14pt" in html
|
||||
|
||||
def test_render_table_escapes_content(self):
|
||||
"""测试表格内容转义"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TableElement(
|
||||
position=[0, 0], col_widths=[1], data=[["<script>"]], style={}
|
||||
)
|
||||
|
||||
html = renderer.render_table(elem)
|
||||
|
||||
assert "<" in html
|
||||
assert "<script>" not in html
|
||||
|
||||
def test_render_table_with_single_row(self):
|
||||
"""测试单行表格"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TableElement(
|
||||
position=[0, 0], col_widths=[1, 2, 3], data=[["A", "B", "C"]], style={}
|
||||
)
|
||||
|
||||
html = renderer.render_table(elem)
|
||||
|
||||
assert "<tr>" in html
|
||||
assert "A" in html
|
||||
assert "B" in html
|
||||
assert "C" in html
|
||||
|
||||
def test_render_table_with_many_rows(self):
|
||||
"""测试多行表格"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TableElement(
|
||||
position=[0, 0],
|
||||
col_widths=[1, 1],
|
||||
data=[["R1C1", "R1C2"], ["R2C1", "R2C2"], ["R3C1", "R3C2"]],
|
||||
style={},
|
||||
)
|
||||
|
||||
html = renderer.render_table(elem)
|
||||
|
||||
assert html.count("<tr>") == 3
|
||||
assert "R1C1" in html
|
||||
assert "R2C2" in html
|
||||
|
||||
def test_render_table_with_unicode(self):
|
||||
"""测试表格 Unicode 内容"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = TableElement(position=[0, 0], col_widths=[1], data=[["测试"]], style={})
|
||||
|
||||
html = renderer.render_table(elem)
|
||||
|
||||
assert "测试" in html
|
||||
|
||||
|
||||
class TestRenderImage:
|
||||
"""render_image 方法测试类"""
|
||||
|
||||
def test_render_image_basic(self, temp_dir):
|
||||
"""测试渲染基本图片"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = ImageElement(box=[1, 1, 4, 3], src="test.png")
|
||||
|
||||
html = renderer.render_image(elem, temp_dir)
|
||||
|
||||
assert "image-element" in html
|
||||
assert "test.png" in html
|
||||
assert "left: 96px" in html # 1 * 96
|
||||
assert "top: 96px" in html
|
||||
|
||||
def test_render_image_with_base_path(self, temp_dir):
|
||||
"""测试带基础路径的图片"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = ImageElement(box=[0, 0, 2, 2], src="subdir/image.png")
|
||||
|
||||
html = renderer.render_image(elem, temp_dir)
|
||||
|
||||
# 图片路径会被转换为绝对路径
|
||||
assert "file://" in html
|
||||
|
||||
def test_render_image_without_base_path(self):
|
||||
"""测试无基础路径的图片"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = ImageElement(box=[0, 0, 2, 2], src="/absolute/path/image.png")
|
||||
|
||||
html = renderer.render_image(elem, None)
|
||||
|
||||
# 图片路径会被转换为绝对路径
|
||||
assert "file://" in html
|
||||
|
||||
def test_render_image_position_calculation(self):
|
||||
"""测试图片位置计算"""
|
||||
renderer = HtmlRenderer()
|
||||
elem = ImageElement(box=[2.5, 3.5, 4, 3], src="test.png")
|
||||
|
||||
html = renderer.render_image(elem, None)
|
||||
|
||||
# 2.5 * 96 = 240
|
||||
assert "left: 240" in html
|
||||
# 3.5 * 96 = 336
|
||||
assert "top: 336" in html
|
||||
# 4 * 96 = 384
|
||||
assert "width: 384" in html
|
||||
# 3 * 96 = 288
|
||||
assert "height: 288" in html
|
||||
|
||||
|
||||
class TestRenderSlide:
|
||||
"""render_slide 方法测试类"""
|
||||
|
||||
def test_render_slide_basic(self):
|
||||
"""测试渲染基本幻灯片"""
|
||||
renderer = HtmlRenderer()
|
||||
slide_data = {
|
||||
"background": None,
|
||||
"elements": [TextElement(content="Test", box=[0, 0, 1, 1], font={})],
|
||||
}
|
||||
|
||||
html = renderer.render_slide(slide_data, 0, None)
|
||||
|
||||
assert '<div class="slide"' in html
|
||||
assert "幻灯片 1" in html
|
||||
assert "Test" in html
|
||||
|
||||
def test_render_slide_with_background_color(self):
|
||||
"""测试带背景颜色的幻灯片"""
|
||||
renderer = HtmlRenderer()
|
||||
slide_data = {"background": {"color": "#ffffff"}, "elements": []}
|
||||
|
||||
html = renderer.render_slide(slide_data, 0, None)
|
||||
|
||||
assert "background: #ffffff" in html
|
||||
|
||||
def test_render_slide_with_multiple_elements(self):
|
||||
"""测试包含多个元素的幻灯片"""
|
||||
renderer = HtmlRenderer()
|
||||
slide_data = {
|
||||
"background": None,
|
||||
"elements": [
|
||||
TextElement(content="Text 1", box=[0, 0, 1, 1], font={}),
|
||||
ShapeElement(box=[2, 2, 1, 1], shape="rectangle", fill="#000"),
|
||||
],
|
||||
}
|
||||
|
||||
html = renderer.render_slide(slide_data, 0, None)
|
||||
|
||||
assert "Text 1" in html
|
||||
assert "shape-element" in html
|
||||
|
||||
def test_render_slide_with_different_indices(self):
|
||||
"""测试不同幻灯片索引"""
|
||||
renderer = HtmlRenderer()
|
||||
slide_data = {"background": None, "elements": []}
|
||||
|
||||
html0 = renderer.render_slide(slide_data, 0, None)
|
||||
html1 = renderer.render_slide(slide_data, 1, None)
|
||||
html5 = renderer.render_slide(slide_data, 5, None)
|
||||
|
||||
assert "幻灯片 1" in html0
|
||||
assert "幻灯片 2" in html1
|
||||
assert "幻灯片 6" in html5
|
||||
|
||||
def test_render_slide_without_background(self):
|
||||
"""测试无背景的幻灯片"""
|
||||
renderer = HtmlRenderer()
|
||||
slide_data = {"background": None, "elements": []}
|
||||
|
||||
html = renderer.render_slide(slide_data, 0, None)
|
||||
|
||||
# 应该有 slide div 但没有 background style
|
||||
assert '<div class="slide"' in html
|
||||
# 不应该有 background: 样式
|
||||
assert "background:" not in html
|
||||
|
||||
def test_render_slide_empty_elements(self):
|
||||
"""测试空元素列表"""
|
||||
renderer = HtmlRenderer()
|
||||
slide_data = {"background": None, "elements": []}
|
||||
|
||||
html = renderer.render_slide(slide_data, 0, None)
|
||||
|
||||
assert '<div class="slide"' in html
|
||||
assert "幻灯片 1" in html
|
||||
|
||||
def test_render_slide_with_render_error(self):
|
||||
"""测试元素渲染错误处理"""
|
||||
renderer = HtmlRenderer()
|
||||
|
||||
# 创建一个不匹配任何已知类型的元素
|
||||
class UnknownElement:
|
||||
box = [0, 0, 1, 1]
|
||||
type = "unknown_type"
|
||||
|
||||
slide_data = {"background": None, "elements": [UnknownElement()]}
|
||||
|
||||
html = renderer.render_slide(slide_data, 0, None)
|
||||
|
||||
# 未知类型不会被渲染,但不会报错
|
||||
assert '<div class="slide"' in html
|
||||
451
tests/unit/test_renderers/test_pptx_renderer.py
Normal file
451
tests/unit/test_renderers/test_pptx_renderer.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
def _setup_mock_slide(self):
|
||||
"""辅助函数:创建 mock slide"""
|
||||
mock_slide = Mock()
|
||||
mock_text_frame = Mock()
|
||||
mock_text_frame.word_wrap = True
|
||||
mock_paragraph = Mock()
|
||||
mock_paragraph.font = Mock()
|
||||
mock_text_frame.paragraphs = [mock_paragraph]
|
||||
|
||||
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(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, 2, 2],
|
||||
data=[["A", "B", "C"], ["1", "2", "3"]],
|
||||
style={"font_size": 14},
|
||||
)
|
||||
|
||||
gen._render_table(mock_slide, elem)
|
||||
|
||||
# 验证添加了表格
|
||||
mock_slide.shapes.add_table.assert_called_once()
|
||||
|
||||
@patch("renderers.pptx_renderer.PptxPresentation")
|
||||
def test_render_table_with_header_style(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, 2],
|
||||
data=[["H1", "H2"], ["D1", "D2"]],
|
||||
style={"font_size": 14, "header_bg": "#4a90e2", "header_color": "#ffffff"},
|
||||
)
|
||||
|
||||
gen._render_table(mock_slide, elem)
|
||||
|
||||
mock_slide.shapes.add_table.assert_called_once()
|
||||
|
||||
@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 列数据
|
||||
style={},
|
||||
)
|
||||
|
||||
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)
|
||||
451
tests/unit/test_template.py
Normal file
451
tests/unit/test_template.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
模板系统单元测试
|
||||
|
||||
测试 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, sample_template):
|
||||
"""测试非字符串值保持原样"""
|
||||
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"})
|
||||
# 由于条件渲染,subtitle元素被跳过,只返回1个元素
|
||||
assert len(result) == 1
|
||||
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"
|
||||
assert result[0]["font"]["color"] == "#ff0000"
|
||||
104
tests/unit/test_utils.py
Normal file
104
tests/unit/test_utils.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
工具函数单元测试
|
||||
|
||||
测试颜色转换和格式验证函数
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from utils import hex_to_rgb, validate_color
|
||||
|
||||
|
||||
class TestHexToRgb:
|
||||
"""hex_to_rgb 函数测试类"""
|
||||
|
||||
def test_full_hex_format(self):
|
||||
"""测试完整的 #RRGGBB 格式"""
|
||||
assert hex_to_rgb("#ffffff") == (255, 255, 255)
|
||||
assert hex_to_rgb("#000000") == (0, 0, 0)
|
||||
assert hex_to_rgb("#4a90e2") == (74, 144, 226)
|
||||
assert hex_to_rgb("#FF0000") == (255, 0, 0)
|
||||
assert hex_to_rgb("#00FF00") == (0, 255, 0)
|
||||
assert hex_to_rgb("#0000FF") == (0, 0, 255)
|
||||
|
||||
def test_short_hex_format(self):
|
||||
"""测试短格式 #RGB"""
|
||||
assert hex_to_rgb("#fff") == (255, 255, 255)
|
||||
assert hex_to_rgb("#000") == (0, 0, 0)
|
||||
assert hex_to_rgb("#abc") == (170, 187, 204)
|
||||
assert hex_to_rgb("#ABC") == (170, 187, 204)
|
||||
|
||||
def test_without_hash_sign(self):
|
||||
"""测试没有 # 号"""
|
||||
assert hex_to_rgb("ffffff") == (255, 255, 255)
|
||||
assert hex_to_rgb("4a90e2") == (74, 144, 226)
|
||||
|
||||
def test_invalid_length(self):
|
||||
"""测试无效长度"""
|
||||
with pytest.raises(ValueError, match="无效的颜色格式"):
|
||||
hex_to_rgb("#ff") # 太短
|
||||
with pytest.raises(ValueError, match="无效的颜色格式"):
|
||||
hex_to_rgb("#fffff") # 5 位
|
||||
with pytest.raises(ValueError, match="无效的颜色格式"):
|
||||
hex_to_rgb("#fffffff") # 7 位
|
||||
|
||||
def test_invalid_characters(self):
|
||||
"""测试无效字符"""
|
||||
with pytest.raises(ValueError):
|
||||
hex_to_rgb("#gggggg")
|
||||
with pytest.raises(ValueError):
|
||||
hex_to_rgb("#xyz")
|
||||
|
||||
def test_mixed_case(self):
|
||||
"""测试大小写混合"""
|
||||
assert hex_to_rgb("#AbCdEf") == (171, 205, 239)
|
||||
assert hex_to_rgb("#aBcDeF") == (171, 205, 239)
|
||||
|
||||
|
||||
class TestValidateColor:
|
||||
"""validate_color 函数测试类"""
|
||||
|
||||
def test_valid_full_hex_colors(self):
|
||||
"""测试有效的完整十六进制颜色"""
|
||||
assert validate_color("#ffffff") is True
|
||||
assert validate_color("#000000") is True
|
||||
assert validate_color("#4a90e2") is True
|
||||
assert validate_color("#FF0000") is True
|
||||
assert validate_color("#ABCDEF") is True
|
||||
|
||||
def test_valid_short_hex_colors(self):
|
||||
"""测试有效的短格式十六进制颜色"""
|
||||
assert validate_color("#fff") is True
|
||||
assert validate_color("#000") is True
|
||||
assert validate_color("#abc") is True
|
||||
assert validate_color("#ABC") is True
|
||||
|
||||
def test_without_hash_sign(self):
|
||||
"""测试没有 # 号"""
|
||||
assert validate_color("ffffff") is False
|
||||
assert validate_color("fff") is False
|
||||
|
||||
def test_invalid_length(self):
|
||||
"""测试无效长度"""
|
||||
assert validate_color("#ff") is False
|
||||
assert validate_color("#ffff") is False
|
||||
assert validate_color("#fffff") is False
|
||||
assert validate_color("#fffffff") is False
|
||||
|
||||
def test_invalid_characters(self):
|
||||
"""测试无效字符"""
|
||||
assert validate_color("#gggggg") is False
|
||||
assert validate_color("#xyz") is False
|
||||
assert validate_color("red") is False
|
||||
assert validate_color("rgb(255,0,0)") is False
|
||||
|
||||
def test_non_string_input(self):
|
||||
"""测试非字符串输入"""
|
||||
assert validate_color(123) is False
|
||||
assert validate_color(None) is False
|
||||
assert validate_color([]) is False
|
||||
assert validate_color({"color": "#fff"}) is False
|
||||
|
||||
def test_empty_string(self):
|
||||
"""测试空字符串"""
|
||||
assert validate_color("") is False
|
||||
assert validate_color("#") is False
|
||||
3
tests/unit/test_validators/__init__.py
Normal file
3
tests/unit/test_validators/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
验证器测试包
|
||||
"""
|
||||
128
tests/unit/test_validators/test_geometry.py
Normal file
128
tests/unit/test_validators/test_geometry.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
几何验证器单元测试
|
||||
|
||||
测试 GeometryValidator 的元素边界检查功能
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from validators.geometry import GeometryValidator, TOLERANCE
|
||||
from validators.result import ValidationIssue
|
||||
|
||||
|
||||
class TestGeometryValidator:
|
||||
"""GeometryValidator 测试类"""
|
||||
|
||||
def test_init(self):
|
||||
"""测试初始化"""
|
||||
validator = GeometryValidator(10, 5.625)
|
||||
assert validator.slide_width == 10
|
||||
assert validator.slide_height == 5.625
|
||||
|
||||
def test_validate_element_within_bounds(self):
|
||||
"""测试元素在边界内"""
|
||||
validator = GeometryValidator(10, 5.625)
|
||||
elem = type('Element', (), {
|
||||
'box': [1, 1, 5, 2]
|
||||
})()
|
||||
issues = validator.validate_element(elem, 1, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_validate_element_right_boundary_exceeded(self):
|
||||
"""测试元素右边界超出"""
|
||||
validator = GeometryValidator(10, 5.625)
|
||||
elem = type('Element', (), {
|
||||
'box': [8, 1, 3, 1] # right = 11 > 10
|
||||
})()
|
||||
issues = validator.validate_element(elem, 1, 1)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "WARNING"
|
||||
assert issues[0].code == "ELEMENT_OUT_OF_BOUNDS"
|
||||
assert "右边界" in issues[0].message
|
||||
|
||||
def test_validate_element_bottom_boundary_exceeded(self):
|
||||
"""测试元素下边界超出"""
|
||||
validator = GeometryValidator(10, 5.625)
|
||||
elem = type('Element', (), {
|
||||
'box': [1, 5, 2, 1] # bottom = 6 > 5.625
|
||||
})()
|
||||
issues = validator.validate_element(elem, 1, 1)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "WARNING"
|
||||
assert issues[0].code == "ELEMENT_OUT_OF_BOUNDS"
|
||||
assert "下边界" in issues[0].message
|
||||
|
||||
def test_validate_element_completely_out_of_bounds(self):
|
||||
"""测试元素完全在页面外"""
|
||||
validator = GeometryValidator(10, 5.625)
|
||||
elem = type('Element', (), {
|
||||
'box': [12, 7, 2, 1] # 完全在页面外
|
||||
})()
|
||||
issues = validator.validate_element(elem, 1, 1)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "WARNING"
|
||||
assert issues[0].code == "ELEMENT_COMPLETELY_OUT_OF_BOUNDS"
|
||||
|
||||
def test_validate_element_within_tolerance(self):
|
||||
"""测试元素在容忍度范围内"""
|
||||
validator = GeometryValidator(10, 5.625)
|
||||
# 右边界 = 10.05,容忍度 0.1,应该通过
|
||||
elem = type('Element', (), {
|
||||
'box': [8, 1, 2.05, 1]
|
||||
})()
|
||||
issues = validator.validate_element(elem, 1, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_validate_element_slightly_beyond_tolerance(self):
|
||||
"""测试元素稍微超出容忍度"""
|
||||
validator = GeometryValidator(10, 5.625)
|
||||
# 右边界 = 10.15,超出容忍度 0.1
|
||||
elem = type('Element', (), {
|
||||
'box': [8, 1, 2.15, 1]
|
||||
})()
|
||||
issues = validator.validate_element(elem, 1, 1)
|
||||
assert len(issues) == 1
|
||||
|
||||
def test_validate_table_within_bounds(self):
|
||||
"""测试表格在边界内"""
|
||||
validator = GeometryValidator(10, 5.625)
|
||||
elem = type('Element', (), {
|
||||
'position': [1, 1],
|
||||
'col_widths': [2, 2, 2]
|
||||
})()
|
||||
issues = validator.validate_element(elem, 1, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_validate_table_exceeds_width(self):
|
||||
"""测试表格超出页面宽度"""
|
||||
validator = GeometryValidator(10, 5.625)
|
||||
elem = type('Element', (), {
|
||||
'position': [1, 1],
|
||||
'col_widths': [3, 3, 4] # 总宽 10,位置 1,右边界 11
|
||||
})()
|
||||
issues = validator.validate_element(elem, 1, 1)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].code == "TABLE_OUT_OF_BOUNDS"
|
||||
|
||||
def test_validate_element_without_box(self):
|
||||
"""测试没有 box 属性的元素"""
|
||||
validator = GeometryValidator(10, 5.625)
|
||||
elem = type('Element', (), {})() # 没有 box 属性
|
||||
issues = validator.validate_element(elem, 1, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_validate_table_without_col_widths(self):
|
||||
"""测试没有 col_widths 的表格"""
|
||||
validator = GeometryValidator(10, 5.625)
|
||||
elem = type('Element', (), {
|
||||
'position': [1, 1]
|
||||
})()
|
||||
issues = validator.validate_element(elem, 1, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
|
||||
class TestToleranceConstant:
|
||||
"""容忍度常量测试"""
|
||||
|
||||
def test_tolerance_value(self):
|
||||
"""测试容忍度值为 0.1"""
|
||||
assert TOLERANCE == 0.1
|
||||
239
tests/unit/test_validators/test_resource.py
Normal file
239
tests/unit/test_validators/test_resource.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
资源验证器单元测试
|
||||
|
||||
测试 ResourceValidator 的图片和模板文件验证功能
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from validators.resource import ResourceValidator
|
||||
|
||||
|
||||
class TestResourceValidator:
|
||||
"""ResourceValidator 测试类"""
|
||||
|
||||
def test_init(self, temp_dir):
|
||||
"""测试初始化"""
|
||||
validator = ResourceValidator(yaml_dir=temp_dir)
|
||||
assert validator.yaml_dir == temp_dir
|
||||
assert validator.template_dir is None
|
||||
|
||||
def test_init_with_template_dir(self, temp_dir):
|
||||
"""测试带模板目录初始化"""
|
||||
template_dir = temp_dir / "templates"
|
||||
validator = ResourceValidator(yaml_dir=temp_dir, template_dir=template_dir)
|
||||
assert validator.template_dir == template_dir
|
||||
|
||||
|
||||
class TestValidateImage:
|
||||
"""validate_image 方法测试"""
|
||||
|
||||
def test_existing_image(self, temp_dir, sample_image):
|
||||
"""测试存在的图片文件"""
|
||||
validator = ResourceValidator(yaml_dir=temp_dir)
|
||||
elem = type("Element", (), {"src": sample_image.name})()
|
||||
issues = validator.validate_image(elem, 1, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_nonexistent_image(self, temp_dir):
|
||||
"""测试不存在的图片文件"""
|
||||
validator = ResourceValidator(yaml_dir=temp_dir)
|
||||
elem = type("Element", (), {"src": "nonexistent.png"})()
|
||||
issues = validator.validate_image(elem, 1, 1)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "ERROR"
|
||||
assert issues[0].code == "IMAGE_FILE_NOT_FOUND"
|
||||
|
||||
def test_image_with_absolute_path(self, temp_dir, sample_image):
|
||||
"""测试绝对路径图片"""
|
||||
validator = ResourceValidator(yaml_dir=temp_dir)
|
||||
elem = type("Element", (), {"src": str(sample_image)})()
|
||||
issues = validator.validate_image(elem, 1, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_image_without_src_attribute(self, temp_dir):
|
||||
"""测试没有 src 属性的元素"""
|
||||
validator = ResourceValidator(yaml_dir=temp_dir)
|
||||
elem = type("Element", (), {})() # 没有 src 属性
|
||||
issues = validator.validate_image(elem, 1, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_image_with_empty_src(self, temp_dir):
|
||||
"""测试空 src 字符串"""
|
||||
validator = ResourceValidator(yaml_dir=temp_dir)
|
||||
elem = type("Element", (), {"src": ""})()
|
||||
issues = validator.validate_image(elem, 1, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
|
||||
class TestValidateTemplate:
|
||||
"""validate_template 方法测试"""
|
||||
|
||||
def test_slide_without_template(self, temp_dir):
|
||||
"""测试不使用模板的幻灯片"""
|
||||
validator = ResourceValidator(yaml_dir=temp_dir)
|
||||
slide_data = {} # 没有 template 字段
|
||||
issues = validator.validate_template(slide_data, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_template_with_dir_not_specified(self, temp_dir):
|
||||
"""测试使用模板但未指定模板目录"""
|
||||
validator = ResourceValidator(yaml_dir=temp_dir, template_dir=None)
|
||||
slide_data = {"template": "title-slide"}
|
||||
issues = validator.validate_template(slide_data, 1)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "ERROR"
|
||||
assert issues[0].code == "TEMPLATE_DIR_NOT_SPECIFIED"
|
||||
|
||||
def test_nonexistent_template_file(self, temp_dir):
|
||||
"""测试不存在的模板文件"""
|
||||
template_dir = temp_dir / "templates"
|
||||
template_dir.mkdir()
|
||||
validator = ResourceValidator(yaml_dir=temp_dir, template_dir=template_dir)
|
||||
slide_data = {"template": "nonexistent"}
|
||||
issues = validator.validate_template(slide_data, 1)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "ERROR"
|
||||
assert issues[0].code == "TEMPLATE_FILE_NOT_FOUND"
|
||||
|
||||
def test_valid_template_file(self, sample_template):
|
||||
"""测试有效的模板文件"""
|
||||
validator = ResourceValidator(
|
||||
yaml_dir=sample_template.parent, template_dir=sample_template
|
||||
)
|
||||
slide_data = {"template": "title-slide"}
|
||||
issues = validator.validate_template(slide_data, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_template_with_yaml_extension(self, temp_dir):
|
||||
"""测试带 .yaml 扩展名的模板"""
|
||||
template_dir = temp_dir / "templates"
|
||||
template_dir.mkdir()
|
||||
(template_dir / "test.yaml").write_text("vars: []\nelements: []")
|
||||
|
||||
validator = ResourceValidator(yaml_dir=temp_dir, template_dir=template_dir)
|
||||
slide_data = {"template": "test.yaml"}
|
||||
issues = validator.validate_template(slide_data, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_invalid_template_structure(self, temp_dir):
|
||||
"""测试无效的模板结构"""
|
||||
template_dir = temp_dir / "templates"
|
||||
template_dir.mkdir()
|
||||
# 创建缺少 elements 字段的无效模板
|
||||
(template_dir / "invalid.yaml").write_text("vars: []")
|
||||
|
||||
validator = ResourceValidator(yaml_dir=temp_dir, template_dir=template_dir)
|
||||
slide_data = {"template": "invalid"}
|
||||
issues = validator.validate_template(slide_data, 1)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "ERROR"
|
||||
assert issues[0].code == "TEMPLATE_STRUCTURE_ERROR"
|
||||
|
||||
|
||||
class TestValidateTemplateVars:
|
||||
"""validate_template_vars 方法测试"""
|
||||
|
||||
def test_slide_without_template(self, temp_dir):
|
||||
"""测试不使用模板的幻灯片"""
|
||||
validator = ResourceValidator(yaml_dir=temp_dir)
|
||||
slide_data = {}
|
||||
issues = validator.validate_template_vars(slide_data, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_template_with_dir_not_specified(self, temp_dir):
|
||||
"""测试使用模板但未指定模板目录"""
|
||||
validator = ResourceValidator(yaml_dir=temp_dir, template_dir=None)
|
||||
slide_data = {"template": "title-slide"}
|
||||
issues = validator.validate_template_vars(slide_data, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_provide_all_required_vars(self, sample_template):
|
||||
"""测试提供所有必需变量时验证通过"""
|
||||
validator = ResourceValidator(
|
||||
yaml_dir=sample_template.parent, template_dir=sample_template
|
||||
)
|
||||
slide_data = {
|
||||
"template": "title-slide",
|
||||
"vars": {"title": "Hello", "subtitle": "World"},
|
||||
}
|
||||
issues = validator.validate_template_vars(slide_data, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_missing_required_var(self, sample_template):
|
||||
"""测试缺少必需变量时验证失败"""
|
||||
validator = ResourceValidator(
|
||||
yaml_dir=sample_template.parent, template_dir=sample_template
|
||||
)
|
||||
slide_data = {"template": "title-slide", "vars": {"subtitle": "World"}}
|
||||
issues = validator.validate_template_vars(slide_data, 1)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "ERROR"
|
||||
assert issues[0].code == "MISSING_TEMPLATE_REQUIRED_VARS"
|
||||
assert "title" in issues[0].message
|
||||
|
||||
def test_multiple_required_vars_partial_missing(self, temp_dir):
|
||||
"""测试多个必需变量部分缺失"""
|
||||
template_dir = temp_dir / "templates"
|
||||
template_dir.mkdir()
|
||||
template_content = """vars:
|
||||
- name: title
|
||||
required: true
|
||||
- name: subtitle
|
||||
required: true
|
||||
- name: author
|
||||
required: false
|
||||
elements: []
|
||||
"""
|
||||
(template_dir / "multi-var.yaml").write_text(template_content)
|
||||
|
||||
validator = ResourceValidator(yaml_dir=temp_dir, template_dir=template_dir)
|
||||
slide_data = {"template": "multi-var", "vars": {"title": "Hello"}}
|
||||
issues = validator.validate_template_vars(slide_data, 1)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].level == "ERROR"
|
||||
assert "subtitle" in issues[0].message
|
||||
|
||||
def test_optional_var_missing(self, sample_template):
|
||||
"""测试可选变量缺失时验证通过"""
|
||||
validator = ResourceValidator(
|
||||
yaml_dir=sample_template.parent, template_dir=sample_template
|
||||
)
|
||||
slide_data = {"template": "title-slide", "vars": {"title": "Hello"}}
|
||||
issues = validator.validate_template_vars(slide_data, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_template_with_no_required_vars(self, temp_dir):
|
||||
"""测试模板没有必需变量时"""
|
||||
template_dir = temp_dir / "templates"
|
||||
template_dir.mkdir()
|
||||
template_content = """vars:
|
||||
- name: subtitle
|
||||
required: false
|
||||
elements: []
|
||||
"""
|
||||
(template_dir / "optional-only.yaml").write_text(template_content)
|
||||
|
||||
validator = ResourceValidator(yaml_dir=temp_dir, template_dir=template_dir)
|
||||
slide_data = {"template": "optional-only", "vars": {}}
|
||||
issues = validator.validate_template_vars(slide_data, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_nonexistent_template_file(self, temp_dir):
|
||||
"""测试模板文件不存在时不报错(由 validate_template 处理)"""
|
||||
template_dir = temp_dir / "templates"
|
||||
template_dir.mkdir()
|
||||
validator = ResourceValidator(yaml_dir=temp_dir, template_dir=template_dir)
|
||||
slide_data = {"template": "nonexistent"}
|
||||
issues = validator.validate_template_vars(slide_data, 1)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_error_location_contains_slide_number(self, sample_template):
|
||||
"""测试错误信息包含幻灯片位置"""
|
||||
validator = ResourceValidator(
|
||||
yaml_dir=sample_template.parent, template_dir=sample_template
|
||||
)
|
||||
slide_data = {"template": "title-slide", "vars": {}}
|
||||
issues = validator.validate_template_vars(slide_data, 5)
|
||||
assert len(issues) == 1
|
||||
assert "幻灯片 5" in issues[0].location
|
||||
155
tests/unit/test_validators/test_result.py
Normal file
155
tests/unit/test_validators/test_result.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
验证结果数据结构单元测试
|
||||
|
||||
测试 ValidationIssue 和 ValidationResult 类
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from validators.result import ValidationIssue, ValidationResult
|
||||
|
||||
|
||||
class TestValidationIssue:
|
||||
"""ValidationIssue 测试类"""
|
||||
|
||||
def test_create_error_issue(self):
|
||||
"""测试创建错误级别问题"""
|
||||
issue = ValidationIssue(
|
||||
level="ERROR",
|
||||
message="Test error",
|
||||
location="Slide 1",
|
||||
code="TEST_ERROR"
|
||||
)
|
||||
assert issue.level == "ERROR"
|
||||
assert issue.message == "Test error"
|
||||
assert issue.location == "Slide 1"
|
||||
assert issue.code == "TEST_ERROR"
|
||||
|
||||
def test_create_warning_issue(self):
|
||||
"""测试创建警告级别问题"""
|
||||
issue = ValidationIssue(
|
||||
level="WARNING",
|
||||
message="Test warning",
|
||||
location="",
|
||||
code="TEST_WARNING"
|
||||
)
|
||||
assert issue.level == "WARNING"
|
||||
|
||||
def test_create_info_issue(self):
|
||||
"""测试创建提示级别问题"""
|
||||
issue = ValidationIssue(
|
||||
level="INFO",
|
||||
message="Test info",
|
||||
location="",
|
||||
code="TEST_INFO"
|
||||
)
|
||||
assert issue.level == "INFO"
|
||||
|
||||
|
||||
class TestValidationResult:
|
||||
"""ValidationResult 测试类"""
|
||||
|
||||
def test_create_with_defaults(self):
|
||||
"""测试使用默认值创建"""
|
||||
result = ValidationResult(valid=True)
|
||||
assert result.valid is True
|
||||
assert result.errors == []
|
||||
assert result.warnings == []
|
||||
assert result.infos == []
|
||||
|
||||
def test_create_with_issues(self):
|
||||
"""测试创建包含问题的结果"""
|
||||
errors = [ValidationIssue("ERROR", "Error 1", "", "ERR1")]
|
||||
warnings = [ValidationIssue("WARNING", "Warning 1", "", "WARN1")]
|
||||
infos = [ValidationIssue("INFO", "Info 1", "", "INFO1")]
|
||||
|
||||
result = ValidationResult(
|
||||
valid=False,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
infos=infos
|
||||
)
|
||||
assert result.valid is False
|
||||
assert len(result.errors) == 1
|
||||
assert len(result.warnings) == 1
|
||||
assert len(result.infos) == 1
|
||||
|
||||
def test_has_errors(self):
|
||||
"""测试 has_errors 方法"""
|
||||
result = ValidationResult(
|
||||
valid=True,
|
||||
errors=[ValidationIssue("ERROR", "Error", "", "ERR")]
|
||||
)
|
||||
assert result.has_errors() is True
|
||||
|
||||
def test_has_errors_no_errors(self):
|
||||
"""测试没有错误时 has_errors 返回 False"""
|
||||
result = ValidationResult(valid=True)
|
||||
assert result.has_errors() is False
|
||||
|
||||
def test_format_output_clean(self):
|
||||
"""测试格式化输出 - 无问题"""
|
||||
result = ValidationResult(valid=True)
|
||||
output = result.format_output()
|
||||
assert "验证通过" in output
|
||||
|
||||
def test_format_output_with_errors(self):
|
||||
"""测试格式化输出 - 包含错误"""
|
||||
result = ValidationResult(
|
||||
valid=False,
|
||||
errors=[
|
||||
ValidationIssue("ERROR", "Error 1", "Slide 1", "ERR1"),
|
||||
ValidationIssue("ERROR", "Error 2", "Slide 2", "ERR2")
|
||||
]
|
||||
)
|
||||
output = result.format_output()
|
||||
assert "2 个错误" in output
|
||||
assert "Error 1" in output
|
||||
assert "Error 2" in output
|
||||
assert "[Slide 1]" in output
|
||||
|
||||
def test_format_output_with_warnings(self):
|
||||
"""测试格式化输出 - 包含警告"""
|
||||
result = ValidationResult(
|
||||
valid=True,
|
||||
warnings=[
|
||||
ValidationIssue("WARNING", "Warning 1", "Slide 1", "WARN1")
|
||||
]
|
||||
)
|
||||
output = result.format_output()
|
||||
assert "1 个警告" in output
|
||||
assert "Warning 1" in output
|
||||
|
||||
def test_format_output_with_infos(self):
|
||||
"""测试格式化输出 - 包含提示"""
|
||||
result = ValidationResult(
|
||||
valid=True,
|
||||
infos=[
|
||||
ValidationIssue("INFO", "Info 1", "", "INFO1")
|
||||
]
|
||||
)
|
||||
output = result.format_output()
|
||||
assert "1 个提示" in output
|
||||
|
||||
def test_format_output_mixed_issues(self):
|
||||
"""测试格式化输出 - 混合问题"""
|
||||
result = ValidationResult(
|
||||
valid=False,
|
||||
errors=[ValidationIssue("ERROR", "Error", "", "ERR")],
|
||||
warnings=[ValidationIssue("WARNING", "Warning", "", "WARN")],
|
||||
infos=[ValidationIssue("INFO", "Info", "", "INFO")]
|
||||
)
|
||||
output = result.format_output()
|
||||
assert "1 个错误" in output
|
||||
assert "1 个警告" in output
|
||||
assert "1 个提示" in output
|
||||
|
||||
def test_format_output_issue_without_location(self):
|
||||
"""测试没有位置信息的问题"""
|
||||
result = ValidationResult(
|
||||
valid=False,
|
||||
errors=[ValidationIssue("ERROR", "Error", "", "ERR")]
|
||||
)
|
||||
output = result.format_output()
|
||||
assert "Error" in output
|
||||
# 不应该有位置前缀
|
||||
assert "[]" not in output
|
||||
119
tests/unit/test_validators/test_validator.py
Normal file
119
tests/unit/test_validators/test_validator.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
主验证器单元测试
|
||||
|
||||
测试 Validator 类的协调和分类功能
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from validators.validator import Validator
|
||||
from validators.result import ValidationResult
|
||||
|
||||
|
||||
class TestValidator:
|
||||
"""Validator 测试类"""
|
||||
|
||||
def test_init(self):
|
||||
"""测试初始化"""
|
||||
validator = Validator()
|
||||
assert validator.SLIDE_SIZES == {
|
||||
"16:9": (10, 5.625),
|
||||
"4:3": (10, 7.5)
|
||||
}
|
||||
|
||||
def test_validate_valid_yaml(self, sample_yaml):
|
||||
"""测试验证有效的 YAML 文件"""
|
||||
validator = Validator()
|
||||
result = validator.validate(sample_yaml)
|
||||
assert isinstance(result, ValidationResult)
|
||||
assert result.valid is True
|
||||
|
||||
def test_validate_nonexistent_file(self, temp_dir):
|
||||
"""测试验证不存在的文件"""
|
||||
validator = Validator()
|
||||
nonexistent = temp_dir / "nonexistent.yaml"
|
||||
result = validator.validate(nonexistent)
|
||||
assert result.valid is False
|
||||
assert len(result.errors) > 0
|
||||
|
||||
def test_categorize_issues_error(self):
|
||||
"""测试分类问题为错误"""
|
||||
validator = Validator()
|
||||
from validators.result import ValidationIssue
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
infos = []
|
||||
|
||||
issue = ValidationIssue("ERROR", "Test", "", "ERR")
|
||||
validator._categorize_issues([issue], errors, warnings, infos)
|
||||
|
||||
assert len(errors) == 1
|
||||
assert len(warnings) == 0
|
||||
assert len(infos) == 0
|
||||
|
||||
def test_categorize_issues_warning(self):
|
||||
"""测试分类问题为警告"""
|
||||
validator = Validator()
|
||||
from validators.result import ValidationIssue
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
infos = []
|
||||
|
||||
issue = ValidationIssue("WARNING", "Test", "", "WARN")
|
||||
validator._categorize_issues([issue], errors, warnings, infos)
|
||||
|
||||
assert len(errors) == 0
|
||||
assert len(warnings) == 1
|
||||
assert len(infos) == 0
|
||||
|
||||
def test_categorize_issues_info(self):
|
||||
"""测试分类问题为提示"""
|
||||
validator = Validator()
|
||||
from validators.result import ValidationIssue
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
infos = []
|
||||
|
||||
issue = ValidationIssue("INFO", "Test", "", "INFO")
|
||||
validator._categorize_issues([issue], errors, warnings, infos)
|
||||
|
||||
assert len(errors) == 0
|
||||
assert len(warnings) == 0
|
||||
assert len(infos) == 1
|
||||
|
||||
def test_categorize_mixed_issues(self):
|
||||
"""测试分类混合问题"""
|
||||
validator = Validator()
|
||||
from validators.result import ValidationIssue
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
infos = []
|
||||
|
||||
issues = [
|
||||
ValidationIssue("ERROR", "Error", "", "ERR"),
|
||||
ValidationIssue("WARNING", "Warning", "", "WARN"),
|
||||
ValidationIssue("INFO", "Info", "", "INFO"),
|
||||
]
|
||||
validator._categorize_issues(issues, errors, warnings, infos)
|
||||
|
||||
assert len(errors) == 1
|
||||
assert len(warnings) == 1
|
||||
assert len(infos) == 1
|
||||
|
||||
|
||||
class TestSlideSizes:
|
||||
"""幻灯片尺寸常量测试"""
|
||||
|
||||
def test_16_9_size(self):
|
||||
"""测试 16:9 尺寸"""
|
||||
validator = Validator()
|
||||
assert validator.SLIDE_SIZES["16:9"] == (10, 5.625)
|
||||
|
||||
def test_4_3_size(self):
|
||||
"""测试 4:3 尺寸"""
|
||||
validator = Validator()
|
||||
assert validator.SLIDE_SIZES["4:3"] == (10, 7.5)
|
||||
@@ -38,7 +38,7 @@ class ResourceValidator:
|
||||
issues = []
|
||||
location = f"幻灯片 {slide_index}, 元素 {elem_index}"
|
||||
|
||||
if not hasattr(element, 'src'):
|
||||
if not hasattr(element, "src"):
|
||||
return issues
|
||||
|
||||
src = element.src
|
||||
@@ -52,12 +52,14 @@ class ResourceValidator:
|
||||
|
||||
# 检查文件是否存在
|
||||
if not src_path.exists():
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
level="ERROR",
|
||||
message=f"图片文件不存在: {src}",
|
||||
location=location,
|
||||
code="IMAGE_FILE_NOT_FOUND"
|
||||
))
|
||||
code="IMAGE_FILE_NOT_FOUND",
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
@@ -75,36 +77,40 @@ class ResourceValidator:
|
||||
issues = []
|
||||
location = f"幻灯片 {slide_index}"
|
||||
|
||||
if 'template' not in slide_data:
|
||||
if "template" not in slide_data:
|
||||
return issues
|
||||
|
||||
template_name = slide_data['template']
|
||||
template_name = slide_data["template"]
|
||||
if not template_name:
|
||||
return issues
|
||||
|
||||
# 检查是否提供了模板目录
|
||||
if not self.template_dir:
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
level="ERROR",
|
||||
message=f"使用了模板但未指定模板目录: {template_name}",
|
||||
location=location,
|
||||
code="TEMPLATE_DIR_NOT_SPECIFIED"
|
||||
))
|
||||
code="TEMPLATE_DIR_NOT_SPECIFIED",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
# 解析模板文件路径
|
||||
template_path = self.template_dir / template_name
|
||||
if not template_path.suffix:
|
||||
template_path = template_path.with_suffix('.yaml')
|
||||
template_path = template_path.with_suffix(".yaml")
|
||||
|
||||
# 检查模板文件是否存在
|
||||
if not template_path.exists():
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
level="ERROR",
|
||||
message=f"模板文件不存在: {template_name}",
|
||||
location=location,
|
||||
code="TEMPLATE_FILE_NOT_FOUND"
|
||||
))
|
||||
code="TEMPLATE_FILE_NOT_FOUND",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
# 验证模板文件结构
|
||||
@@ -112,11 +118,71 @@ class ResourceValidator:
|
||||
template_data = load_yaml_file(template_path)
|
||||
validate_template_yaml(template_data, str(template_path))
|
||||
except Exception as e:
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
level="ERROR",
|
||||
message=f"模板文件结构错误: {template_name} - {str(e)}",
|
||||
location=location,
|
||||
code="TEMPLATE_STRUCTURE_ERROR"
|
||||
))
|
||||
code="TEMPLATE_STRUCTURE_ERROR",
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
def validate_template_vars(self, slide_data: dict, slide_index: int) -> list:
|
||||
"""
|
||||
验证模板变量是否满足要求
|
||||
|
||||
Args:
|
||||
slide_data: 幻灯片数据字典
|
||||
slide_index: 幻灯片索引(从 1 开始)
|
||||
|
||||
Returns:
|
||||
验证问题列表
|
||||
"""
|
||||
issues = []
|
||||
location = f"幻灯片 {slide_index}"
|
||||
|
||||
if "template" not in slide_data:
|
||||
return issues
|
||||
|
||||
template_name = slide_data["template"]
|
||||
if not template_name:
|
||||
return issues
|
||||
|
||||
if not self.template_dir:
|
||||
return issues
|
||||
|
||||
template_path = self.template_dir / template_name
|
||||
if not template_path.suffix:
|
||||
template_path = template_path.with_suffix(".yaml")
|
||||
|
||||
if not template_path.exists():
|
||||
return issues
|
||||
|
||||
try:
|
||||
template_data = load_yaml_file(template_path)
|
||||
validate_template_yaml(template_data, str(template_path))
|
||||
except Exception:
|
||||
return issues
|
||||
|
||||
template_vars = template_data.get("vars", [])
|
||||
required_vars = [v["name"] for v in template_vars if v.get("required", False)]
|
||||
|
||||
if not required_vars:
|
||||
return issues
|
||||
|
||||
user_vars = slide_data.get("vars", {})
|
||||
missing_vars = [v for v in required_vars if v not in user_vars]
|
||||
|
||||
if missing_vars:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
level="ERROR",
|
||||
message=f"缺少模板必需变量: {', '.join(missing_vars)}",
|
||||
location=location,
|
||||
code="MISSING_TEMPLATE_REQUIRED_VARS",
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import List
|
||||
@dataclass
|
||||
class ValidationIssue:
|
||||
"""验证问题"""
|
||||
|
||||
level: str # "ERROR" | "WARNING" | "INFO"
|
||||
message: str
|
||||
location: str # "幻灯片 2, 元素 3"
|
||||
@@ -20,6 +21,7 @@ class ValidationIssue:
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""验证结果"""
|
||||
|
||||
valid: bool # 是否有 ERROR
|
||||
errors: List[ValidationIssue] = field(default_factory=list)
|
||||
warnings: List[ValidationIssue] = field(default_factory=list)
|
||||
@@ -61,13 +63,15 @@ class ValidationResult:
|
||||
|
||||
# 总结
|
||||
if not self.errors and not self.warnings and not self.infos:
|
||||
lines.append("✅ 验证通过,未发现问题")
|
||||
lines.append("验证通过,未发现问题")
|
||||
else:
|
||||
summary_parts = []
|
||||
if self.errors:
|
||||
summary_parts.append(f"{len(self.errors)} 个错误")
|
||||
if self.warnings:
|
||||
summary_parts.append(f"{len(self.warnings)} 个警告")
|
||||
if self.infos:
|
||||
summary_parts.append(f"{len(self.infos)} 个提示")
|
||||
lines.append(f"检查完成: 发现 {', '.join(summary_parts)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -45,59 +45,61 @@ class Validator:
|
||||
data = load_yaml_file(yaml_path)
|
||||
validate_presentation_yaml(data, str(yaml_path))
|
||||
except YAMLError as e:
|
||||
errors.append(ValidationIssue(
|
||||
level="ERROR",
|
||||
message=str(e),
|
||||
location="",
|
||||
code="YAML_ERROR"
|
||||
))
|
||||
errors.append(
|
||||
ValidationIssue(
|
||||
level="ERROR", message=str(e), location="", code="YAML_ERROR"
|
||||
)
|
||||
)
|
||||
return ValidationResult(
|
||||
valid=False,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
infos=infos
|
||||
valid=False, errors=errors, warnings=warnings, infos=infos
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(ValidationIssue(
|
||||
errors.append(
|
||||
ValidationIssue(
|
||||
level="ERROR",
|
||||
message=f"加载 YAML 文件失败: {str(e)}",
|
||||
location="",
|
||||
code="YAML_LOAD_ERROR"
|
||||
))
|
||||
code="YAML_LOAD_ERROR",
|
||||
)
|
||||
)
|
||||
return ValidationResult(
|
||||
valid=False,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
infos=infos
|
||||
valid=False, errors=errors, warnings=warnings, infos=infos
|
||||
)
|
||||
|
||||
# 获取幻灯片尺寸
|
||||
size_str = data.get('metadata', {}).get('size', '16:9')
|
||||
size_str = data.get("metadata", {}).get("size", "16:9")
|
||||
slide_width, slide_height = self.SLIDE_SIZES.get(size_str, (10, 5.625))
|
||||
|
||||
# 初始化子验证器
|
||||
geometry_validator = GeometryValidator(slide_width, slide_height)
|
||||
resource_validator = ResourceValidator(
|
||||
yaml_dir=yaml_path.parent,
|
||||
template_dir=template_dir
|
||||
yaml_dir=yaml_path.parent, template_dir=template_dir
|
||||
)
|
||||
|
||||
# 2. 验证每个幻灯片
|
||||
slides = data.get('slides', [])
|
||||
slides = data.get("slides", [])
|
||||
for slide_index, slide_data in enumerate(slides, start=1):
|
||||
# 验证模板
|
||||
template_issues = resource_validator.validate_template(slide_data, slide_index)
|
||||
template_issues = resource_validator.validate_template(
|
||||
slide_data, slide_index
|
||||
)
|
||||
self._categorize_issues(template_issues, errors, warnings, infos)
|
||||
|
||||
# 验证模板变量
|
||||
template_var_issues = resource_validator.validate_template_vars(
|
||||
slide_data, slide_index
|
||||
)
|
||||
self._categorize_issues(template_var_issues, errors, warnings, infos)
|
||||
|
||||
# 验证元素
|
||||
elements = slide_data.get('elements', [])
|
||||
elements = slide_data.get("elements", [])
|
||||
for elem_index, elem_dict in enumerate(elements, start=1):
|
||||
# 3. 元素级验证
|
||||
try:
|
||||
element = create_element(elem_dict)
|
||||
|
||||
# 调用元素的 validate() 方法
|
||||
if hasattr(element, 'validate'):
|
||||
if hasattr(element, "validate"):
|
||||
elem_issues = element.validate()
|
||||
# 填充位置信息
|
||||
for issue in elem_issues:
|
||||
@@ -111,7 +113,7 @@ class Validator:
|
||||
self._categorize_issues(geom_issues, errors, warnings, infos)
|
||||
|
||||
# 5. 资源验证(图片)
|
||||
if elem_dict.get('type') == 'image':
|
||||
if elem_dict.get("type") == "image":
|
||||
img_issues = resource_validator.validate_image(
|
||||
element, slide_index, elem_index
|
||||
)
|
||||
@@ -119,29 +121,32 @@ class Validator:
|
||||
|
||||
except ValueError as e:
|
||||
# 元素创建失败(__post_init__ 中的验证)
|
||||
errors.append(ValidationIssue(
|
||||
errors.append(
|
||||
ValidationIssue(
|
||||
level="ERROR",
|
||||
message=str(e),
|
||||
location=f"幻灯片 {slide_index}, 元素 {elem_index}",
|
||||
code="ELEMENT_VALIDATION_ERROR"
|
||||
))
|
||||
code="ELEMENT_VALIDATION_ERROR",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(ValidationIssue(
|
||||
errors.append(
|
||||
ValidationIssue(
|
||||
level="ERROR",
|
||||
message=f"验证元素时出错: {str(e)}",
|
||||
location=f"幻灯片 {slide_index}, 元素 {elem_index}",
|
||||
code="ELEMENT_VALIDATION_ERROR"
|
||||
))
|
||||
code="ELEMENT_VALIDATION_ERROR",
|
||||
)
|
||||
)
|
||||
|
||||
# 返回验证结果
|
||||
return ValidationResult(
|
||||
valid=len(errors) == 0,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
infos=infos
|
||||
valid=len(errors) == 0, errors=errors, warnings=warnings, infos=infos
|
||||
)
|
||||
|
||||
def _categorize_issues(self, issues: list, errors: list, warnings: list, infos: list):
|
||||
def _categorize_issues(
|
||||
self, issues: list, errors: list, warnings: list, infos: list
|
||||
):
|
||||
"""
|
||||
将问题按级别分类
|
||||
|
||||
|
||||
10
yaml2pptx.py
10
yaml2pptx.py
@@ -1,14 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.8"
|
||||
# dependencies = [
|
||||
# "python-pptx",
|
||||
# "pyyaml",
|
||||
# "flask",
|
||||
# "watchdog",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
"""
|
||||
YAML to PPTX Converter
|
||||
将 YAML 格式的演示文稿源文件转换为 PPTX 文件
|
||||
|
||||
Reference in New Issue
Block a user