diff --git a/README.md b/README.md index c40d31d..98a0f73 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,44 @@ yaml2pptx 采用模块化架构,易于扩展: 依赖在 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/ # 测试数据 +``` + ## 🤝 贡献 欢迎贡献代码、报告问题或提出建议! diff --git a/README_DEV.md b/README_DEV.md index d4697fe..c9a6e03 100644 --- a/README_DEV.md +++ b/README_DEV.md @@ -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` 格式 + +**测试方法命名**:使用 `test_` 格式 + +```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,10 +628,11 @@ uv run yaml2pptx.py preview temp/test.yaml --no-browser ### 测试文件位置 -所有测试文件必须放在 `temp/` 目录下: -- `temp/*.yaml` - 测试用的 YAML 文件 -- `temp/*.pptx` - 生成的 PPTX 文件 -- `temp/templates/` - 测试用的模板文件 +- **自动化测试**:`tests/` 目录 +- **手动测试文件**:`temp/` 目录 + - `temp/*.yaml` - 手动测试用的 YAML 文件 + - `temp/*.pptx` - 生成的 PPTX 文件 + - `temp/templates/` - 手动测试用的模板文件 ## 常见问题 diff --git a/openspec/changes/archive/2026-03-02-add-comprehensive-tests/.openspec.yaml b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/.openspec.yaml new file mode 100644 index 0000000..fd79bfc --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-02 diff --git a/openspec/changes/archive/2026-03-02-add-comprehensive-tests/design.md b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/design.md new file mode 100644 index 0000000..c05b875 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/design.md @@ -0,0 +1,172 @@ +## Context + +yaml2pptx 项目目前没有测试代码。项目采用模块化架构,包含以下模块: +- core/: 核心领域模型(elements、presentation、template) +- loaders/: YAML 加载层 +- validators/: 验证层(geometry、resource、result、validator) +- renderers/: PPTX 和 HTML 渲染 +- preview/: Flask 预览服务器 + +约束条件: +1. 必须使用 pytest 框架 +2. PPTX 文件需要实际生成和验证(Level 2:文件结构、元素数量、内容匹配、位置范围) +3. Preview 测试不启动真实服务器 +4. 临时文件使用系统临时目录并自动清理 + +## Goals / Non-Goals + +**Goals:** +- 建立全面的测试体系,覆盖单元、集成、端到端三个层级 +- 确保核心功能的正确性和稳定性 +- 为未来重构提供安全网 +- 测试代码结构清晰、易于维护 + +**Non-Goals:** +- 性能测试 +- UI/视觉对比测试(像素级) +- 100% 代码覆盖率(追求合理覆盖而非完美数字) + +## Decisions + +### D1: 测试目录结构 + +采用三层测试结构: +``` +tests/ +├── unit/ # 单元测试 - 测试独立函数/类 +├── integration/ # 集成测试 - 测试模块间协作 +├── e2e/ # 端到端测试 - 测试完整用户场景 +└── fixtures/ # 测试数据 +``` + +**理由**:清晰的分层便于理解和维护,符合测试最佳实践。 + +### D2: Fixtures 策略 + +使用 pytest 的 conftest.py 集中管理共享 fixtures: +- `temp_dir`: 临时目录(使用 pytest 内置 tmp_path) +- `sample_yaml`: 最小可用 YAML 文件 +- `sample_image`: 测试图片(使用 Pillow 创建) +- `sample_template`: 测试模板文件和目录 +- `pptx_validator`: PPTX 验证工具实例 + +**理由**:集中管理避免重复,便于维护。 + +### D3: PPTX 验证方案 + +创建专门的 PptxFileValidator 工具类(Level 2 验证): +- 文件级:存在、可打开 +- 幻灯片级:数量、尺寸 +- 元素级:类型、数量、内容、位置(容差 0.1 英寸) + +**理由**:Level 2 平衡了覆盖度和复杂度,无需图像对比库。 + +### D4: Preview 测试方案 + +不启动真实 Flask 服务器,测试以下内容: +- `generate_preview_html()` 函数的 HTML 生成 +- `create_flask_app()` 的路由配置 +- `YAMLChangeHandler` 的文件变化检测(Mock watchdog) + +**理由**:避免异步服务器的复杂性,测试核心逻辑。 + +### D5: Mock 边界 + +**需要 Mock:** +- `webbrowser.open()`(避免打开浏览器) +- `watchdog.Observer`(避免文件监听) +- CLI 的 `sys.argv`(使用 CliRunner) + +**不 Mock:** +- `python-pptx`(实际生成和验证) +- 文件系统(使用临时目录) +- `yaml.safe_load()`(实际解析) + +**理由**:核心生成逻辑必须真实测试,外部依赖可以 Mock。 + +### D6: 测试数据组织 + +``` +tests/fixtures/ +├── yaml_samples/ +│ ├── minimal.yaml # 最小示例 +│ ├── full_features.yaml # 包含所有功能 +│ ├── edge_cases/ # 边界情况 +│ └── invalid/ # 无效样本(测试错误处理) +├── templates/ # 测试模板 +└── images/ # 测试图片 +``` + +**理由**:测试数据与代码分离,便于复用和维护。 + +### D7: 临时文件清理 + +方案:使用 pytest 内置 `tmp_path` fixture,自动管理临时目录。 + +**理由**:pytest 原生支持,无需手动清理代码。 + +## Risks / Trade-offs + +### Risk 1: PPTX 验证可能不够精确 +**描述**:Level 2 验证不检查像素级渲染效果。 + +**缓解措施**:手动验证关键用例的视觉效果,自动化测试覆盖结构正确性。 + +### Risk 2: 测试运行时间较长 +**描述**:实际生成 PPTX 文件会增加测试时间。 + +**缓解措施**:不考虑时间限制,优先保证测试覆盖度。 + +### Risk 3: 全局变量影响测试 +**描述**:preview/server.py 使用全局变量(app、change_queue 等)。 + +**缓解措施**:在测试中显式重置全局状态,或重构代码减少全局变量依赖。 + +### Risk 4: CLI 测试复杂性 +**描述**:yaml2pptx.py 是单体脚本,难以单独导入测试。 + +**缓解措施**:使用 `subprocess` 运行命令并检查输出/退出码。 + +## Migration Plan + +### 步骤 + +1. **配置测试环境** + - 更新 pyproject.toml 添加测试依赖 + - 创建 tests/conftest.py + +2. **实现测试基础设施** + - 创建 tests/conftest_pptx.py(PPTX 验证工具) + - 创建 tests/fixtures/ 目录和测试数据 + +3. **实现单元测试** + - test_elements.py + - test_template.py + - test_validators/*.py + - test_loaders/test_yaml_loader.py + - test_utils.py + +4. **实现集成测试** + - test_presentation.py + - test_rendering_flow.py + - test_validation_flow.py + +5. **实现端到端测试** + - test_convert_cmd.py + - test_check_cmd.py + - test_preview_cmd.py + +6. **更新文档** + - README.md 添加测试运行说明 + - README_DEV.md 添加测试开发指南 + +### 回滚策略 + +如果测试引入问题: +1. 测试代码位于独立目录,不影响生产代码 +2. 可以通过删除 tests/ 目录完全移除 +3. pyproject.toml 的测试依赖是可选的 + +## Open Questions + +无。所有技术决策已明确。 diff --git a/openspec/changes/archive/2026-03-02-add-comprehensive-tests/proposal.md b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/proposal.md new file mode 100644 index 0000000..a14d879 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/proposal.md @@ -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` 添加测试开发指南 diff --git a/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/e2e-tests/spec.md b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/e2e-tests/spec.md new file mode 100644 index 0000000..6d6c408 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/e2e-tests/spec.md @@ -0,0 +1,96 @@ +## ADDED Requirements + +### Requirement: Convert 命令端到端测试 +系统 SHALL 提供 convert 命令的端到端测试。 + +#### Scenario: 基本转换 +- **WHEN** 执行 `yaml2pptx.py convert input.yaml output.pptx` +- **THEN** 输出文件成功生成 +- **AND** 文件可以打开并包含正确内容 + +#### Scenario: 自动输出文件名 +- **WHEN** 执行 `yaml2pptx.py convert input.yaml`(不指定输出) +- **THEN** 使用输入文件名生成 .pptx 文件 + +#### Scenario: 使用模板 +- **WHEN** 执行转换时指定 --template-dir +- **THEN** 模板被正确加载和应用 + +#### Scenario: 跳过验证 +- **WHEN** 执行转换时指定 --skip-validation +- **THEN** 验证步骤被跳过 + +#### Scenario: 强制覆盖 +- **WHEN** 输出文件已存在并使用 --force +- **THEN** 现有文件被覆盖 + +#### Scenario: 文件已存在不强制 +- **WHEN** 输出文件已存在且不使用 --force +- **THEN** 程序提示文件已存在并退出 + +#### Scenario: 无效输入文件 +- **WHEN** 输入文件不存在 +- **THEN** 程序显示错误信息并退出 + +#### Scenario: 包含所有元素类型的转换 +- **WHEN** YAML 包含所有元素类型(文本、图片、形状、表格) +- **THEN** 生成的 PPTX 包含所有元素并正确渲染 + +### Requirement: Check 命令端到端测试 +系统 SHALL 提供 check 命令的端到端测试。 + +#### Scenario: 验证通过 +- **WHEN** 执行 `yaml2pptx.py check valid.yaml` +- **THEN** 显示验证通过消息 +- **AND** 退出码为 0 + +#### Scenario: 验证失败 +- **WHEN** 执行 `yaml2pptx.py check invalid.yaml` +- **THEN** 显示错误信息 +- **AND** 退出码非 0 + +#### Scenario: 验证包含警告 +- **WHEN** YAML 文件有问题但不阻止转换 +- **THEN** 显示警告信息 +- **AND** 验证标记为通过 + +#### Scenario: 使用模板验证 +- **WHEN** YAML 使用模板并指定 --template-dir +- **THEN** 模板文件也被验证 + +#### Scenario: 多错误报告 +- **WHEN** YAML 文件包含多个错误 +- **THEN** 所有错误被列出 +- **AND** 每个错误包含位置信息 + +### Requirement: Preview 命令端到端测试 +系统 SHALL 提供 preview 命令的端到端测试(不启动真实服务器)。 + +#### Scenario: HTML 生成 +- **WHEN** 调用 generate_preview_html() 并传入有效 YAML +- **THEN** 返回完整的 HTML 页面 +- **AND** HTML 包含所有幻灯片的渲染 + +#### Scenario: HTML 包含幻灯片元素 +- **WHEN** 生成的预览 HTML +- **THEN** 每张幻灯片包含正确的元素渲染 +- **AND** CSS 样式正确应用 + +#### Scenario: 错误处理 +- **WHEN** YAML 文件包含错误 +- **THEN** generate_preview_html() 返回错误页面 HTML +- **AND** 错误信息正确显示 + +#### Scenario: SSE 事件流路由 +- **WHEN** 访问 /events 路由 +- **THEN** 返回 text/event-stream 内容类型 + +#### Scenario: 文件变化处理 +- **WHEN** YAML 文件被修改 +- **THEN** YAMLChangeHandler 检测到变化 +- **AND** 将 'reload' 消息放入队列 + +#### Scenario: Flask 应用创建 +- **WHEN** 调用 create_flask_app() +- **THEN** 返回配置好的 Flask 应用 +- **AND** 包含 / 和 /events 路由 diff --git a/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/integration-tests/spec.md b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/integration-tests/spec.md new file mode 100644 index 0000000..e771110 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/integration-tests/spec.md @@ -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** 包含错误、警告、提示的分级显示 diff --git a/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/pptx-validation/spec.md b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/pptx-validation/spec.md new file mode 100644 index 0000000..45e8fc4 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/pptx-validation/spec.md @@ -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** 包含详细的失败信息 diff --git a/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/test-framework/spec.md b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/test-framework/spec.md new file mode 100644 index 0000000..3791b9e --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/test-framework/spec.md @@ -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 文件被删除 diff --git a/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/unit-tests/spec.md b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/unit-tests/spec.md new file mode 100644 index 0000000..5660eb0 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/specs/unit-tests/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-02-add-comprehensive-tests/tasks.md b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/tasks.md new file mode 100644 index 0000000..051950d --- /dev/null +++ b/openspec/changes/archive/2026-03-02-add-comprehensive-tests/tasks.md @@ -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 中添加测试覆盖率要求说明 diff --git a/openspec/config.yaml b/openspec/config.yaml index 948e2c2..1e549e2 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -4,7 +4,7 @@ context: | 本项目始终面向中文开发者,使用中文进行注释、交流等内容的处理,不考虑多语言; 本项目编写的python脚本和任何python命令都始终使用uv运行,需要执行临时命令使用uv run python -c "xxx"执行命令; 严禁直接使用主机环境的python直接执行脚本或命令,严禁在主机环境直接安装python依赖包; - 本项目编写的测试文件、临时文件必须放在temp目录下; + 本项目编写的非正式测试文件、临时文件必须放在temp目录下; 严禁污染主机环境的任何配置,如有需要,必须请求用户审核操作; 当前项目的面向用户的使用文档在README.md;当前项目的面向AI和开发者的开发规范文档在README_DEV.md;每次功能迭代都需要同步更新这两份说明文档; 所有的文档、日志、说明严禁使用emoji或其他特殊字符,保证字符显示的兼容性; diff --git a/pyproject.toml b/pyproject.toml index aed4b6b..4e53cbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,3 +8,14 @@ dependencies = [ "flask", "watchdog", ] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "pytest-mock", + "pillow", +] + +[tool.setuptools] +packages = ["core", "loaders", "validators", "renderers", "preview"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fbf9497 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +yaml2pptx 测试套件 +""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6105940 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,340 @@ +""" +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 + + +# ============= 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 diff --git a/tests/conftest_pptx.py b/tests/conftest_pptx.py new file mode 100644 index 0000000..29aefed --- /dev/null +++ b/tests/conftest_pptx.py @@ -0,0 +1,314 @@ +""" +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 shape.shape_type == MSO_SHAPE.TEXT_BOX: + counts["text_box"] += 1 + elif 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 + 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_boxes = [s for s in slide.shapes if s.shape_type == MSO_SHAPE.TEXT_BOX] + + 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() diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..7a5b5fa --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1,3 @@ +""" +端到端测试包 +""" diff --git a/tests/e2e/test_check_cmd.py b/tests/e2e/test_check_cmd.py new file mode 100644 index 0000000..3cd5367 --- /dev/null +++ b/tests/e2e/test_check_cmd.py @@ -0,0 +1,191 @@ +""" +Check 命令端到端测试 + +测试 yaml2pptx.py check 命令的验证功能 +""" + +import pytest +import subprocess +import sys +from pathlib import Path + + +class TestCheckCmd: + """check 命令测试类""" + + def run_check(self, *args): + """辅助函数:运行 check 命令""" + cmd = [sys.executable, "-m", "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 diff --git a/tests/e2e/test_convert_cmd.py b/tests/e2e/test_convert_cmd.py new file mode 100644 index 0000000..1eba12c --- /dev/null +++ b/tests/e2e/test_convert_cmd.py @@ -0,0 +1,205 @@ +""" +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 = [sys.executable, "-m", "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): + """测试自动生成输出文件名""" + # 在 temp_dir 中运行 + result = subprocess.run( + [sys.executable, "-m", "uv", "run", "python", + "yaml2pptx.py", "convert", str(sample_yaml)], + capture_output=True, + text=True, + cwd=temp_dir + ) + + assert result.returncode == 0 + # 应该生成与输入同名的 .pptx 文件 + expected_output = temp_dir / "test.pptx" + assert expected_output.exists() + + 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 diff --git a/tests/e2e/test_preview_cmd.py b/tests/e2e/test_preview_cmd.py new file mode 100644 index 0000000..edffb90 --- /dev/null +++ b/tests/e2e/test_preview_cmd.py @@ -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 "" in html + assert "" in html + assert "" 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 "