diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6e37b456 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +## [2.1.0] - 2025-01-19 + +### 🎉 Added - 收藏管理重构 (Favorite Management Refactor) + +#### 🏗️ 核心架构改进 +- **三层分类体系**: + - `functionMode`: `basic | context | image` (必填) + - `optimizationMode`: `system | user` (basic模式) + - `imageSubMode`: `text2image | image2image` (image模式) + - **Category**: 主题分类 (学习研究、日常助手等) +- **元数据重组**: `originalContent` 和 `sourceHistoryId` 移至 `metadata` 对象 +- **TypeMapper 工具类**: 自动从历史记录类型推断功能模式 + +#### 🏷️ 独立标签库系统 +- **标签全生命周期管理**: 重命名、合并、删除、统计 +- **智能标签自动完成**: 基于使用频率的建议排序 +- **独立标签存储**: 支持零使用次数的标签 + +#### 📁 分类管理增强 +- **分类排序**: 支持上移/下移调整顺序 +- **使用统计**: 计算每个分类的收藏数量 +- **删除保护**: 有收藏的分类无法删除 +- **颜色标识**: 支持自定义分类颜色 + +#### 🎨 UI 组件重构 +- **SaveFavoriteDialog**: 统一的创建/编辑对话框,支持功能模式选择 +- **TagManager**: 完整的标签管理界面 +- **CategoryManager**: 分类管理界面,支持颜色选择和排序 +- **标签自动完成**: `useTagSuggestions` + `NAutoComplete` 集成 + +#### 🔄 向后兼容性 +- **数据迁移**: 自动检测和迁移旧数据 +- **渐进式迁移**: 保留现有分类,不强制迁移 + +### 💔 Breaking Changes +- **移除 `isPublic` 字段**: 单机应用中无意义的公开字段 +- **`FavoritePrompt` 接口变更**: `functionMode` 变为必填,`metadata` 结构重组 + +### 📝 Migration Guide +系统会自动检测旧数据并迁移,所有现有收藏保持不变,向后兼容。 + +### 🐛 Bug Fixes +- 修复导入导出数据完整性问题 +- 修复标签计数不准确问题 +- 修复E2E测试中遮罩层拦截点击问题 + +--- + +## [2.0.0] - 2025-01-XX + +### 🎉 Initial Release +- 基础收藏管理功能 +- 优化历史集成 +- 标签和分类基础支持 +- 导入导出功能 \ No newline at end of file diff --git a/docs/user/favorites.md b/docs/user/favorites.md new file mode 100644 index 00000000..e7b7d433 --- /dev/null +++ b/docs/user/favorites.md @@ -0,0 +1,201 @@ +# 收藏管理指南 + +## 📚 收藏管理功能概览 + +收藏管理是提示词优化器的核心功能,帮助您保存、组织和管理优化后的提示词。经过重构后,收藏管理提供了更强大的分类和标签系统。 + +## 🏷️ 三层分类体系 + +### 1. 功能模式 (Function Mode) - 一级分类 +- **基础模式 (Basic)**: 标准的提示词优化 +- **上下文模式 (Context)**: 需要上下文信息的优化 +- **图像模式 (Image)**: 图像相关的提示词优化 + +### 2. 优化模式 (Optimization Mode) - 二级分类 (基础模式) +- **系统优化 (System)**: 系统自动优化 +- **用户优化 (User)**: 用户自定义优化 + +### 3. 图像子模式 (Image Sub Mode) - 二级分类 (图像模式) +- **文本生成图像 (Text to Image)**: 从文本生成图像 +- **图像生成图像 (Image to Image)**: 基于图像生成新图像 + +### 4. 分类 (Category) - 主题分类 +- 学习研究 +- 日常助手 +- 工作效率 +- 创意写作 +- 编程开发 +- 自定义分类 + +## 🎯 使用指南 + +### 创建收藏 + +#### 方法1: 从优化历史创建 +1. 完成提示词优化后,点击 **收藏** 按钮 +2. 系统自动推断功能模式和相关设置 +3. 填写标题和标签 +4. 选择分类 +5. 点击 **保存** + +#### 方法2: 手动创建收藏 +1. 点击 **收藏管理** 按钮 +2. 点击 **创建收藏** 按钮 +3. 选择功能模式: + - 基础模式:选择优化模式 + - 图像模式:选择图像子模式 +4. 填写提示词内容 +5. 添加标签(支持自动完成) +6. 选择分类 +7. 点击 **保存** + +### 标签管理 + +#### 标签自动完成 +- 在标签输入框中输入文字 +- 系统会显示匹配的标签建议 +- 使用频率高的标签会优先显示 +- 按 Enter 键或点击建议添加标签 + +#### 标签管理器 +1. 在收藏管理器中点击 **标签管理** +2. **查看标签**:列表显示所有标签和使用次数 +3. **重命名标签**:点击重命名按钮,批量更新所有相关收藏 +4. **合并标签**:将多个标签合并为一个,自动去重 +5. **删除标签**:从所有收藏中移除指定标签 +6. **搜索标签**:使用搜索框快速找到目标标签 + +### 分类管理 + +#### 分类管理器 +1. 在收藏管理器中点击 **分类管理** +2. **创建分类**: + - 点击 **添加分类** + - 输入分类名称 + - 选择颜色标识 + - 添加描述(可选) +3. **编辑分类**:修改分类名称、颜色或描述 +4. **排序分类**:使用上移/下移按钮调整顺序 +5. **删除分类**: + - 系统会检查是否有收藏使用该分类 + - 有收藏的分类无法删除,防止数据丢失 + +### 搜索和过滤 + +#### 搜索功能 +- **关键词搜索**:在搜索框输入关键词,搜索标题和内容 +- **实时搜索**:输入时即时显示搜索结果 +- **搜索高亮**:匹配的文字会被高亮显示 + +#### 过滤功能 +- **按分类过滤**:下拉选择特定分类 +- **按标签过滤**:选择标签进行过滤 +- **组合过滤**:可以同时使用分类和标签过滤 +- **清除过滤**:点击清除按钮重置所有过滤条件 + +### 导入导出 + +#### 导出收藏 +1. 在收藏管理器中点击 **更多菜单** +2. 选择 **导出** 选项 +3. 系统会下载包含所有收藏、标签和分类的JSON文件 +4. 导出数据包含: + - 收藏内容 + - 标签信息 + - 分类设置 + - 使用统计 + +#### 导入收藏 +1. 在收藏管理器中点击 **更多菜单** +2. 选择 **导入** 选项 +3. 选择要导入的JSON文件 +4. 系统会: + - 自动处理ID冲突 + - 导入标签和分类 + - 迁移旧数据格式 + - 显示导入结果统计 + +## 🔧 高级功能 + +### 独立标签库 +- 标签可以独立于收藏存在 +- 支持预创建标签,为后续收藏做准备 +- 导入时保留零使用次数的标签 + +### 数据迁移 +- 系统自动检测旧版本数据 +- 自动添加缺失的功能模式字段 +- 保持向后兼容性 + +### 性能优化 +- 支持大量收藏(1000+)的快速搜索 +- 智能缓存统计信息 +- 批量操作原子性保证 + +## 📱 快捷操作 + +### 收藏管理器快捷键 +- **Ctrl/Cmd + F**: 聚焦搜索框 +- **Ctrl/Cmd + N**: 创建新收藏 +- **Escape**: 关闭对话框 + +### 标签输入快捷键 +- **Enter**: 添加当前输入的标签 +- **Tab**: 移动到下一个输入框 +- **↑/↓**: 在自动完成建议中导航 + +## 🎨 界面特性 + +### 响应式设计 +- 适配不同屏幕尺寸 +- 移动端友好的触控操作 +- 自适应布局 + +### 主题支持 +- 支持亮色/暗色主题 +- 自动跟随系统主题设置 +- 自定义颜色配置 + +### 无障碍访问 +- 完整的键盘导航支持 +- 屏幕阅读器兼容 +- 高对比度模式 + +## ⚠️ 注意事项 + +### 数据备份 +- 定期导出收藏数据作为备份 +- 重要建议创建多个副本 +- 使用云存储同步备份文件 + +### 性能建议 +- 收藏数量超过500个时,建议使用搜索和过滤功能 +- 定期清理不需要的标签和分类 +- 避免创建过多的重复标签 + +### 兼容性 +- 支持从旧版本自动升级 +- 导入的旧格式数据会自动转换 +- 跨平台数据完全兼容 + +## 🆘 常见问题 + +**Q: 为什么我的标签无法删除?** +A: 如果标签正在被收藏使用,需要先从所有收藏中移除该标签,或者使用标签管理器的删除功能。 + +**Q: 导入数据时会发生什么?** +A: 系统会自动处理ID冲突,保留现有数据,导入新数据,并自动合并标签和分类。 + +**Q: 如何批量编辑多个收藏?** +A: 目前支持批量标签操作(重命名、合并、删除),通过标签管理器可以实现。 + +**Q: 收藏数量有限制吗?** +A: 理论上没有限制,但建议保持在1000个以内以获得最佳性能。 + +## 📞 技术支持 + +如果在使用过程中遇到问题,请: +1. 查看本指南的相关章节 +2. 检查是否有数据备份 +3. 重启应用尝试解决 +4. 联系技术支持团队 \ No newline at end of file diff --git a/docs/user/quick-start.md b/docs/user/quick-start.md index 65316570..fd24c53a 100644 --- a/docs/user/quick-start.md +++ b/docs/user/quick-start.md @@ -22,8 +22,9 @@ ### 3. 查看和使用结果 1. 在右侧查看优化后的提示词 2. 阅读推理过程了解优化原理 -3. 点击 **复制** 按钮复制结果 -4. 如需改进,使用 **继续优化** 功能 +3. 点击 **收藏** 保存优质优化结果 +4. 点击 **复制** 按钮复制结果 +5. 如需改进,使用 **继续优化** 功能 ## 🎯 主要功能 @@ -33,6 +34,25 @@ - **迭代改进** - 可以多次优化直到满意 - **版本管理** - 保存和切换不同版本 +### 📚 收藏管理 🆕 +- **三层分类体系** - 功能模式、优化模式、主题分类 +- **智能标签系统** - 自动完成建议、批量管理 +- **强大搜索过滤** - 关键词搜索、分类过滤、标签过滤 +- **数据导入导出** - 完整备份和恢复功能 +- **详细使用指南** - [收藏管理完整指南](favorites.md) + +### 🏷️ 标签管理 🆕 +- **独立标签库** - 标签可独立存在,支持预创建 +- **标签自动完成** - 智能建议,按使用频率排序 +- **批量操作** - 重命名、合并、删除标签 +- **统计信息** - 实时显示标签使用次数 + +### 📁 分类管理 🆕 +- **自定义分类** - 创建主题分类,支持颜色标识 +- **分类排序** - 拖拽调整分类顺序 +- **使用统计** - 显示每个分类的使用次数 +- **删除保护** - 防止误删有收藏的分类 + ### 🤖 模型管理 - **多模型支持** - 支持OpenAI、Claude、Gemini等 - **连接测试** - 确保API配置正确 diff --git a/package.json b/package.json index 8cc4ac8a..9d8ffc1a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "dev:desktop:fresh": "npm-run-all clean pnpm-install dev:desktop", "dev:desktop:parallel": "concurrently -k -p \"[{name}]\" -n \"WEB,DESKTOP\" \"pnpm -F @prompt-optimizer/web dev\" \"pnpm -F @prompt-optimizer/desktop dev\"", "test": "pnpm -r test --run --passWithNoTests", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", "clean": "npm-run-all clean:dist clean:vite", "clean:dist": "rimraf packages/core/dist packages/ui/dist packages/web/dist packages/extension/dist packages/desktop/dist packages/desktop/web-dist", "clean:vite": "rimraf packages/core/node_modules/.vite packages/ui/node_modules/.vite packages/web/node_modules/.vite packages/extension/node_modules/.vite", @@ -46,6 +49,7 @@ }, "devDependencies": { "@intlify/unplugin-vue-i18n": "^6.0.3", + "@playwright/test": "^1.56.1", "concurrently": "^8.2.2", "cross-env": "^7.0.3", "electron": "^37.1.0", diff --git a/packages/core/tests/integration/data-compatibility.test.ts b/packages/core/tests/integration/data-compatibility.test.ts new file mode 100644 index 00000000..c96b3ad2 --- /dev/null +++ b/packages/core/tests/integration/data-compatibility.test.ts @@ -0,0 +1,330 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { FavoriteManager } from '../../src/services/favorite/manager'; +import type { IStorageProvider } from '../../src/services/storage/types'; + +/** + * 数据兼容性回归测试 + * + * 目的: 确保旧版本的收藏数据能够正常导入和使用 + * 场景: + * 1. 旧数据缺少 functionMode 字段 + * 2. 旧数据使用旧的 metadata 结构 + * 3. 旧数据缺少新增的可选字段 + */ +describe('数据兼容性回归测试', () => { + let manager: FavoriteManager; + let storage: Map; + + // 创建内存存储提供者 + const createMemoryStorage = (): IStorageProvider => { + storage = new Map(); + return { + async getItem(key: string): Promise { + return storage.get(key) || null; + }, + async setItem(key: string, value: string): Promise { + storage.set(key, value); + }, + async removeItem(key: string): Promise { + storage.delete(key); + }, + async clearAll(): Promise { + storage.clear(); + }, + async updateData(key: string, modifier: (currentValue: T | null) => T): Promise { + const currentStr = storage.get(key); + const currentValue = currentStr ? JSON.parse(currentStr) : null; + const updated = modifier(currentValue); + storage.set(key, JSON.stringify(updated)); + }, + async batchUpdate(operations: Array<{ + key: string; + operation: 'set' | 'remove'; + value?: string; + }>): Promise { + for (const op of operations) { + if (op.operation === 'set' && op.value) { + storage.set(op.key, op.value); + } else if (op.operation === 'remove') { + storage.delete(op.key); + } + } + } + }; + }; + + beforeEach(async () => { + manager = new FavoriteManager(createMemoryStorage()); + await manager.initialize(); + }); + + it('应该能够导入缺少 functionMode 的旧数据', async () => { + // 1. 创建旧格式的收藏数据(无 functionMode) + const oldData = { + favorites: [ + { + id: 'old-fav-001', + title: '旧版收藏1', + content: '这是没有 functionMode 的旧收藏', + tags: ['测试', '旧数据'], + category: undefined, + createdAt: new Date('2024-01-01').toISOString(), + updatedAt: new Date('2024-01-01').toISOString() + // 注意:没有 functionMode 字段 + }, + { + id: 'old-fav-002', + title: '旧版收藏2', + content: '另一个旧收藏', + tags: ['兼容性'], + category: undefined, + createdAt: new Date('2024-01-02').toISOString(), + updatedAt: new Date('2024-01-02').toISOString() + } + ], + categories: [], + tags: [] + }; + + // 2. 导入旧数据 + const result = await manager.importFavorites(JSON.stringify(oldData)); + + // 3. 验证导入成功 + expect(result.imported).toBe(2); + expect(result.skipped).toBe(0); + expect(result.errors.length).toBe(0); + + // 4. 验证数据已正确导入并设置了默认 functionMode + const allFavorites = await manager.getFavorites(); + expect(allFavorites.length).toBe(2); + + // 使用 title 查找,因为ID会被重新生成 + const fav1 = allFavorites.find(f => f.title === '旧版收藏1'); + expect(fav1).toBeDefined(); + expect(fav1!.functionMode).toBe('basic'); // 应该有默认值 + expect(fav1!.content).toBe('这是没有 functionMode 的旧收藏'); + expect(fav1!.tags).toEqual(['测试', '旧数据']); + + const fav2 = allFavorites.find(f => f.title === '旧版收藏2'); + expect(fav2).toBeDefined(); + expect(fav2!.functionMode).toBe('basic'); // 应该有默认值 + }); + + it('应该能够导入使用旧 metadata 结构的数据', async () => { + // 1. 创建使用旧 metadata 结构的数据 + const oldData = { + favorites: [ + { + id: 'old-meta-001', + title: '旧 metadata 结构', + content: '优化后的内容', + tags: ['测试'], + category: undefined, + // 旧结构:直接在顶层 + originalContent: '原始内容', + sourceHistoryId: 'hist-001', + functionMode: 'basic', // 有 functionMode + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [], + tags: [] + }; + + // 2. 导入 + const result = await manager.importFavorites(JSON.stringify(oldData)); + + // 3. 验证导入成功 + expect(result.imported).toBe(1); + + // 4. 验证数据正确导入 + const favorites = await manager.getFavorites(); + expect(favorites.length).toBeGreaterThan(0); + + const imported = favorites.find(f => f.title === '旧 metadata 结构'); + expect(imported).toBeDefined(); + expect(imported!.functionMode).toBe('basic'); + // metadata字段可能存在也可能不存在,取决于导入逻辑是否保留 + }); + + it('应该能够正常查询和搜索迁移后的旧数据', async () => { + // 1. 导入旧数据 + const oldData = { + favorites: [ + { + id: 'search-test-001', + title: '可搜索的旧收藏', + content: '这是一个可以被搜索到的内容', + tags: ['搜索', '测试'], + category: undefined, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [], + tags: [] + }; + + await manager.importFavorites(JSON.stringify(oldData)); + + // 2. 测试查询功能 + const allFavorites = await manager.getFavorites(); + expect(allFavorites.length).toBeGreaterThan(0); + + // 3. 测试搜索功能 + const searchResults = await manager.searchFavorites('可搜索'); + expect(searchResults.length).toBeGreaterThan(0); + expect(searchResults[0].title).toContain('可搜索'); + + // 4. 测试按标签过滤 + const tagResults = await manager.getFavorites({ tags: ['搜索'] }); + expect(tagResults.length).toBeGreaterThan(0); + }); + + it('应该能够更新迁移后的旧数据', async () => { + // 1. 导入旧数据 + const oldData = { + favorites: [ + { + title: '可更新的旧收藏', + content: '原始内容', + tags: ['测试'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [], + tags: [] + }; + + await manager.importFavorites(JSON.stringify(oldData)); + + // 2. 获取导入的收藏ID + const favorites = await manager.getFavorites(); + const imported = favorites.find(f => f.title === '可更新的旧收藏'); + expect(imported).toBeDefined(); + + // 3. 更新数据 + await manager.updateFavorite(imported!.id, { + title: '更新后的标题', + content: '更新后的内容' + }); + + // 4. 验证更新成功 + const updated = await manager.getFavorite(imported!.id); + expect(updated).toBeDefined(); + expect(updated!.title).toBe('更新后的标题'); + expect(updated!.content).toBe('更新后的内容'); + expect(updated!.functionMode).toBe('basic'); // functionMode 应该保持 + }); + + it('应该能够删除迁移后的旧数据', async () => { + // 1. 导入旧数据 + const oldData = { + favorites: [ + { + title: '可删除的旧收藏', + content: '这个收藏将被删除', + tags: ['测试'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [], + tags: [] + }; + + await manager.importFavorites(JSON.stringify(oldData)); + + // 2. 获取导入的收藏并验证存在 + let favorites = await manager.getFavorites(); + const imported = favorites.find(f => f.title === '可删除的旧收藏'); + expect(imported).toBeDefined(); + + // 3. 删除数据 + await manager.deleteFavorite(imported!.id); + + // 4. 验证删除成功 + favorites = await manager.getFavorites(); + expect(favorites.find(f => f.title === '可删除的旧收藏')).toBeUndefined(); + }); + + it('应该能够导出迁移后的数据并保持完整性', async () => { + // 1. 导入旧数据 + const oldData = { + favorites: [ + { + title: '导出测试收藏', + content: '这个数据将被导出', + tags: ['导出', '测试'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [], + tags: ['导出', '测试'] + }; + + await manager.importFavorites(JSON.stringify(oldData)); + + // 2. 导出数据 + const exported = await manager.exportFavorites(); + const exportedData = JSON.parse(exported); + + // 3. 验证导出的数据包含必要字段 + expect(exportedData.favorites).toBeDefined(); + expect(exportedData.favorites.length).toBeGreaterThan(0); + + const exportedFav = exportedData.favorites.find((f: any) => f.title === '导出测试收藏'); + expect(exportedFav).toBeDefined(); + expect(exportedFav.functionMode).toBe('basic'); // 应该有默认的 functionMode + expect(exportedFav.title).toBe('导出测试收藏'); + expect(exportedFav.tags).toEqual(['导出', '测试']); + }); + + it('应该能够处理混合新旧格式的数据', async () => { + // 1. 创建混合数据(一些有 functionMode,一些没有) + const mixedData = { + favorites: [ + { + title: '旧格式收藏', + content: '没有 functionMode', + tags: ['旧'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + // 无 functionMode + }, + { + title: '新格式收藏', + content: '有 functionMode', + tags: ['新'], + functionMode: 'context', + optimizationMode: 'user', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [], + tags: [] + }; + + // 2. 导入混合数据 + const result = await manager.importFavorites(JSON.stringify(mixedData)); + + // 3. 验证都导入成功 + expect(result.imported).toBe(2); + + // 4. 验证两种数据都正确处理 + const favorites = await manager.getFavorites(); + + const oldFav = favorites.find(f => f.title === '旧格式收藏'); + expect(oldFav).toBeDefined(); + expect(oldFav!.functionMode).toBe('basic'); // 旧数据应该有默认值 + + const newFav = favorites.find(f => f.title === '新格式收藏'); + expect(newFav).toBeDefined(); + expect(newFav!.functionMode).toBe('context'); // 新数据保持原值 + expect(newFav!.optimizationMode).toBe('user'); + }); +}); diff --git a/packages/core/tests/performance/favorites.perf.test.ts b/packages/core/tests/performance/favorites.perf.test.ts new file mode 100644 index 00000000..ff218652 --- /dev/null +++ b/packages/core/tests/performance/favorites.perf.test.ts @@ -0,0 +1,445 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { FavoriteManager } from '../../src/services/favorite/manager'; +import type { IStorageProvider } from '../../src/services/storage/types'; + +/** + * 性能回归测试 + * + * 目的: 确保性能没有明显下降 + * 基准: + * - 查询1000个收藏: < 100ms + * - 添加单个收藏: < 50ms + * - 搜索1000个收藏: < 200ms + * - 导出1000个收藏: < 500ms + */ +describe('性能回归测试', () => { + let manager: FavoriteManager; + let storage: Map; + + // 创建内存存储提供者 + const createMemoryStorage = (): IStorageProvider => { + storage = new Map(); + return { + async getItem(key: string): Promise { + return storage.get(key) || null; + }, + async setItem(key: string, value: string): Promise { + storage.set(key, value); + }, + async removeItem(key: string): Promise { + storage.delete(key); + }, + async clearAll(): Promise { + storage.clear(); + }, + async updateData(key: string, modifier: (currentValue: T | null) => T): Promise { + const currentStr = storage.get(key); + const currentValue = currentStr ? JSON.parse(currentStr) : null; + const updated = modifier(currentValue); + storage.set(key, JSON.stringify(updated)); + }, + async batchUpdate(operations: Array<{ + key: string; + operation: 'set' | 'remove'; + value?: string; + }>): Promise { + for (const op of operations) { + if (op.operation === 'set' && op.value) { + storage.set(op.key, op.value); + } else if (op.operation === 'remove') { + storage.delete(op.key); + } + } + } + }; + }; + + beforeEach(async () => { + manager = new FavoriteManager(createMemoryStorage()); + await manager.initialize(); + }); + + it('应该能在合理时间内添加单个收藏 (< 50ms)', async () => { + const startTime = performance.now(); + + await manager.addFavorite({ + title: '性能测试收藏', + content: '这是用于性能测试的收藏', + tags: ['性能', '测试'], + functionMode: 'basic', + optimizationMode: 'system' + }); + + const endTime = performance.now(); + const duration = endTime - startTime; + + // 应该在 50ms 内完成 + expect(duration).toBeLessThan(50); + }); + + it('应该能在合理时间内查询大量收藏 (< 100ms for 1000 items)', async () => { + // 1. 准备大量测试数据 + const favorites = Array.from({ length: 1000 }, (_, i) => ({ + title: `性能测试收藏 ${i}`, + content: `这是第 ${i} 个收藏的内容`, + tags: [`tag${i % 10}`, '性能测试'], + functionMode: 'basic' as const, + optimizationMode: 'system' as const + })); + + // 2. 批量添加(这个操作不计入性能测试) + for (const fav of favorites) { + await manager.addFavorite(fav); + } + + // 3. 测试查询性能 + const startTime = performance.now(); + + const result = await manager.getFavorites(); + + const endTime = performance.now(); + const duration = endTime - startTime; + + // 4. 验证查询成功 + expect(result.length).toBeGreaterThanOrEqual(1000); + + // 5. 验证性能 + // 查询1000个收藏应该在 100ms 内完成 + expect(duration).toBeLessThan(100); + }); + + it('应该能在合理时间内搜索大量收藏 (< 200ms for 1000 items)', async () => { + // 1. 准备测试数据 + const favorites = Array.from({ length: 1000 }, (_, i) => ({ + title: `搜索测试 ${i}`, + content: i % 10 === 0 ? '包含关键词的内容' : '普通内容', + tags: ['测试'], + functionMode: 'basic' as const, + optimizationMode: 'system' as const + })); + + for (const fav of favorites) { + await manager.addFavorite(fav); + } + + // 2. 测试搜索性能 + const startTime = performance.now(); + + const searchResults = await manager.searchFavorites('关键词'); + + const endTime = performance.now(); + const duration = endTime - startTime; + + // 3. 验证搜索结果 + expect(searchResults.length).toBeGreaterThan(0); + + // 4. 验证性能 + // 搜索应该在 200ms 内完成 + expect(duration).toBeLessThan(200); + }); + + it('应该能在合理时间内导出大量收藏 (< 500ms for 1000 items)', async () => { + // 1. 准备测试数据 + const favorites = Array.from({ length: 1000 }, (_, i) => ({ + title: `导出测试 ${i}`, + content: `导出测试内容 ${i}`, + tags: ['导出', `tag${i % 5}`], + functionMode: 'basic' as const, + optimizationMode: 'system' as const + })); + + for (const fav of favorites) { + await manager.addFavorite(fav); + } + + // 2. 测试导出性能 + const startTime = performance.now(); + + const exportData = await manager.exportFavorites(); + + const endTime = performance.now(); + const duration = endTime - startTime; + + // 3. 验证导出数据 + expect(exportData).toBeTruthy(); + const parsed = JSON.parse(exportData); + expect(parsed.favorites.length).toBeGreaterThanOrEqual(1000); + + // 4. 验证性能 + // 导出应该在 500ms 内完成 + expect(duration).toBeLessThan(500); + }); + + it('应该能在合理时间内导入大量收藏 (< 1000ms for 1000 items)', async () => { + // 1. 准备导入数据 + const importData = { + favorites: Array.from({ length: 1000 }, (_, i) => ({ + id: `import-${i}`, + title: `导入测试 ${i}`, + content: `导入内容 ${i}`, + tags: ['导入'], + functionMode: 'basic', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + })), + categories: [], + tags: [] + }; + + // 2. 测试导入性能 + const startTime = performance.now(); + + await manager.importFavorites(JSON.stringify(importData)); + + const endTime = performance.now(); + const duration = endTime - startTime; + + // 3. 验证导入成功 + const favorites = await manager.getFavorites(); + expect(favorites.length).toBeGreaterThanOrEqual(1000); + + // 4. 验证性能 + // 导入1000个收藏应该在 1000ms 内完成 + expect(duration).toBeLessThan(1000); + }); + + it('应该能在合理时间内按分类过滤 (< 100ms)', async () => { + // 1. 创建分类 + const categoryId = await manager.addCategory({ + name: '性能测试分类', + description: '用于性能测试', + color: '#FF5722' + }); + + // 2. 添加大量收藏到该分类 + for (let i = 0; i < 500; i++) { + await manager.addFavorite({ + title: `分类测试 ${i}`, + content: `内容 ${i}`, + tags: ['测试'], + category: categoryId, + functionMode: 'basic', + optimizationMode: 'system' + }); + } + + // 3. 测试按分类过滤的性能 + const startTime = performance.now(); + + const filtered = await manager.getFavorites({ categoryId }); + + const endTime = performance.now(); + const duration = endTime - startTime; + + // 4. 验证过滤结果 + expect(filtered.length).toBe(500); + + // 5. 验证性能 + expect(duration).toBeLessThan(100); + }); + + it('应该能在合理时间内按标签过滤 (< 100ms)', async () => { + // 1. 添加大量收藏,部分带特定标签 + for (let i = 0; i < 500; i++) { + await manager.addFavorite({ + title: `标签测试 ${i}`, + content: `内容 ${i}`, + tags: i % 2 === 0 ? ['性能标签', '测试'] : ['测试'], + functionMode: 'basic', + optimizationMode: 'system' + }); + } + + // 2. 测试按标签过滤的性能 + const startTime = performance.now(); + + const filtered = await manager.getFavorites({ tags: ['性能标签'] }); + + const endTime = performance.now(); + const duration = endTime - startTime; + + // 3. 验证过滤结果 + expect(filtered.length).toBeGreaterThan(0); + + // 4. 验证性能 + expect(duration).toBeLessThan(100); + }); + + it('应该能在合理时间内更新单个收藏 (< 50ms)', async () => { + // 1. 添加一个收藏 + const favoriteId = await manager.addFavorite({ + title: '待更新的收藏', + content: '原始内容', + tags: ['测试'], + functionMode: 'basic', + optimizationMode: 'system' + }); + + // 2. 测试更新性能 + const startTime = performance.now(); + + await manager.updateFavorite(favoriteId, { + title: '更新后的标题', + content: '更新后的内容' + }); + + const endTime = performance.now(); + const duration = endTime - startTime; + + // 3. 验证更新成功 + const updated = await manager.getFavorite(favoriteId); + expect(updated!.title).toBe('更新后的标题'); + + // 4. 验证性能 + expect(duration).toBeLessThan(50); + }); + + it('应该能在合理时间内删除单个收藏 (< 50ms)', async () => { + // 1. 添加一个收藏 + const favoriteId = await manager.addFavorite({ + title: '待删除的收藏', + content: '内容', + tags: ['测试'], + functionMode: 'basic', + optimizationMode: 'system' + }); + + // 2. 测试删除性能 + const startTime = performance.now(); + + await manager.deleteFavorite(favoriteId); + + const endTime = performance.now(); + const duration = endTime - startTime; + + // 3. 验证删除成功(getFavorite在找不到时会抛出错误) + const allFavorites = await manager.getFavorites(); + expect(allFavorites.find(f => f.id === favoriteId)).toBeUndefined(); + + // 4. 验证性能 + expect(duration).toBeLessThan(50); + }); + + it('应该能在合理时间内获取标签统计 (< 100ms for 1000 items)', async () => { + // 1. 添加大量收藏,包含各种标签 + for (let i = 0; i < 1000; i++) { + await manager.addFavorite({ + title: `标签统计测试 ${i}`, + content: `内容 ${i}`, + tags: [`tag${i % 20}`, '通用标签'], + functionMode: 'basic', + optimizationMode: 'system' + }); + } + + // 2. 测试标签统计性能 + const startTime = performance.now(); + + const tagStats = await manager.getAllTags(); + + const endTime = performance.now(); + const duration = endTime - startTime; + + // 3. 验证统计结果 + expect(tagStats.length).toBeGreaterThan(0); + + // 4. 验证性能 + expect(duration).toBeLessThan(100); + }); +}); + +/** + * 内存使用测试 + * 确保没有明显的内存泄漏 + */ +describe('内存使用测试', () => { + let manager: FavoriteManager; + let storage: Map; + + const createMemoryStorage = (): IStorageProvider => { + storage = new Map(); + return { + async getItem(key: string): Promise { + return storage.get(key) || null; + }, + async setItem(key: string, value: string): Promise { + storage.set(key, value); + }, + async removeItem(key: string): Promise { + storage.delete(key); + }, + async clearAll(): Promise { + storage.clear(); + }, + async updateData(key: string, modifier: (currentValue: T | null) => T): Promise { + const currentStr = storage.get(key); + const currentValue = currentStr ? JSON.parse(currentStr) : null; + const updated = modifier(currentValue); + storage.set(key, JSON.stringify(updated)); + }, + async batchUpdate(operations: Array<{ + key: string; + operation: 'set' | 'remove'; + value?: string; + }>): Promise { + for (const op of operations) { + if (op.operation === 'set' && op.value) { + storage.set(op.key, op.value); + } else if (op.operation === 'remove') { + storage.delete(op.key); + } + } + } + }; + }; + + beforeEach(async () => { + manager = new FavoriteManager(createMemoryStorage()); + await manager.initialize(); + }); + + it('重复添加和删除不应导致内存泄漏', async () => { + // 1. 记录初始状态 + const initialSize = storage.size; + + // 2. 重复添加和删除 + for (let i = 0; i < 100; i++) { + const id = await manager.addFavorite({ + title: `临时收藏 ${i}`, + content: '临时内容', + tags: ['临时'], + functionMode: 'basic', + optimizationMode: 'system' + }); + + await manager.deleteFavorite(id); + } + + // 3. 验证存储大小没有显著增长 + const finalSize = storage.size; + + // 存储大小应该基本相同或略有增加(因为可能有缓存) + expect(finalSize - initialSize).toBeLessThan(5); + }); + + it('大量数据操作后存储应该合理', async () => { + // 1. 添加1000个收藏 + for (let i = 0; i < 1000; i++) { + await manager.addFavorite({ + title: `收藏 ${i}`, + content: `内容 ${i}`, + tags: ['测试'], + functionMode: 'basic', + optimizationMode: 'system' + }); + } + + // 2. 导出数据检查大小 + const exported = await manager.exportFavorites(); + const exportedSize = exported.length; + + // 3. 存储的数据不应过分膨胀 + // 1000个简单收藏的JSON字符串应该在合理范围内(比如 < 1MB) + expect(exportedSize).toBeLessThan(1024 * 1024); // < 1MB + }); +}); diff --git a/packages/ui/src/components/CategoryManager.vue b/packages/ui/src/components/CategoryManager.vue index 02410a59..64081f53 100644 --- a/packages/ui/src/components/CategoryManager.vue +++ b/packages/ui/src/components/CategoryManager.vue @@ -137,7 +137,6 @@ import { NInput, NTreeSelect, NColorPicker, - NTag, NDropdown, type TreeOption, type FormInst, @@ -509,7 +508,6 @@ onMounted(() => { }); // 监听服务初始化 -import { watch } from 'vue'; watch(() => services?.value?.favoriteManager, (favoriteManager) => { if (favoriteManager) { loadCategories(); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..93458aa2 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,73 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright E2E 测试配置 + * 用于测试 Web 应用的完整用户流程 + */ + +// E2E 测试专用端口,避免与开发服务器冲突 +const E2E_PORT = process.env.E2E_PORT || 15555; +const BASE_URL = `http://localhost:${E2E_PORT}`; + +export default defineConfig({ + // 测试目录 + testDir: './tests/e2e', + + // 完全并行运行测试 + fullyParallel: true, + + // CI 环境下失败时不重试,本地开发时重试一次 + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + + // CI 环境下使用更少的 worker + workers: process.env.CI ? 1 : undefined, + + // 测试报告配置 + reporter: [ + ['html'], + ['list'] + ], + + // 共享设置 + use: { + // 基础 URL + baseURL: BASE_URL, + + // 收集失败测试的 trace + trace: 'on-first-retry', + + // 截图配置 + screenshot: 'only-on-failure', + + // 视频配置 + video: 'retain-on-failure', + }, + + // 项目配置 - 不同浏览器 + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // 如果需要测试其他浏览器,可以取消注释 + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + ], + + // 自动启动 E2E 测试专用开发服务器 + webServer: { + command: `pnpm -F @prompt-optimizer/web dev --port ${E2E_PORT}`, + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46488066..653ff317 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@intlify/unplugin-vue-i18n': specifier: ^6.0.3 version: 6.0.8(@vue/compiler-dom@3.5.17)(eslint@9.30.1(jiti@1.21.7))(rollup@4.44.1)(typescript@5.8.3)(vue-i18n@10.0.7(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3)) + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -965,6 +968,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.56.1': + resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} + engines: {node: '>=18'} + hasBin: true + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -1723,7 +1731,7 @@ packages: hasBin: true buffer-crc32@0.2.13: - resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=} + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -1936,7 +1944,7 @@ packages: engines: {node: '>=12.13'} core-util-is@1.0.2: - resolution: {integrity: sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=} + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -2529,6 +2537,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2768,7 +2781,7 @@ packages: engines: {node: '>=8'} imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + resolution: {integrity: sha1-khi5srkoojixPcT7a21XbyMUU+o=} engines: {node: '>=0.8.19'} inflight@1.0.6: @@ -3105,7 +3118,7 @@ packages: deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash.isplainobject@4.0.6: - resolution: {integrity: sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=} + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3578,6 +3591,16 @@ packages: pkg-types@2.2.0: resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==} + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -3764,7 +3787,7 @@ packages: engines: {node: '>=0.10.0'} requires-port@1.0.0: - resolution: {integrity: sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=} + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -5320,6 +5343,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.56.1': + dependencies: + playwright: 1.56.1 + '@popperjs/core@2.11.8': {} '@rollup/pluginutils@5.2.0(rollup@4.44.1)': @@ -7355,6 +7382,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -8445,6 +8475,14 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.10 diff --git a/tests/e2e/category-management.spec.ts b/tests/e2e/category-management.spec.ts new file mode 100644 index 00000000..b953bc1d --- /dev/null +++ b/tests/e2e/category-management.spec.ts @@ -0,0 +1,521 @@ +import { test, expect } from '@playwright/test'; + +/** + * 分类管理完整 CRUD 流程 E2E 测试 + * + * 测试分类管理器的完整功能: + * - 创建分类(含颜色选择) + * - 编辑分类 + * - 分类排序(上移/下移) + * - 删除分类(带使用保护) + */ +test.describe('分类管理完整流程', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + /** + * 辅助函数:等待所有模态对话框完全关闭 + */ + async function waitForModalClose(page: any) { + // 尝试多种方法关闭现有对话框: + // 1. 尝试按Esc键关闭 + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + + // 2. 尝试点击遮罩层关闭(如果点击遮罩层可以关闭的话) + const mask = page.locator('.n-modal-mask').first(); + if (await mask.count() > 0 && await mask.isVisible()) { + await mask.click({ timeout: 1000 }).catch(() => {}); + await page.waitForTimeout(300); + } + + // 3. 尝试点击所有关闭按钮 + const closeButtons = page.locator('[aria-label="close"], .n-base-close, button:has-text("关闭"), button:has-text("关闭")'); + const buttonCount = await closeButtons.count(); + for (let i = 0; i < Math.min(buttonCount, 3); i++) { // 最多尝试关闭3个对话框 + try { + await closeButtons.nth(i).click({ timeout: 1000 }); + await page.waitForTimeout(300); + } catch (e) { + // 忽略点击失败 + } + } + + // 4. 最后等待所有遮罩层消失 + await page.waitForSelector('.n-modal-mask', { state: 'hidden', timeout: 3000 }).catch(() => {}); + } + + /** + * 辅助函数:打开分类管理器 + */ + async function openCategoryManager(page: any) { + // 等待任何现有对话框完全关闭 + await waitForModalClose(page); + + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + return null; + } + await favoriteButton.first().click(); + await page.waitForTimeout(500); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 2. 打开更多菜单 + const moreButton = managerDialog.getByRole('button').filter({ + has: page.locator('svg, .n-icon') + }).first(); + + if (await moreButton.count() === 0) { + return null; + } + + await moreButton.click(); + await page.waitForTimeout(300); + + // 3. 点击分类管理选项 + const categoryManagerOption = page.locator('text=/分类管理|Category Management/i'); + if (await categoryManagerOption.count() === 0) { + return null; + } + + await categoryManagerOption.click(); + await page.waitForTimeout(500); + + // 4. 返回分类管理器对话框 + const categoryDialog = page.locator('[role="dialog"]').filter({ hasText: /分类|Category/i }).last(); + if (await categoryDialog.isVisible().catch(() => false)) { + return categoryDialog; + } + + return null; + } + + test('分类创建功能(含颜色选择)', async ({ page }) => { + const categoryDialog = await openCategoryManager(page); + if (!categoryDialog) { + test.skip(); + return; + } + + // 查找添加分类按钮 + const addButton = categoryDialog.getByRole('button', { name: /添加|新建|创建|add|create/i }); + if (await addButton.count() === 0) { + test.skip(); + return; + } + + await addButton.click(); + await page.waitForTimeout(300); + + // 在弹出的对话框中填写分类信息 + const createDialog = page.locator('[role="dialog"]').last(); + + // 填写分类名称 + const nameInput = createDialog.locator('input[type="text"]').first(); + if (await nameInput.count() > 0) { + await nameInput.fill('测试分类'); + + // 填写描述(如果有) + const descInput = createDialog.locator('textarea, input').filter({ + hasText: /描述|description/i + }).or(createDialog.locator('textarea')).first(); + + if (await descInput.count() > 0) { + await descInput.fill('这是一个用于测试的分类'); + } + + // 选择颜色(如果有颜色选择器) + const colorPicker = createDialog.locator('.n-color-picker, [class*="color"]'); + if (await colorPicker.count() > 0) { + await colorPicker.first().click(); + await page.waitForTimeout(300); + + // 选择一个颜色(点击颜色面板中的某个位置) + const colorPanel = page.locator('.n-color-picker-panel, .n-popover'); + if (await colorPanel.isVisible().catch(() => false)) { + // 点击预设颜色或颜色面板 + const presetColor = colorPanel.locator('.n-color-picker-swatch, [class*="swatch"]').first(); + if (await presetColor.count() > 0) { + await presetColor.click(); + await page.waitForTimeout(200); + } + } + } + + // 确认创建 + const confirmButton = createDialog.getByRole('button', { name: /确定|确认|保存|ok|save/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(500); + + // 验证分类已创建 + const categoryRow = categoryDialog.locator('tr, .n-list-item, [class*="category"]').filter({ + hasText: '测试分类' + }); + + if (await categoryRow.count() > 0) { + await expect(categoryRow.first()).toBeVisible(); + } + } + } + }); + + test('分类编辑功能', async ({ page }) => { + const categoryDialog = await openCategoryManager(page); + if (!categoryDialog) { + test.skip(); + return; + } + + // 先创建一个分类 + const addButton = categoryDialog.getByRole('button', { name: /添加|新建|创建|add|create/i }); + if (await addButton.count() > 0) { + await addButton.click(); + await page.waitForTimeout(300); + + const createDialog = page.locator('[role="dialog"]').last(); + const nameInput = createDialog.locator('input[type="text"]').first(); + + if (await nameInput.count() > 0) { + await nameInput.fill('待编辑分类'); + + const confirmButton = createDialog.getByRole('button', { name: /确定|确认|保存|ok|save/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(500); + } + } + } + + // 查找包含"待编辑分类"的行 + const categoryRow = categoryDialog.locator('tr, .n-list-item, [class*="category"]').filter({ + hasText: '待编辑分类' + }); + + if (await categoryRow.count() === 0) { + // 分类可能没有正确创建,跳过测试 + return; + } + + // 查找编辑按钮 + const editButton = categoryRow.locator('button').filter({ + hasText: /编辑|edit/i + }).first(); + + if (await editButton.count() > 0) { + await editButton.click(); + await page.waitForTimeout(300); + + // 在编辑对话框中修改名称 + const editDialog = page.locator('[role="dialog"]').last(); + const nameInput = editDialog.locator('input[type="text"]').first(); + + if (await nameInput.count() > 0) { + await nameInput.clear(); + await nameInput.fill('已编辑分类'); + + // 确认编辑 + const confirmButton = editDialog.getByRole('button', { name: /确定|确认|保存|ok|save/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(500); + + // 验证新名称出现 + const updatedRow = categoryDialog.locator('tr, .n-list-item').filter({ + hasText: '已编辑分类' + }); + + if (await updatedRow.count() > 0) { + await expect(updatedRow.first()).toBeVisible(); + } + } + } + } + }); + + test('分类排序功能(上移/下移)', async ({ page }) => { + const categoryDialog = await openCategoryManager(page); + if (!categoryDialog) { + test.skip(); + return; + } + + // 创建两个分类用于排序测试 + const categoriesToCreate = ['排序测试A', '排序测试B']; + + for (const categoryName of categoriesToCreate) { + const addButton = categoryDialog.getByRole('button', { name: /添加|新建|创建|add|create/i }); + if (await addButton.count() > 0) { + await addButton.click(); + await page.waitForTimeout(300); + + const createDialog = page.locator('[role="dialog"]').last(); + const nameInput = createDialog.locator('input[type="text"]').first(); + + if (await nameInput.count() > 0) { + await nameInput.fill(categoryName); + + const confirmButton = createDialog.getByRole('button', { name: /确定|确认|保存|ok|save/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(500); + } + } + } + } + + // 查找"排序测试B"的行 + const categoryRow = categoryDialog.locator('tr, .n-list-item').filter({ + hasText: '排序测试B' + }); + + if (await categoryRow.count() > 0) { + // 查找上移按钮 + const moveUpButton = categoryRow.locator('button').filter({ + hasText: /上移|move up|↑/i + }).or(categoryRow.locator('button[aria-label*="up"]')).first(); + + if (await moveUpButton.count() > 0) { + await moveUpButton.click(); + await page.waitForTimeout(500); + + // 验证顺序已改变(这里只验证按钮可点击,实际顺序验证较复杂) + // 在实际应用中,可以通过检查所有行的顺序来验证 + const allRows = categoryDialog.locator('tr, .n-list-item').filter({ + hasText: /排序测试/ + }); + + expect(await allRows.count()).toBeGreaterThanOrEqual(2); + } + } + }); + + test('分类删除功能(空分类)', async ({ page }) => { + const categoryDialog = await openCategoryManager(page); + if (!categoryDialog) { + test.skip(); + return; + } + + // 创建一个空分类用于删除 + const addButton = categoryDialog.getByRole('button', { name: /添加|新建|创建|add|create/i }); + if (await addButton.count() > 0) { + await addButton.click(); + await page.waitForTimeout(300); + + const createDialog = page.locator('[role="dialog"]').last(); + const nameInput = createDialog.locator('input[type="text"]').first(); + + if (await nameInput.count() > 0) { + await nameInput.fill('待删除分类'); + + const confirmButton = createDialog.getByRole('button', { name: /确定|确认|保存|ok|save/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(500); + } + } + } + + // 查找包含"待删除分类"的行 + const categoryRow = categoryDialog.locator('tr, .n-list-item').filter({ + hasText: '待删除分类' + }); + + if (await categoryRow.count() > 0) { + // 查找删除按钮 + const deleteButton = categoryRow.locator('button').filter({ + hasText: /删除|delete/i + }).first(); + + if (await deleteButton.count() > 0) { + await deleteButton.click(); + await page.waitForTimeout(300); + + // 确认删除 + const confirmButton = page.getByRole('button', { name: /确定|确认|ok|confirm/i }).last(); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(500); + + // 验证分类已删除 + const deletedRow = categoryDialog.locator('tr, .n-list-item').filter({ + hasText: '待删除分类' + }); + expect(await deletedRow.count()).toBe(0); + } + } + } + }); + + test('分类删除保护(有收藏的分类)', async ({ page }) => { + // 等待任何遮罩层消失 + await page.waitForSelector('.n-modal-mask', { state: 'hidden', timeout: 2000 }).catch(() => {}); + + // 1. 打开分类管理器并创建分类 + const categoryDialog = await openCategoryManager(page); + if (!categoryDialog) { + test.skip(); + return; + } + + // 获取收藏管理器引用(openCategoryManager已经打开了) + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + + const addCategoryButton = categoryDialog.getByRole('button', { name: /添加|新建|创建|add|create/i }); + if (await addCategoryButton.count() > 0) { + await addCategoryButton.click(); + await page.waitForTimeout(300); + + const createDialog = page.locator('[role="dialog"]').last(); + const nameInput = createDialog.locator('input[type="text"]').first(); + + if (await nameInput.count() > 0) { + await nameInput.fill('有收藏的分类'); + + const confirmButton = createDialog.getByRole('button', { name: /确定|确认|保存|ok|save/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(500); + } + } + } + + // 关闭分类管理器 + const closeButton = categoryDialog.locator('[aria-label="close"], .n-base-close').first(); + if (await closeButton.count() > 0) { + await closeButton.click(); + await page.waitForTimeout(300); + } + + // 3. 创建一个属于该分类的收藏 + const addFavoriteButton = managerDialog.getByRole('button', { name: /添加|创建|新建|add|create/i }).first(); + await addFavoriteButton.click(); + await page.waitForTimeout(500); + + const createFavDialog = page.locator('[role="dialog"]').last(); + const titleInput = createFavDialog.getByPlaceholder(/标题|title/i); + + if (await titleInput.count() > 0) { + await titleInput.fill('属于分类的收藏'); + + const contentInput = createFavDialog.locator('textarea').first(); + if (await contentInput.count() > 0) { + await contentInput.fill('测试内容'); + } + + // 选择刚创建的分类 + const categorySelect = createFavDialog.locator('.n-base-selection, .n-select').first(); + if (await categorySelect.count() > 0) { + await categorySelect.click(); + await page.waitForTimeout(300); + + const categoryOption = page.locator('.n-base-select-option').filter({ + hasText: '有收藏的分类' + }); + + if (await categoryOption.count() > 0) { + await categoryOption.first().click(); + await page.waitForTimeout(300); + } + } + + // 保存收藏 + const saveFavButton = createFavDialog.getByRole('button', { name: /保存|save|确定|ok/i }); + if (await saveFavButton.count() > 0) { + await saveFavButton.click(); + await page.waitForTimeout(1000); + } + } + + // 4. 再次打开分类管理器尝试删除该分类 + const categoryDialog2 = await openCategoryManager(page); + if (!categoryDialog2) { + return; + } + + const categoryRow = categoryDialog2.locator('tr, .n-list-item').filter({ + hasText: '有收藏的分类' + }); + + if (await categoryRow.count() > 0) { + const deleteButton = categoryRow.locator('button').filter({ + hasText: /删除|delete/i + }).first(); + + if (await deleteButton.count() > 0) { + await deleteButton.click(); + await page.waitForTimeout(300); + + // 应该显示警告或错误提示(不能删除有收藏的分类) + // 检查是否有警告消息 + const warningMessage = page.locator('.n-message, .n-notification').filter({ + hasText: /不能删除|cannot delete|存在收藏|has favorites/i + }); + + if (await warningMessage.count() > 0) { + await expect(warningMessage.first()).toBeVisible(); + } else { + // 或者删除确认对话框应该仍然显示(未实际删除) + const categoryStillExists = categoryDialog2.locator('tr, .n-list-item').filter({ + hasText: '有收藏的分类' + }); + expect(await categoryStillExists.count()).toBeGreaterThan(0); + } + } + } + }); + + test('分类颜色显示正确', async ({ page }) => { + const categoryDialog = await openCategoryManager(page); + if (!categoryDialog) { + test.skip(); + return; + } + + // 验证分类列表表格/列表存在 + const table = categoryDialog.locator('table, .n-list, .n-data-table'); + if (await table.count() > 0) { + await expect(table.first()).toBeVisible(); + + // 如果有分类数据,验证颜色显示 + const colorIndicators = categoryDialog.locator('[class*="color"], .n-tag, .n-badge'); + if (await colorIndicators.count() > 0) { + // 至少应该有一些颜色指示器 + expect(await colorIndicators.count()).toBeGreaterThan(0); + } + } + }); + + test('分类搜索过滤功能', async ({ page }) => { + const categoryDialog = await openCategoryManager(page); + if (!categoryDialog) { + test.skip(); + return; + } + + // 查找搜索框 + const searchInput = categoryDialog.getByPlaceholder(/搜索|search|过滤|filter/i); + if (await searchInput.count() > 0) { + // 输入搜索关键词 + await searchInput.fill('测试'); + await page.waitForTimeout(500); + + // 验证搜索框的值 + const inputValue = await searchInput.inputValue(); + expect(inputValue).toBe('测试'); + + // 清空搜索 + await searchInput.clear(); + await page.waitForTimeout(300); + + const clearedValue = await searchInput.inputValue(); + expect(clearedValue).toBe(''); + } + }); +}); diff --git a/tests/e2e/favorite-management.spec.ts b/tests/e2e/favorite-management.spec.ts new file mode 100644 index 00000000..d01ea939 --- /dev/null +++ b/tests/e2e/favorite-management.spec.ts @@ -0,0 +1,428 @@ +import { test, expect } from '@playwright/test'; + +/** + * 收藏管理基础 E2E 测试 + * 验证收藏管理器的核心功能 + */ +test.describe('收藏管理基础功能', () => { + test.beforeEach(async ({ page }) => { + // 访问应用首页 + await page.goto('/'); + + // 等待页面加载完成 + await page.waitForLoadState('networkidle'); + }); + + test('应用能够正常加载', async ({ page }) => { + // 验证页面标题 + await expect(page).toHaveTitle(/提示词优化器|Prompt Optimizer/i); + + // 验证主要元素存在 + const mainContent = page.locator('main, #app, [role="main"]'); + await expect(mainContent).toBeAttached(); + }); + + test('能够打开收藏管理器', async ({ page }) => { + // 查找并点击收藏管理按钮 + // 根据实际UI调整选择器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + + if (await favoriteButton.count() > 0) { + await favoriteButton.first().click(); + + // 等待收藏管理器对话框出现 + const dialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|favorite/i }); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // 验证对话框标题 + const dialogTitle = dialog.locator('h1, h2, .n-card-header__main'); + await expect(dialogTitle).toContainText(/收藏|Favorites/i); + } else { + // 如果没有找到收藏按钮,跳过测试 + test.skip(); + } + }); + + test('收藏管理器包含必要的UI元素', async ({ page }) => { + // 尝试打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + + await favoriteButton.first().click(); + + // 等待对话框出现 + const dialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|favorite/i }); + await expect(dialog).toBeVisible(); + + // 验证搜索输入框 + const searchInput = dialog.getByPlaceholder(/搜索|search/i); + if (await searchInput.count() > 0) { + await expect(searchInput.first()).toBeVisible(); + } + + // 验证"添加"或"创建"按钮 + const addButton = dialog.getByRole('button', { name: /添加|创建|新建|add|create/i }); + if (await addButton.count() > 0) { + await expect(addButton.first()).toBeVisible(); + } + }); + + test('能够创建新收藏(基础验证)', async ({ page }) => { + // 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + + await favoriteButton.first().click(); + + const dialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|favorite/i }); + await expect(dialog).toBeVisible(); + + // 点击添加按钮 + const addButton = dialog.getByRole('button', { name: /添加|创建|新建|add|create/i }); + + if (await addButton.count() === 0) { + test.skip(); + return; + } + + await addButton.first().click(); + + // 等待创建对话框出现 + await page.waitForTimeout(500); // 等待动画 + + // 验证创建对话框出现(可能是第二个对话框) + const dialogs = page.locator('[role="dialog"]'); + const dialogCount = await dialogs.count(); + + // 如果有多个对话框,说明创建对话框已打开 + expect(dialogCount).toBeGreaterThanOrEqual(1); + }); +}); + +/** + * 收藏 CRUD 完整流程测试 + */ +test.describe('收藏完整 CRUD 流程', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('完整的创建、编辑、删除收藏流程', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 等待对话框完全加载 + await page.waitForTimeout(500); + + // 2. 点击添加收藏按钮 + const addButton = managerDialog.getByRole('button', { name: /添加|创建|新建|add|create/i }).first(); + await addButton.click(); + await page.waitForTimeout(500); + + // 3. 填写收藏信息 + const createDialog = page.locator('[role="dialog"]').last(); + + // 填写标题 + const titleInput = createDialog.getByPlaceholder(/标题|title/i); + if (await titleInput.count() > 0) { + await titleInput.fill('E2E 测试收藏'); + } + + // 填写内容 + const contentInput = createDialog.locator('textarea').first(); + if (await contentInput.count() > 0) { + await contentInput.fill('这是一个 E2E 测试创建的收藏内容'); + } + + // 4. 保存收藏 + const saveButton = createDialog.getByRole('button', { name: /保存|save|确定|ok/i }); + if (await saveButton.count() > 0) { + await saveButton.click(); + + // 等待保存完成 + await page.waitForTimeout(1000); + + // 5. 验证收藏已创建 - 搜索刚创建的收藏 + const searchInput = managerDialog.getByPlaceholder(/搜索|search/i); + if (await searchInput.count() > 0) { + await searchInput.fill('E2E 测试收藏'); + await page.waitForTimeout(500); + + // 验证收藏卡片出现 + const favoriteCard = managerDialog.locator('text=E2E 测试收藏'); + if (await favoriteCard.count() > 0) { + await expect(favoriteCard.first()).toBeVisible(); + + // 6. 删除收藏 - 查找删除按钮 + const card = favoriteCard.locator('..').locator('..').first(); + const deleteButton = card.getByRole('button', { name: /删除|delete/i }); + + if (await deleteButton.count() > 0) { + await deleteButton.click(); + await page.waitForTimeout(300); + + // 确认删除 + const confirmButton = page.getByRole('button', { name: /确定|确认|yes|ok/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(500); + + // 7. 验证收藏已删除 + await searchInput.clear(); + await searchInput.fill('E2E 测试收藏'); + await page.waitForTimeout(500); + + const deletedCard = managerDialog.locator('text=E2E 测试收藏'); + expect(await deletedCard.count()).toBe(0); + } + } + } + } + } + }); +}); + +/** + * 搜索和过滤功能测试 + */ +test.describe('搜索和过滤功能', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('搜索功能能够正常工作', async ({ page }) => { + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + await favoriteButton.first().click(); + + const dialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(dialog).toBeVisible(); + + // 测试搜索功能 + const searchInput = dialog.getByPlaceholder(/搜索|search/i); + if (await searchInput.count() > 0) { + // 输入搜索关键词 + await searchInput.fill('测试'); + await page.waitForTimeout(800); + + // 验证搜索输入框的值已更新 + const searchValue = await searchInput.inputValue(); + expect(searchValue).toBe('测试'); + + // 清空搜索 + await searchInput.clear(); + await page.waitForTimeout(500); + + // 验证搜索已清空 + const clearedValue = await searchInput.inputValue(); + expect(clearedValue).toBe(''); + } + }); + + test('分类过滤能够正常工作', async ({ page }) => { + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + await favoriteButton.first().click(); + + const dialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(dialog).toBeVisible(); + + // 查找分类选择器 + const categorySelect = dialog.locator('.n-base-selection, .n-select').first(); + if (await categorySelect.count() > 0) { + await categorySelect.click(); + await page.waitForTimeout(300); + + // 选择一个分类选项(如果有) + const firstOption = page.locator('.n-base-select-option').first(); + if (await firstOption.count() > 0) { + await firstOption.click(); + await page.waitForTimeout(800); + + // 验证选择器不再显示下拉菜单(已选择) + const dropdownHidden = await page.locator('.n-base-select-menu').isHidden().catch(() => true); + expect(dropdownHidden).toBe(true); + } + } + }); +}); + +/** + * 标签管理功能测试 + */ +test.describe('标签管理功能', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('能够打开标签管理器', async ({ page }) => { + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 查找更多操作菜单按钮 + const moreButton = managerDialog.getByRole('button').filter({ + has: page.locator('svg, .n-icon') + }).first(); + + if (await moreButton.count() > 0) { + await moreButton.click(); + await page.waitForTimeout(300); + + // 查找标签管理选项 + const tagManagerOption = page.locator('text=/标签管理|Tag/i'); + if (await tagManagerOption.count() > 0) { + await tagManagerOption.click(); + await page.waitForTimeout(500); + + // 验证标签管理器对话框出现 + const tagDialog = page.locator('[role="dialog"]').filter({ hasText: /标签|Tag/i }); + await expect(tagDialog).toBeVisible({ timeout: 3000 }); + } + } + }); +}); + +/** + * 分类管理功能测试 + */ +test.describe('分类管理功能', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('能够打开分类管理器', async ({ page }) => { + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 查找更多操作菜单按钮 + const moreButton = managerDialog.getByRole('button').filter({ + has: page.locator('svg, .n-icon') + }).first(); + + if (await moreButton.count() > 0) { + await moreButton.click(); + await page.waitForTimeout(300); + + // 查找分类管理选项 + const categoryManagerOption = page.locator('text=/分类管理|Category/i'); + if (await categoryManagerOption.count() > 0) { + await categoryManagerOption.click(); + await page.waitForTimeout(500); + + // 验证分类管理器对话框出现 + const categoryDialog = page.locator('[role="dialog"]').filter({ hasText: /分类|Category/i }); + await expect(categoryDialog).toBeVisible({ timeout: 3000 }); + } + } + }); +}); + +/** + * 导入导出功能测试 + */ +test.describe('导入导出功能', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('导出按钮能够正常工作', async ({ page }) => { + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 查找更多操作菜单 + const moreButton = managerDialog.getByRole('button').filter({ + has: page.locator('svg, .n-icon') + }).first(); + + if (await moreButton.count() > 0) { + await moreButton.click(); + await page.waitForTimeout(300); + + // 查找导出选项 + const exportOption = page.locator('text=/导出|Export/i'); + if (await exportOption.count() > 0) { + // 设置下载监听 + const downloadPromise = page.waitForEvent('download', { timeout: 5000 }).catch(() => null); + + await exportOption.click(); + + // 验证下载开始(如果有) + const download = await downloadPromise; + if (download) { + expect(download).toBeTruthy(); + } + } + } + }); +}); + +/** + * 收藏管理数据持久化测试 + */ +test.describe('收藏数据持久化', () => { + test('本地存储能够正常工作', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // 检查 localStorage 是否可用 + const localStorageAvailable = await page.evaluate(() => { + try { + localStorage.setItem('test', 'test'); + localStorage.removeItem('test'); + return true; + } catch (e) { + return false; + } + }); + + expect(localStorageAvailable).toBe(true); + }); +}); diff --git a/tests/e2e/import-export.spec.ts b/tests/e2e/import-export.spec.ts new file mode 100644 index 00000000..559c51d0 --- /dev/null +++ b/tests/e2e/import-export.spec.ts @@ -0,0 +1,618 @@ +import { test, expect } from '@playwright/test'; + +/** + * 导入导出完整流程 E2E 测试 + * + * 测试导入导出功能的完整场景: + * - 导入有效JSON数据 + * - 导入无效JSON处理 + * - 导入数据合并策略 + * - 导入结果统计显示 + * - 导出数据完整性 + */ +test.describe('导入导出完整流程', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + /** + * 辅助函数:打开收藏管理器 + */ + async function openFavoriteManager(page: any) { + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + return null; + } + await favoriteButton.first().click(); + await page.waitForTimeout(500); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + if (await managerDialog.isVisible().catch(() => false)) { + return managerDialog; + } + return null; + } + + test('导入有效的JSON数据', async ({ page }) => { + const managerDialog = await openFavoriteManager(page); + if (!managerDialog) { + test.skip(); + return; + } + + // 查找导入按钮 + const importButton = managerDialog.getByRole('button', { name: /导入|Import/i }); + if (await importButton.count() === 0) { + test.skip(); + return; + } + + await importButton.click(); + await page.waitForTimeout(500); + + // 准备导入数据 + const importData = { + favorites: [ + { + id: 'import-test-001', + title: '导入测试收藏1', + content: '这是通过导入创建的收藏', + tags: ['导入', '测试'], + functionMode: 'basic', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }, + { + id: 'import-test-002', + title: '导入测试收藏2', + content: '另一个导入的收藏', + tags: ['导入'], + functionMode: 'context', + optimizationMode: 'user', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [ + { + id: 'import-cat-001', + name: '导入的分类', + description: '通过导入创建的分类', + color: '#FF5722' + } + ], + tags: ['导入', '测试'] + }; + + // 在导入对话框中输入JSON数据 + const importDialog = page.locator('[role="dialog"]').last(); + + // 查找文本输入区域(可能是textarea或文件上传) + const jsonInput = importDialog.locator('textarea').first(); + + if (await jsonInput.count() > 0) { + await jsonInput.fill(JSON.stringify(importData, null, 2)); + + // 点击确认导入按钮 + const confirmButton = importDialog.getByRole('button', { name: /确定|确认|导入|ok|import/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(1500); + + // 验证导入成功消息 + const successMessage = page.locator('.n-message, .n-notification').filter({ + hasText: /成功|success|导入/i + }); + + if (await successMessage.count() > 0) { + await expect(successMessage.first()).toBeVisible(); + } + + // 验证导入的收藏显示在列表中 + const importedFavorite = managerDialog.locator('text=导入测试收藏1'); + if (await importedFavorite.count() > 0) { + await expect(importedFavorite.first()).toBeVisible(); + } + } + } else { + // 可能是文件上传模式 + // 创建临时JSON文件并上传 + const fileInput = importDialog.locator('input[type="file"]'); + if (await fileInput.count() > 0) { + // 在实际环境中,这里需要创建真实文件 + // Playwright支持通过setInputFiles上传文件 + test.skip(); // 文件上传模式需要额外处理 + } + } + }); + + test('导入无效JSON数据处理', async ({ page }) => { + const managerDialog = await openFavoriteManager(page); + if (!managerDialog) { + test.skip(); + return; + } + + const importButton = managerDialog.getByRole('button', { name: /导入|Import/i }); + if (await importButton.count() === 0) { + test.skip(); + return; + } + + await importButton.click(); + await page.waitForTimeout(500); + + const importDialog = page.locator('[role="dialog"]').last(); + const jsonInput = importDialog.locator('textarea').first(); + + if (await jsonInput.count() > 0) { + // 输入无效的JSON + await jsonInput.fill('{ 这不是有效的JSON }'); + + const confirmButton = importDialog.getByRole('button', { name: /确定|确认|导入|ok|import/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(500); + + // 应该显示错误消息 + const errorMessage = page.locator('.n-message, .n-notification').filter({ + hasText: /错误|失败|error|invalid|格式/i + }); + + if (await errorMessage.count() > 0) { + await expect(errorMessage.first()).toBeVisible(); + } + + // 对话框应该仍然打开(未成功导入) + const stillOpen = await importDialog.isVisible().catch(() => false); + if (stillOpen) { + expect(stillOpen).toBe(true); + } + } + } + }); + + test('导入数据后统计信息更新', async ({ page }) => { + const managerDialog = await openFavoriteManager(page); + if (!managerDialog) { + test.skip(); + return; + } + + // 记录导入前的收藏数量(如果有显示) + const initialFavorites = await managerDialog.locator('.n-card, [class*="favorite"]').count(); + + const importButton = managerDialog.getByRole('button', { name: /导入|Import/i }); + if (await importButton.count() === 0) { + test.skip(); + return; + } + + await importButton.click(); + await page.waitForTimeout(500); + + const importData = { + favorites: [ + { + title: '统计测试收藏', + content: '用于测试统计更新', + tags: ['统计'], + functionMode: 'basic', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [], + tags: ['统计'] + }; + + const importDialog = page.locator('[role="dialog"]').last(); + const jsonInput = importDialog.locator('textarea').first(); + + if (await jsonInput.count() > 0) { + await jsonInput.fill(JSON.stringify(importData)); + + const confirmButton = importDialog.getByRole('button', { name: /确定|确认|导入|ok|import/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(1500); + + // 验证收藏数量增加 + const finalFavorites = await managerDialog.locator('.n-card, [class*="favorite"]').count(); + expect(finalFavorites).toBeGreaterThan(initialFavorites); + } + } + }); + + test('导入包含分类的数据', async ({ page }) => { + const managerDialog = await openFavoriteManager(page); + if (!managerDialog) { + test.skip(); + return; + } + + const importButton = managerDialog.getByRole('button', { name: /导入|Import/i }); + if (await importButton.count() === 0) { + test.skip(); + return; + } + + await importButton.click(); + await page.waitForTimeout(500); + + const importData = { + favorites: [ + { + title: '分类测试收藏', + content: '属于导入分类的收藏', + tags: ['分类测试'], + category: 'import-category-001', + functionMode: 'basic', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [ + { + id: 'import-category-001', + name: '导入的测试分类', + description: '通过导入创建的分类', + color: '#4CAF50' + } + ], + tags: ['分类测试'] + }; + + const importDialog = page.locator('[role="dialog"]').last(); + const jsonInput = importDialog.locator('textarea').first(); + + if (await jsonInput.count() > 0) { + await jsonInput.fill(JSON.stringify(importData)); + + const confirmButton = importDialog.getByRole('button', { name: /确定|确认|导入|ok|import/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(1500); + + // 验证收藏已导入 + const importedFavorite = managerDialog.locator('text=分类测试收藏'); + if (await importedFavorite.count() > 0) { + await expect(importedFavorite.first()).toBeVisible(); + } + + // 可以通过打开分类管理器验证分类也被导入 + // 这里简化处理,只验证收藏导入成功 + } + } + }); + + test('导入数据合并策略(相同ID处理)', async ({ page }) => { + const managerDialog = await openFavoriteManager(page); + if (!managerDialog) { + test.skip(); + return; + } + + // 第一次导入 + const importButton = managerDialog.getByRole('button', { name: /导入|Import/i }); + if (await importButton.count() === 0) { + test.skip(); + return; + } + + await importButton.click(); + await page.waitForTimeout(500); + + const importData = { + favorites: [ + { + id: 'duplicate-test-001', + title: '重复ID测试收藏', + content: '第一次导入', + tags: ['重复测试'], + functionMode: 'basic', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [], + tags: ['重复测试'] + }; + + const importDialog = page.locator('[role="dialog"]').last(); + const jsonInput = importDialog.locator('textarea').first(); + + if (await jsonInput.count() > 0) { + await jsonInput.fill(JSON.stringify(importData)); + + const confirmButton = importDialog.getByRole('button', { name: /确定|确认|导入|ok|import/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(1500); + + // 第二次导入相同ID的数据 + const importButton2 = managerDialog.getByRole('button', { name: /导入|Import/i }); + if (await importButton2.count() > 0) { + await importButton2.click(); + await page.waitForTimeout(500); + + const importData2 = { + favorites: [ + { + id: 'duplicate-test-001', // 相同ID + title: '重复ID测试收藏-修改版', + content: '第二次导入', + tags: ['重复测试', '修改'], + functionMode: 'basic', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [], + tags: ['重复测试', '修改'] + }; + + const importDialog2 = page.locator('[role="dialog"]').last(); + const jsonInput2 = importDialog2.locator('textarea').first(); + + if (await jsonInput2.count() > 0) { + await jsonInput2.fill(JSON.stringify(importData2)); + + const confirmButton2 = importDialog2.getByRole('button', { name: /确定|确认|导入|ok|import/i }); + if (await confirmButton2.count() > 0) { + await confirmButton2.click(); + await page.waitForTimeout(1500); + + // 验证两个收藏都存在(ID冲突应该生成新ID) + const favorites = managerDialog.locator('.n-card, [class*="favorite"]').filter({ + hasText: /重复ID测试收藏/ + }); + + const count = await favorites.count(); + // 应该有2个收藏(系统重新生成了ID以避免冲突) + expect(count).toBeGreaterThanOrEqual(1); + } + } + } + } + } + }); + + test('导出功能生成有效JSON', async ({ page }) => { + const managerDialog = await openFavoriteManager(page); + if (!managerDialog) { + test.skip(); + return; + } + + // 先创建一个收藏,确保有数据可导出 + const addButton = managerDialog.getByRole('button', { name: /添加|创建|新建|add|create/i }).first(); + if (await addButton.count() > 0) { + await addButton.click(); + await page.waitForTimeout(500); + + const createDialog = page.locator('[role="dialog"]').last(); + const titleInput = createDialog.getByPlaceholder(/标题|title/i); + + if (await titleInput.count() > 0) { + await titleInput.fill('导出测试收藏'); + + const contentInput = createDialog.locator('textarea').first(); + if (await contentInput.count() > 0) { + await contentInput.fill('用于测试导出功能'); + } + + const saveButton = createDialog.getByRole('button', { name: /保存|save|确定|ok/i }); + if (await saveButton.count() > 0) { + await saveButton.click(); + await page.waitForTimeout(1000); + + // 等待创建对话框的遮罩层消失 + await page.waitForSelector('.n-modal-mask', { state: 'hidden', timeout: 3000 }).catch(() => {}); + } + } + } + + // 等待创建对话框完全关闭后再打开更多菜单 + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + + // 打开更多菜单导出 + const moreButton = managerDialog.getByRole('button').filter({ + has: page.locator('svg, .n-icon') + }).first(); + + if (await moreButton.count() > 0) { + await moreButton.click(); + await page.waitForTimeout(300); + + const exportOption = page.locator('text=/导出|Export/i'); + if (await exportOption.count() > 0) { + // 监听下载事件 + const downloadPromise = page.waitForEvent('download', { timeout: 5000 }).catch(() => null); + + await exportOption.click(); + + const download = await downloadPromise; + if (download) { + // 验证下载的文件 + const path = await download.path(); + if (path) { + const fs = await import('fs'); + const content = fs.readFileSync(path, 'utf-8'); + + // 验证是有效的JSON + expect(() => JSON.parse(content)).not.toThrow(); + + const data = JSON.parse(content); + + // 验证包含必要的字段 + expect(data).toHaveProperty('favorites'); + expect(Array.isArray(data.favorites)).toBe(true); + expect(data.favorites.length).toBeGreaterThan(0); + + // 验证收藏数据结构 + const firstFavorite = data.favorites[0]; + expect(firstFavorite).toHaveProperty('id'); + expect(firstFavorite).toHaveProperty('title'); + expect(firstFavorite).toHaveProperty('content'); + expect(firstFavorite).toHaveProperty('tags'); + expect(firstFavorite).toHaveProperty('functionMode'); + } + } + } + } + }); + + test('导入后数据可以正常编辑', async ({ page }) => { + const managerDialog = await openFavoriteManager(page); + if (!managerDialog) { + test.skip(); + return; + } + + // 导入数据 + const importButton = managerDialog.getByRole('button', { name: /导入|Import/i }); + if (await importButton.count() === 0) { + test.skip(); + return; + } + + await importButton.click(); + await page.waitForTimeout(500); + + const importData = { + favorites: [ + { + title: '可编辑导入收藏', + content: '原始内容', + tags: ['可编辑'], + functionMode: 'basic', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [], + tags: ['可编辑'] + }; + + const importDialog = page.locator('[role="dialog"]').last(); + const jsonInput = importDialog.locator('textarea').first(); + + if (await jsonInput.count() > 0) { + await jsonInput.fill(JSON.stringify(importData)); + + const confirmButton = importDialog.getByRole('button', { name: /确定|确认|导入|ok|import/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(1500); + + // 查找并编辑导入的收藏 + const favoriteCard = managerDialog.locator('text=可编辑导入收藏').locator('..').locator('..'); + if (await favoriteCard.count() > 0) { + // 查找编辑按钮 + const editButton = favoriteCard.locator('button').filter({ + hasText: /编辑|edit/i + }).first(); + + if (await editButton.count() > 0) { + await editButton.click(); + await page.waitForTimeout(500); + + // 修改标题 + const editDialog = page.locator('[role="dialog"]').last(); + const titleInput = editDialog.getByPlaceholder(/标题|title/i); + + if (await titleInput.count() > 0) { + await titleInput.clear(); + await titleInput.fill('编辑后的导入收藏'); + + const saveButton = editDialog.getByRole('button', { name: /保存|save|确定|ok/i }); + if (await saveButton.count() > 0) { + await saveButton.click(); + await page.waitForTimeout(1000); + + // 验证修改成功 + const updatedCard = managerDialog.locator('text=编辑后的导入收藏'); + if (await updatedCard.count() > 0) { + await expect(updatedCard.first()).toBeVisible(); + } + } + } + } + } + } + } + }); + + test('导入后数据可以正常删除', async ({ page }) => { + const managerDialog = await openFavoriteManager(page); + if (!managerDialog) { + test.skip(); + return; + } + + // 导入数据 + const importButton = managerDialog.getByRole('button', { name: /导入|Import/i }); + if (await importButton.count() === 0) { + test.skip(); + return; + } + + await importButton.click(); + await page.waitForTimeout(500); + + const importData = { + favorites: [ + { + title: '可删除导入收藏', + content: '将被删除的内容', + tags: ['可删除'], + functionMode: 'basic', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + ], + categories: [], + tags: ['可删除'] + }; + + const importDialog = page.locator('[role="dialog"]').last(); + const jsonInput = importDialog.locator('textarea').first(); + + if (await jsonInput.count() > 0) { + await jsonInput.fill(JSON.stringify(importData)); + + const confirmButton = importDialog.getByRole('button', { name: /确定|确认|导入|ok|import/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(1500); + + // 查找并删除导入的收藏 + const favoriteCard = managerDialog.locator('text=可删除导入收藏').locator('..').locator('..'); + if (await favoriteCard.count() > 0) { + const deleteButton = favoriteCard.locator('button').filter({ + hasText: /删除|delete/i + }).first(); + + if (await deleteButton.count() > 0) { + await deleteButton.click(); + await page.waitForTimeout(300); + + // 确认删除 + const confirmDeleteButton = page.getByRole('button', { name: /确定|确认|ok|confirm/i }).last(); + if (await confirmDeleteButton.count() > 0) { + await confirmDeleteButton.click(); + await page.waitForTimeout(1000); + + // 验证已删除 + const deletedCard = managerDialog.locator('text=可删除导入收藏'); + expect(await deletedCard.count()).toBe(0); + } + } + } + } + } + }); +}); diff --git a/tests/e2e/regression.spec.ts b/tests/e2e/regression.spec.ts new file mode 100644 index 00000000..95ab85d8 --- /dev/null +++ b/tests/e2e/regression.spec.ts @@ -0,0 +1,383 @@ +import { test, expect } from '@playwright/test'; + +/** + * UI 交互回归测试 + * + * 目的: 确保现有的 UI 功能没有被新变更破坏 + * 测试范围: + * 1. 收藏列表的显示和交互 + * 2. 导入导出功能 + * 3. 分类管理基本功能 + * 4. 现有的搜索和过滤 + */ +test.describe('UI 交互回归测试', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('收藏管理器能够正常打开和关闭', async ({ page }) => { + // 这是一个基础功能,应该一直工作 + + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + + await favoriteButton.first().click(); + await page.waitForTimeout(500); + + // 2. 验证对话框打开 + const dialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(dialog).toBeVisible(); + + // 3. 关闭对话框 + const closeButton = dialog.locator('[aria-label="close"], .n-base-close, .n-dialog__close').first(); + if (await closeButton.count() > 0) { + await closeButton.click(); + await page.waitForTimeout(500); + + // 4. 验证对话框关闭 + await expect(dialog).not.toBeVisible(); + } + }); + + test('收藏列表能够正常显示', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 2. 等待列表加载 + await page.waitForTimeout(1000); + + // 3. 验证列表容器存在 + // 即使没有数据,也应该有空状态或列表容器 + const hasEmptyState = await managerDialog.locator('.n-empty').isVisible().catch(() => false); + const hasList = await managerDialog.locator('.n-card, [class*="favorite"]').count() > 0; + + // 应该至少有一个存在(空状态或列表) + expect(hasEmptyState || hasList).toBe(true); + }); + + test('导出功能能够正常触发', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 2. 查找更多操作菜单 + const moreButton = managerDialog.getByRole('button').filter({ + has: page.locator('svg, .n-icon') + }).first(); + + if (await moreButton.count() > 0) { + await moreButton.click(); + await page.waitForTimeout(300); + + // 3. 查找导出选项 + const exportOption = page.locator('text=/导出|Export/i'); + + if (await exportOption.count() > 0) { + // 设置下载监听 + const downloadPromise = page.waitForEvent('download', { timeout: 5000 }).catch(() => null); + + await exportOption.click(); + + // 验证下载开始(如果有数据) + const download = await downloadPromise; + if (download) { + expect(download).toBeTruthy(); + } + // 如果没有数据,可能没有下载,这也是正常的 + } + } + }); + + test('导入功能对话框能够正常打开', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 2. 查找导入按钮 + const importButton = managerDialog.getByRole('button', { name: /导入|Import/i }); + + if (await importButton.count() > 0) { + await importButton.click(); + await page.waitForTimeout(500); + + // 3. 验证导入对话框或文件选择器出现 + // 可能是新对话框或文件上传组件 + const importDialog = page.locator('[role="dialog"]').last(); + const isNewDialog = await importDialog.isVisible().catch(() => false); + + // 如果有导入对话框,验证它显示正常 + if (isNewDialog) { + await expect(importDialog).toBeVisible(); + } + } + }); + + test('搜索功能输入响应正常', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + + await favoriteButton.first().click(); + + const dialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(dialog).toBeVisible(); + + // 2. 查找搜索框 + const searchInput = dialog.getByPlaceholder(/搜索|search/i); + + if (await searchInput.count() > 0) { + // 3. 输入搜索文本 + await searchInput.fill('回归测试'); + await page.waitForTimeout(500); + + // 4. 验证输入值正确 + const inputValue = await searchInput.inputValue(); + expect(inputValue).toBe('回归测试'); + + // 5. 清空搜索 + await searchInput.clear(); + await page.waitForTimeout(300); + + const clearedValue = await searchInput.inputValue(); + expect(clearedValue).toBe(''); + } + }); + + test('分类选择器能够正常交互', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + + await favoriteButton.first().click(); + + const dialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(dialog).toBeVisible(); + + // 2. 查找分类选择器 + const categorySelect = dialog.locator('.n-base-selection, .n-select').first(); + + if (await categorySelect.count() > 0) { + // 3. 点击打开下拉菜单 + await categorySelect.click(); + await page.waitForTimeout(300); + + // 4. 验证下拉菜单出现 + const dropdown = page.locator('.n-base-select-menu, .n-select-menu'); + if (await dropdown.isVisible().catch(() => false)) { + await expect(dropdown).toBeVisible(); + + // 5. 关闭下拉菜单(点击其他地方) + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + } + } + }); + + test('创建收藏对话框表单验证正常工作', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 2. 点击创建按钮 + const createButton = managerDialog.getByRole('button', { name: /添加|创建|新建|add|create/i }).first(); + await createButton.click(); + await page.waitForTimeout(500); + + // 3. 定位到创建对话框 + const createDialog = page.locator('[role="dialog"]').last(); + + // 4. 尝试保存空表单(应该有验证) + const saveButton = createDialog.getByRole('button', { name: /保存|save|确定|ok/i }); + + if (await saveButton.count() > 0) { + // 点击保存 + await saveButton.click(); + await page.waitForTimeout(500); + + // 验证对话框仍然打开(因为验证失败) + // 或者有错误提示显示 + const stillVisible = await createDialog.isVisible().catch(() => false); + + // 如果对话框仍然可见,说明验证起作用了 + if (stillVisible) { + expect(stillVisible).toBe(true); + } + } + }); + + test('工具栏按钮都能正常点击', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 2. 查找所有工具栏按钮 + const toolbarButtons = managerDialog.locator('.toolbar button, [class*="toolbar"] button'); + + const buttonCount = await toolbarButtons.count(); + + if (buttonCount > 0) { + // 3. 验证至少有一些按钮可点击 + let clickableCount = 0; + + for (let i = 0; i < Math.min(buttonCount, 5); i++) { + const button = toolbarButtons.nth(i); + const isEnabled = await button.isEnabled().catch(() => false); + if (isEnabled) { + clickableCount++; + } + } + + // 至少应该有一些按钮是可用的 + expect(clickableCount).toBeGreaterThan(0); + } + }); + + test('收藏卡片能够正常显示(如果有数据)', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + await page.waitForTimeout(1000); + + // 2. 先创建一个收藏以确保有数据 + const createButton = managerDialog.getByRole('button', { name: /添加|创建|新建|add|create/i }).first(); + await createButton.click(); + await page.waitForTimeout(500); + + const createDialog = page.locator('[role="dialog"]').last(); + + // 填写基本信息 + const titleInput = createDialog.getByPlaceholder(/标题|title/i); + if (await titleInput.count() > 0) { + await titleInput.fill('回归测试收藏'); + + const contentInput = createDialog.locator('textarea').first(); + if (await contentInput.count() > 0) { + await contentInput.fill('这是用于回归测试的收藏内容'); + } + + // 保存 + const saveButton = createDialog.getByRole('button', { name: /保存|save|确定|ok/i }); + if (await saveButton.count() > 0) { + await saveButton.click(); + await page.waitForTimeout(1500); + + // 3. 验证收藏卡片显示 + const favoriteCard = managerDialog.locator('text=回归测试收藏'); + if (await favoriteCard.count() > 0) { + await expect(favoriteCard.first()).toBeVisible(); + } + } + } + }); +}); + +/** + * 关键功能持续性测试 + * 确保核心功能在重构后依然可用 + */ +test.describe('关键功能持续性测试', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('应用主界面能够正常加载', async ({ page }) => { + // 验证基础 HTML 结构 + const app = page.locator('#app, [id="app"], main'); + await expect(app).toBeAttached(); + + // 验证页面标题 + await expect(page).toHaveTitle(/提示词优化器|Prompt Optimizer/i); + }); + + test('本地存储功能保持可用', async ({ page }) => { + // 验证 localStorage 仍然可用 + const storageWorks = await page.evaluate(() => { + try { + const testKey = 'regression-test-' + Date.now(); + localStorage.setItem(testKey, 'test-value'); + const retrieved = localStorage.getItem(testKey); + localStorage.removeItem(testKey); + return retrieved === 'test-value'; + } catch (e) { + return false; + } + }); + + expect(storageWorks).toBe(true); + }); + + test('页面布局结构保持完整', async ({ page }) => { + // 验证基本布局元素存在 + await page.waitForTimeout(1000); + + // 应该有某种导航或工具栏 + const hasNavigation = await page.locator('nav, header, .toolbar, [class*="toolbar"]').count(); + + // 应该有主内容区域 + const hasMainContent = await page.locator('main, #app, [role="main"], .content').count(); + + // 至少应该有一些结构 + expect(hasNavigation + hasMainContent).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/tag-autocomplete.spec.ts b/tests/e2e/tag-autocomplete.spec.ts new file mode 100644 index 00000000..f88e55ba --- /dev/null +++ b/tests/e2e/tag-autocomplete.spec.ts @@ -0,0 +1,256 @@ +import { test, expect } from '@playwright/test'; + +/** + * 标签自动完成功能 E2E 测试 + * 验证标签输入和自动完成建议功能 + */ +test.describe('标签自动完成功能', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('标签自动完成建议能够正常显示', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 2. 点击添加收藏按钮 + const addButton = managerDialog.getByRole('button', { name: /添加|创建|新建|add|create/i }).first(); + await addButton.click(); + await page.waitForTimeout(500); + + // 3. 定位到编辑对话框 + const editDialog = page.locator('[role="dialog"]').last(); + await expect(editDialog).toBeVisible(); + + // 4. 查找标签输入框 + const tagInput = editDialog.getByPlaceholder(/标签|tag/i); + + if (await tagInput.count() > 0) { + // 5. 输入部分标签文本 + await tagInput.fill('测'); + await page.waitForTimeout(500); + + // 6. 验证标签输入框的值 + const inputValue = await tagInput.inputValue(); + expect(inputValue).toBe('测'); + + // 7. 检查是否有自动完成下拉菜单出现 + const autocompleteMenu = page.locator('.n-auto-complete-menu, .n-base-select-menu'); + + // 注意:如果没有匹配的建议,菜单可能不会显示,这是正常的 + // 我们只验证输入功能正常 + + // 8. 清空输入 + await tagInput.clear(); + await page.waitForTimeout(300); + + const clearedValue = await tagInput.inputValue(); + expect(clearedValue).toBe(''); + } + }); + + test('可以通过手动输入添加标签', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 2. 点击添加收藏按钮 + const addButton = managerDialog.getByRole('button', { name: /添加|创建|新建|add|create/i }).first(); + await addButton.click(); + await page.waitForTimeout(500); + + // 3. 定位到编辑对话框 + const editDialog = page.locator('[role="dialog"]').last(); + + // 4. 查找标签输入框 + const tagInput = editDialog.getByPlaceholder(/标签|tag/i); + + if (await tagInput.count() > 0) { + // 5. 输入标签文本 + await tagInput.fill('E2E测试标签'); + await page.waitForTimeout(300); + + // 6. 按 Enter 键添加标签 + await tagInput.press('Enter'); + await page.waitForTimeout(500); + + // 7. 验证标签是否被添加(查找标签显示) + // 标签通常显示为 NTag 组件 + const addedTag = editDialog.locator('text=E2E测试标签'); + if (await addedTag.count() > 0) { + await expect(addedTag.first()).toBeVisible(); + } + + // 8. 验证输入框已清空(准备输入下一个标签) + const inputValue = await tagInput.inputValue(); + expect(inputValue).toBe(''); + } + }); + + test('可以删除已添加的标签', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 2. 点击添加收藏按钮 + const addButton = managerDialog.getByRole('button', { name: /添加|创建|新建|add|create/i }).first(); + await addButton.click(); + await page.waitForTimeout(500); + + // 3. 定位到编辑对话框 + const editDialog = page.locator('[role="dialog"]').last(); + + // 4. 查找标签输入框并添加标签 + const tagInput = editDialog.getByPlaceholder(/标签|tag/i); + + if (await tagInput.count() > 0) { + // 5. 添加一个标签 + await tagInput.fill('可删除标签'); + await tagInput.press('Enter'); + await page.waitForTimeout(500); + + // 6. 查找已添加的标签 + const addedTag = editDialog.locator('text=可删除标签').first(); + + if (await addedTag.count() > 0) { + // 7. 查找标签的关闭按钮(通常是 closable 的 NTag) + // 查找标签父元素中的关闭图标 + const tagContainer = addedTag.locator('..').first(); + const closeButton = tagContainer.locator('[role="button"], .n-tag__close, .n-base-close').first(); + + if (await closeButton.count() > 0) { + await closeButton.click(); + await page.waitForTimeout(500); + + // 8. 验证标签已被删除 + const deletedTag = editDialog.locator('text=可删除标签'); + expect(await deletedTag.count()).toBe(0); + } + } + } + }); + + test('标签输入框支持多个标签添加', async ({ page }) => { + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 2. 点击添加收藏按钮 + const addButton = managerDialog.getByRole('button', { name: /添加|创建|新建|add|create/i }).first(); + await addButton.click(); + await page.waitForTimeout(500); + + // 3. 定位到编辑对话框 + const editDialog = page.locator('[role="dialog"]').last(); + + // 4. 查找标签输入框 + const tagInput = editDialog.getByPlaceholder(/标签|tag/i); + + if (await tagInput.count() > 0) { + // 5. 连续添加多个标签 + const tags = ['标签1', '标签2', '标签3']; + + for (const tag of tags) { + await tagInput.fill(tag); + await tagInput.press('Enter'); + await page.waitForTimeout(300); + } + + // 6. 验证所有标签都被添加 + for (const tag of tags) { + const addedTag = editDialog.locator(`text=${tag}`); + if (await addedTag.count() > 0) { + await expect(addedTag.first()).toBeVisible(); + } + } + } + }); +}); + +/** + * 标签自动完成建议测试(需要有已存在的标签) + */ +test.describe('标签自动完成建议', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + test('输入时显示匹配的标签建议', async ({ page }) => { + // 注意:这个测试需要数据库中已经有一些标签 + // 如果是全新安装,可能不会有建议 + + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + await favoriteButton.first().click(); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + const addButton = managerDialog.getByRole('button', { name: /添加|创建|新建|add|create/i }).first(); + await addButton.click(); + await page.waitForTimeout(500); + + const editDialog = page.locator('[role="dialog"]').last(); + const tagInput = editDialog.getByPlaceholder(/标签|tag/i); + + if (await tagInput.count() > 0) { + // 先添加一个标签到系统中 + await tagInput.fill('前端开发'); + await tagInput.press('Enter'); + await page.waitForTimeout(300); + + // 清空输入框 + await tagInput.clear(); + await page.waitForTimeout(300); + + // 现在输入部分匹配文本 + await tagInput.fill('前'); + await page.waitForTimeout(500); + + // 检查是否有自动完成菜单 + const autocompleteMenu = page.locator('.n-auto-complete-menu, .n-base-select-menu'); + + // 如果有菜单且可见,验证里面有匹配的选项 + if (await autocompleteMenu.isVisible().catch(() => false)) { + const matchingOption = autocompleteMenu.locator('text=/前端/'); + if (await matchingOption.count() > 0) { + await expect(matchingOption.first()).toBeVisible(); + } + } + } + }); +}); diff --git a/tests/e2e/tag-management.spec.ts b/tests/e2e/tag-management.spec.ts new file mode 100644 index 00000000..191e2387 --- /dev/null +++ b/tests/e2e/tag-management.spec.ts @@ -0,0 +1,281 @@ +import { test, expect } from '@playwright/test'; + +/** + * 标签管理完整 CRUD 流程 E2E 测试 + * + * 测试标签管理器的完整功能: + * - 重命名标签 + * - 合并标签 + * - 删除标签 + * - 标签统计显示 + */ +test.describe('标签管理完整流程', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + /** + * 辅助函数:等待所有模态对话框完全关闭 + */ + async function waitForModalClose(page: any) { + // 尝试多种方法关闭现有对话框: + // 1. 尝试按Esc键关闭 + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + + // 2. 尝试点击遮罩层关闭(如果点击遮罩层可以关闭的话) + const mask = page.locator('.n-modal-mask').first(); + if (await mask.count() > 0 && await mask.isVisible()) { + await mask.click({ timeout: 1000 }).catch(() => {}); + await page.waitForTimeout(300); + } + + // 3. 尝试点击所有关闭按钮 + const closeButtons = page.locator('[aria-label="close"], .n-base-close, button:has-text("关闭"), button:has-text("关闭")'); + const buttonCount = await closeButtons.count(); + for (let i = 0; i < Math.min(buttonCount, 3); i++) { // 最多尝试关闭3个对话框 + try { + await closeButtons.nth(i).click({ timeout: 1000 }); + await page.waitForTimeout(300); + } catch (e) { + // 忽略点击失败 + } + } + + // 4. 最后等待所有遮罩层消失 + await page.waitForSelector('.n-modal-mask', { state: 'hidden', timeout: 3000 }).catch(() => {}); + } + + /** + * 辅助函数:打开标签管理器 + */ + async function openTagManager(page: any) { + // 等待任何现有对话框完全关闭 + await waitForModalClose(page); + + // 1. 打开收藏管理器 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + return null; + } + await favoriteButton.first().click(); + await page.waitForTimeout(500); + + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 2. 打开更多菜单 + const moreButton = managerDialog.getByRole('button').filter({ + has: page.locator('svg, .n-icon') + }).first(); + + if (await moreButton.count() === 0) { + return null; + } + + await moreButton.click(); + await page.waitForTimeout(300); + + // 3. 点击标签管理选项 + const tagManagerOption = page.locator('text=/标签管理|Tag Management/i'); + if (await tagManagerOption.count() === 0) { + return null; + } + + await tagManagerOption.click(); + await page.waitForTimeout(500); + + // 4. 返回标签管理器对话框 + const tagDialog = page.locator('[role="dialog"]').filter({ hasText: /标签|Tag/i }).last(); + if (await tagDialog.isVisible().catch(() => false)) { + return tagDialog; + } + + return null; + } + + test('标签重命名功能', async ({ page }) => { + // 等待任何遮罩层消失 + await page.waitForSelector('.n-modal-mask', { state: 'hidden', timeout: 2000 }).catch(() => {}); + + // 先创建一个带标签的收藏 + const favoriteButton = page.getByRole('button', { name: /收藏|favorite/i }); + if (await favoriteButton.count() === 0) { + test.skip(); + return; + } + + await favoriteButton.first().click(); + const managerDialog = page.locator('[role="dialog"]').filter({ hasText: /收藏|Favorites/i }).first(); + await expect(managerDialog).toBeVisible(); + + // 创建收藏并添加标签 + const addButton = managerDialog.getByRole('button', { name: /添加|创建|新建|add|create/i }).first(); + await addButton.click(); + await page.waitForTimeout(500); + + const createDialog = page.locator('[role="dialog"]').last(); + const titleInput = createDialog.getByPlaceholder(/标题|title/i); + if (await titleInput.count() > 0) { + await titleInput.fill('标签重命名测试收藏'); + + const contentInput = createDialog.locator('textarea').first(); + if (await contentInput.count() > 0) { + await contentInput.fill('用于测试标签重命名功能'); + } + + // 添加标签 + const tagInput = createDialog.getByPlaceholder(/标签|tag/i); + if (await tagInput.count() > 0) { + await tagInput.fill('旧标签名'); + await tagInput.press('Enter'); + await page.waitForTimeout(300); + } + + // 保存收藏 + const saveButton = createDialog.getByRole('button', { name: /保存|save|确定|ok/i }); + if (await saveButton.count() > 0) { + await saveButton.click(); + await page.waitForTimeout(1000); + } + } + + // 打开标签管理器 + const tagDialog = await openTagManager(page); + if (!tagDialog) { + test.skip(); + return; + } + + // 查找包含"旧标签名"的行 + const tagRow = tagDialog.locator('tr').filter({ hasText: '旧标签名' }); + if (await tagRow.count() === 0) { + // 标签可能没有正确创建,跳过测试 + return; + } + + // 查找重命名按钮(可能是编辑按钮或重命名按钮) + const renameButton = tagRow.locator('button').filter({ hasText: /重命名|编辑|rename|edit/i }).first(); + if (await renameButton.count() > 0) { + await renameButton.click(); + await page.waitForTimeout(300); + + // 在弹出的对话框中输入新标签名 + const renameDialog = page.locator('[role="dialog"]').last(); + const newNameInput = renameDialog.locator('input[type="text"]').first(); + if (await newNameInput.count() > 0) { + await newNameInput.clear(); + await newNameInput.fill('新标签名'); + + // 确认重命名 + const confirmButton = renameDialog.getByRole('button', { name: /确定|确认|ok|confirm/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(500); + + // 验证新标签名出现 + const newTagRow = tagDialog.locator('tr').filter({ hasText: '新标签名' }); + if (await newTagRow.count() > 0) { + await expect(newTagRow).toBeVisible(); + } + } + } + } + }); + + test('标签删除功能', async ({ page }) => { + const tagDialog = await openTagManager(page); + if (!tagDialog) { + test.skip(); + return; + } + + // 先添加一个新标签(如果有添加功能) + const addTagButton = tagDialog.getByRole('button', { name: /添加|新建|add|create/i }); + if (await addTagButton.count() > 0) { + await addTagButton.click(); + await page.waitForTimeout(300); + + const addDialog = page.locator('[role="dialog"]').last(); + const tagNameInput = addDialog.locator('input[type="text"]').first(); + if (await tagNameInput.count() > 0) { + await tagNameInput.fill('待删除标签'); + + const confirmButton = addDialog.getByRole('button', { name: /确定|ok/i }); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(500); + } + } + } + + // 查找包含"待删除标签"的行 + const tagRow = tagDialog.locator('tr').filter({ hasText: '待删除标签' }); + if (await tagRow.count() > 0) { + // 查找删除按钮 + const deleteButton = tagRow.locator('button').filter({ hasText: /删除|delete/i }).first(); + if (await deleteButton.count() > 0) { + await deleteButton.click(); + await page.waitForTimeout(300); + + // 确认删除 + const confirmButton = page.getByRole('button', { name: /确定|确认|ok|confirm/i }).last(); + if (await confirmButton.count() > 0) { + await confirmButton.click(); + await page.waitForTimeout(500); + + // 验证标签已删除 + const deletedRow = tagDialog.locator('tr').filter({ hasText: '待删除标签' }); + expect(await deletedRow.count()).toBe(0); + } + } + } + }); + + test('标签统计显示正确', async ({ page }) => { + const tagDialog = await openTagManager(page); + if (!tagDialog) { + test.skip(); + return; + } + + // 验证标签列表表格存在 + const table = tagDialog.locator('table, .n-data-table'); + if (await table.count() > 0) { + await expect(table).toBeVisible(); + + // 验证表头包含统计列(使用次数/count) + const headers = table.locator('th'); + const headerCount = await headers.count(); + expect(headerCount).toBeGreaterThan(0); + } + }); + + test('标签搜索过滤功能', async ({ page }) => { + const tagDialog = await openTagManager(page); + if (!tagDialog) { + test.skip(); + return; + } + + // 查找搜索框 + const searchInput = tagDialog.getByPlaceholder(/搜索|search|过滤|filter/i); + if (await searchInput.count() > 0) { + // 输入搜索关键词 + await searchInput.fill('测试'); + await page.waitForTimeout(500); + + // 验证搜索框的值 + const inputValue = await searchInput.inputValue(); + expect(inputValue).toBe('测试'); + + // 清空搜索 + await searchInput.clear(); + await page.waitForTimeout(300); + + const clearedValue = await searchInput.inputValue(); + expect(clearedValue).toBe(''); + } + }); +});