1
0

Compare commits

...

8 Commits

Author SHA1 Message Date
22614d6f55 fix: 修复 7 个失败测试和 1 个错误测试
- 修复 conftest_pptx.py 中元素类型检测:使用 has_text_frame 替代不存在的 MSO_SHAPE.TEXT_BOX
- 修复 test_presentation.py 中 3 个测试:使用对象属性访问替代字典访问
- 修复 unit/test_presentation.py 中路径比较问题
- 添加缺失的 mock_template_class fixture

测试通过率: 316/316 (100%)
代码覆盖率: 94%
2026-03-03 01:25:36 +08:00
e82a6a945e chore: 更新 OpenSpec 上下文配置 2026-03-03 01:00:36 +08:00
ef3fa6a06a feat: 添加模板变量验证功能
- 在 ResourceValidator 中添加 validate_template_vars 方法
- 在验证阶段检查用户是否提供了模板所需的必需变量
- 缺少必需变量时返回 ERROR 级别错误
- 添加 9 个单元测试用例验证功能
- 同步更新 OpenSpec 规格文档
2026-03-03 01:00:21 +08:00
e31a7e9bed chore: 归档测试修复变更 2026-03-03 00:42:46 +08:00
c73bd0fedd fix: 修复测试问题,提升测试通过率
修复内容:
- E2E测试命令执行方式:将 python -m uv run 改为 uv run
- HTML渲染器:添加 & 字符的HTML转义
- Presentation尺寸验证:添加尺寸值类型验证
- PPTX验证器:修复文本框检测兼容性
- 验证结果格式化:修复提示信息显示
- Mock配置:修复表格渲染等测试的Mock配置

测试结果:
- 修复前: 264 通过, 42 失败, 1 错误
- 修复后: 297 通过, 9 失败, 1 错误

剩余9个失败为待实现的功能增强(验证器模板变量验证)
2026-03-03 00:42:39 +08:00
f273cef195 fix: use quoted strings for size values in YAML to prevent time parsing
YAML parser interprets 16:9 as time format (16h 9m = 969s).
Using quoted strings "16:9" ensures correct string parsing.
2026-03-02 23:50:31 +08:00
ab2510a400 test: add comprehensive pytest test suite
Add complete test infrastructure for yaml2pptx project with 245+ tests
covering unit, integration, and end-to-end scenarios.

Test structure:
- Unit tests: elements, template system, validators, loaders, utils
- Integration tests: presentation and rendering flows
- E2E tests: CLI commands (convert, check, preview)

Key features:
- PptxFileValidator for Level 2 PPTX validation (file structure,
  element count, content matching, position tolerance)
- Comprehensive fixtures for test data consistency
- Mock-based testing for external dependencies
- Test images generated with PIL/Pillow
- Boundary case coverage for edge scenarios

Dependencies added:
- pytest, pytest-cov, pytest-mock
- pillow (for test image generation)

Documentation updated:
- README.md: test running instructions
- README_DEV.md: test development guide

Co-authored-by: OpenSpec change: add-comprehensive-tests
2026-03-02 23:11:34 +08:00
027a832c9a chore: migrate to uv pyproject.toml for dependency management
- Add pyproject.toml with project dependencies
- Remove inline script metadata from yaml2pptx.py
- Update openspec/config.yaml development guidelines
- Update README.md and README_DEV.md documentation
- Archive change: migrate-to-uv-package-management
2026-03-02 22:03:23 +08:00
84 changed files with 9149 additions and 143 deletions

View File

@@ -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/ # 测试数据
```
## 🤝 贡献

View File

@@ -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 文件
- **自动化测试**`tests/` 目录
- **手动测试文件**`temp/` 目录
- `temp/*.yaml` - 手动测试用的 YAML 文件
- `temp/*.pptx` - 生成的 PPTX 文件
- `temp/templates/` - 测试用的模板文件
- `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 而不是普通字典?

View File

@@ -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,
}

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-02

View File

@@ -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.pyPPTX 验证工具)
- 创建 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
无。所有技术决策已明确。

View File

@@ -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` 添加测试开发指南

View File

@@ -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 路由

View File

@@ -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** 包含错误、警告、提示的分级显示

View File

@@ -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** 包含详细的失败信息

View File

@@ -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 文件被删除

View File

@@ -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

View File

@@ -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 中添加测试覆盖率要求说明

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-02

View File

@@ -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.lockuv.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
无。本次变更技术路径明确,无需进一步决策。

View File

@@ -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: 可能需要同步更新开发规范说明

View File

@@ -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 文件。

View File

@@ -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 相关说明需要更新

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-02

View File

@@ -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 字段,不处理嵌套引用

View File

@@ -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 变更,仅内部验证逻辑增强

View File

@@ -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"

View File

@@ -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 命令验证功能

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-02

View 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**: 是否应该将测试辅助函数移至独立的工具模块?
- 当前保持现状,避免引入不必要的重构

View File

@@ -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 现有版本的兼容性

View File

@@ -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%

View File

@@ -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 测试验证整体功能未受影响

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-02

View File

@@ -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
- **风险**: 修改测试代码可能导致测试行为变化
- **缓解**: 逐一验证每个修复后的测试用例
- **风险**: 模板变量替换修复可能影响现有功能
- **缓解**: 运行所有相关测试确保功能正常

View File

@@ -0,0 +1,30 @@
## Why
项目测试套件当前存在42个失败的测试用例主要分为三类问题测试运行方式错误E2E测试使用`python -m uv run`导致命令执行失败、测试代码本身的缺陷缺失fixture、Mock对象配置不当以及项目代码中的真实bug模板变量替换不完整、验证结果格式化错误。这些失败会影响持续集成流程和代码质量保证必须立即修复。
## What Changes
- 修复E2E测试中的命令执行方式`python -m uv run python`改为正确的`uv run`调用方式
- 添加缺失的测试fixturemock_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.pyHTML转义

View File

@@ -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** 输出中应包含 `&amp;`
### Requirement: Presentation尺寸值验证
Presentation类必须验证尺寸值的类型防止YAML解析错误。
#### Scenario: 非字符串尺寸值
- **WHEN** YAML中 `size: 16:9`(无引号)
- **THEN** 抛出明确的错误信息
### Requirement: PPTX验证器兼容性
PPTX文件验证器必须兼容不同版本的python-pptx。
#### Scenario: 文本框检测
- **WHEN** 验证PPTX中的文本元素
- **THEN** 通过检查text_frame属性来判断是否是文本框

View File

@@ -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 记录修复结果

View File

@@ -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或其他特殊字符保证字符显示的兼容性
所有的需求都必须设计全面、合理、完善、有针对性的测试内容;

View 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
View 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"]

View File

@@ -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('<', '&lt;').replace('>', '&gt;')
content = (
elem.content.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
)
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('<', '&lt;').replace('>', '&gt;')
cell_content = str(cell).replace("<", "&lt;").replace(">", "&gt;")
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
View File

@@ -0,0 +1,3 @@
"""
yaml2pptx 测试套件
"""

355
tests/conftest.py Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
"""
端到端测试包
"""

188
tests/e2e/test_check_cmd.py Normal file
View 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

View 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

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

View 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

View 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

View 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

View File

@@ -0,0 +1,10 @@
metadata:
size: "16:9"
slides:
- elements:
- type: text
box: [1, 1, 8, 1]
content: ""
font:
size: 18

View 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"

View 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

View 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"

View 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

View File

@@ -0,0 +1,10 @@
metadata:
size: "16:9"
slides:
- elements:
- type: text
box: [1, 1, 8, 1]
content: "Unicode 测试: 你好世界 🌍"
font:
size: 24

View 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"

View 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

View 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"

View 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

View File

@@ -0,0 +1,8 @@
metadata:
size: "16:9"
# Missing 'slides' key
elements:
- type: text
box: [1, 1, 8, 1]
content: "No slides key"

View File

@@ -0,0 +1,8 @@
metadata:
size: "16:9"
slides:
- elements:
- type: text
box: [1, 1, 8, 1
content: "Missing closing bracket"

View 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

View File

@@ -0,0 +1,3 @@
"""
集成测试包
"""

View 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"

View 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

View 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
View 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

View File

@@ -0,0 +1,3 @@
"""
加载器测试包
"""

View 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")

View 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

View File

@@ -0,0 +1,3 @@
"""
渲染器测试包
"""

View 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 "&lt;" in html
assert "&gt;" 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 "&amp;" in html
assert "&lt;" in html
assert "&gt;" 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 "&lt;" 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

View 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
View 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
View 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

View File

@@ -0,0 +1,3 @@
"""
验证器测试包
"""

View 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

View 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

View 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

View 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)

1688
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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)

View File

@@ -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
):
"""
将问题按级别分类

View File

@@ -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 文件