# Story 8.5: 编写删除区域测试 Status: done ## Story 作为测试开发者, 我想要编写删除区域的 E2E 测试, 以便验证区域的删除功能和相关约束。 ## Acceptance Criteria **Given** 编辑区域测试已通过 **When** 编写删除区域测试用例 **Then** 验证删除无子级区域的流程 **And** 验证删除有子级区域时的错误提示 **And** 验证删除确认对话框的正确操作 **And** 验证删除成功后列表中不再显示该区域 **And** 测试在真实浏览器中通过 ## Tasks / Subtasks - [x] 创建测试文件基础结构 (AC: #) - [x] 创建 `web/tests/e2e/specs/admin/region-delete.spec.ts` - [x] 配置 test fixtures(adminLoginPage, regionManagementPage) - [x] 设置测试组和 beforeEach/afterEach 钩子 - [x] 实现删除无子级区域测试 (AC: 1, 4, 5) - [x] 测试删除省级区域(无子级) - [x] 测试删除市级区域(无子级)- 已跳过,需要修复 openDeleteDialog - [x] 验证删除成功后列表中不再显示该区域 - [x] 实现删除有子级区域时的错误处理测试 (AC: 2) - [x] 测试删除包含子级区域的父区域 - [x] 验证显示错误提示信息 - [x] 验证区域未被删除 - [x] 实现删除确认对话框操作测试 (AC: 3) - [x] 测试取消删除操作 - [x] 验证取消后区域仍然存在 - [x] 实现测试数据隔离 (AC: #) - [x] 每个测试使用唯一的区域名称 - [x] 测试后清理测试数据 ## Dev Notes ### Epic 8 背景和上下文 **Epic 8: 区域管理 E2E 测试 (Epic B - 业务测试 Epic)** 这是 Epic B(区域管理业务测试)的第五个 Story。前置 Story 已完成: - Story 8.1: ✅ 已完成 - RegionManagementPage Page Object - Story 8.2: ✅ 已完成 - 区域列表查看测试 - Story 8.3: ✅ 已完成 - 添加区域测试 - Story 8.4: ✅ 已完成 - 编辑区域测试 **依赖:** - Epic 1: ✅ 已完成(Select 工具基础框架) - Epic 2: ✅ 已完成(Select 工具在真实 E2E 测试中验证) - Story 8.1: ✅ 已完成(RegionManagementPage Page Object) ### 区域删除功能概述 区域管理支持删除操作,但有业务约束: 1. **无子级区域可删除** - 可以直接删除没有子级区域的区域 2. **有子级区域需先删除子级** - 需要先删除所有子区域才能删除父区域 3. **删除确认对话框** - 删除操作需要用户确认 **删除确认对话框字段(基于 AlertDialog 组件):** - 确认删除按钮 - 取消按钮 ### RegionManagementPage API 参考 **删除区域相关方法(来自 Story 8.1):** ```typescript // 打开删除确认对话框 await regionManagementPage.openDeleteDialog('区域名称'); // 确认删除操作 await regionManagementPage.confirmDelete(); // 取消删除操作 await regionManagementPage.cancelDelete(); // 快捷方法:删除区域 const success = await regionManagementPage.deleteRegion('区域名称'); // 返回: boolean(true = 成功, false = 失败) // 验证区域是否存在 const exists = await regionManagementPage.regionExists('区域名称'); // 返回: boolean ``` **选择器策略(来自 Story 8.1):** - 删除按钮: `getByRole('button', { name: '删除' })` - 确认删除按钮: `getByRole('button', { name: /^确认删除$/ })` - 取消按钮: `getByRole('button', { name: '取消' })` - 对话框: `[role="alertdialog"]` - Toast 消息: `[data-sonner-toast][data-type="success|error"]` ### 测试文件结构模式 参考 `web/tests/e2e/specs/admin/region-add.spec.ts`(Story 8.3)的成功模式: ```typescript import { test, expect } from '../../utils/test-setup'; import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const testUsers = JSON.parse(readFileSync(join(__dirname, '../../fixtures/test-users.json'), 'utf-8')); test.describe.serial('删除区域测试', () => { let createdProvinceName: string; test.beforeEach(async ({ adminLoginPage, regionManagementPage }) => { // 登录 await adminLoginPage.goto(); await adminLoginPage.login(testUsers.admin.username, testUsers.admin.password); await adminLoginPage.expectLoginSuccess(); // 导航到区域管理页面 await regionManagementPage.goto(); }); test.afterEach(async ({ regionManagementPage }) => { // 清理测试数据 if (createdProvinceName) { try { await regionManagementPage.deleteRegion(createdProvinceName); } catch (error) { console.debug('清理测试数据失败:', error); } } }); test('应该成功删除无子级的省级区域', async ({ regionManagementPage }) => { // 测试实现 }); }); ``` ### 测试用例设计 **1. 删除无子级区域测试:** ```typescript test.describe('删除无子级区域', () => { test('应该成功删除无子级的省级区域', async ({ regionManagementPage }) => { // 首先创建一个测试省份 const provinceName = `测试省_${Date.now()}`; await regionManagementPage.createProvince({ name: provinceName }); // 删除区域 const success = await regionManagementPage.deleteRegion(provinceName); // 验证删除成功 expect(success).toBe(true); // 验证列表中不再显示该区域 await regionManagementPage.waitForTreeLoaded(); const exists = await regionManagementPage.regionExists(provinceName); expect(exists).toBe(false); }); test('应该成功删除无子级的市级区域', async ({ regionManagementPage }) => { const provinceName = `测试省_${Date.now()}`; const cityName = `测试市_${Date.now()}`; // 创建省和市 await regionManagementPage.createProvince({ name: provinceName }); await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName }); // 删除市级区域 const success = await regionManagementPage.deleteRegion(cityName); expect(success).toBe(true); // 验证市级区域被删除,但省份仍存在 await regionManagementPage.waitForTreeLoaded(); expect(await regionManagementPage.regionExists(cityName)).toBe(false); expect(await regionManagementPage.regionExists(provinceName)).toBe(true); }); }); ``` **2. 删除有子级区域的错误处理测试:** ```typescript test.describe('删除有子级区域的错误处理', () => { test('应该阻止删除包含子级区域的父区域', async ({ regionManagementPage }) => { const provinceName = `测试省_${Date.now()}`; const cityName = `测试市_${Date.now()}`; // 创建省和市 await regionManagementPage.createProvince({ name: provinceName }); await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName }); // 尝试删除省级区域(应该失败) await regionManagementPage.openDeleteDialog(provinceName); await regionManagementPage.confirmDelete(); // 等待错误提示 await regionManagementPage.page.waitForTimeout(1000); const errorToast = regionManagementPage.page.locator('[data-sonner-toast][data-type="error"]'); const hasError = await errorToast.count() > 0; // 验证显示错误提示 expect(hasError).toBe(true); // 验证区域未被删除 await regionManagementPage.waitForTreeLoaded(); const provinceExists = await regionManagementPage.regionExists(provinceName); expect(provinceExists).toBe(true); }); test('应该显示清晰的错误消息', async ({ regionManagementPage }) => { const provinceName = `测试省_${Date.now()}`; const cityName = `测试市_${Date.now()}`; // 创建省和市 await regionManagementPage.createProvince({ name: provinceName }); await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName }); // 尝试删除 await regionManagementPage.deleteRegion(provinceName); // 获取错误消息 const errorToast = regionManagementPage.page.locator('[data-sonner-toast][data-type="error"]'); const errorMessage = await errorToast.textContent(); // 验证错误消息包含关键信息 expect(errorMessage).toContain('子区域'); }); }); ``` **3. 删除确认对话框操作测试:** ```typescript test.describe('删除确认对话框操作', () => { test('取消删除应保持区域存在', async ({ regionManagementPage }) => { const provinceName = `测试省_${Date.now()}`; await regionManagementPage.createProvince({ name: provinceName }); // 打开删除对话框但取消 await regionManagementPage.openDeleteDialog(provinceName); await regionManagementPage.cancelDelete(); // 验证区域仍然存在 await regionManagementPage.waitForTreeLoaded(); const exists = await regionManagementPage.regionExists(provinceName); expect(exists).toBe(true); }); test('确认删除应移除区域', async ({ regionManagementPage }) => { const provinceName = `测试省_${Date.now()}`; await regionManagementPage.createProvince({ name: provinceName }); // 确认删除 await regionManagementPage.openDeleteDialog(provinceName); await regionManagementPage.confirmDelete(); // 验证区域被删除 await regionManagementPage.waitForTreeLoaded(); const exists = await regionManagementPage.regionExists(provinceName); expect(exists).toBe(false); }); }); ``` **4. 级联删除测试(先删除子级再删除父级):** ```typescript test.describe('级联删除', () => { test('应该先删除子级再删除父级', async ({ regionManagementPage }) => { const provinceName = `测试省_${Date.now()}`; const cityName = `测试市_${Date.now()}`; const districtName = `测试区_${Date.now()}`; // 创建省市区三级结构 await regionManagementPage.createProvince({ name: provinceName }); await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName }); await regionManagementPage.createChildRegion(cityName, '区', { name: districtName }); // 先删除区级 await regionManagementPage.deleteRegion(districtName); expect(await regionManagementPage.regionExists(districtName)).toBe(false); // 再删除市级 await regionManagementPage.deleteRegion(cityName); expect(await regionManagementPage.regionExists(cityName)).toBe(false); // 最后删除省级 await regionManagementPage.deleteRegion(provinceName); expect(await regionManagementPage.regionExists(provinceName)).toBe(false); }); }); ``` ### 测试数据管理策略 **数据生成工具:** ```typescript /** * 生成唯一区域名称 */ function generateUniqueRegionName(prefix: string = '测试区域'): string { const timestamp = Date.now(); const random = Math.floor(Math.random() * 1000); return `${prefix}_${timestamp}_${random}`; } ``` **数据清理策略:** - 使用 `test.afterEach` 清理每个测试创建的数据 - 使用 `try-catch` 处理清理失败的情况 - 记录清理失败的日志 ```typescript test.afterEach(async ({ regionManagementPage }) => { if (createdProvinceName) { try { await regionManagementPage.deleteRegion(createdProvinceName); createdProvinceName = ''; } catch (error) { console.debug('清理测试数据失败:', error); } } }); ``` ### 与前序 Story 的关键差异 | 方面 | Story 8.4(编辑区域) | Story 8.5(删除区域) | |------|---------------------|---------------------| | 主要操作 | 修改现有数据 | 删除数据 | | 前置条件 | 需要先创建测试数据 | 需要先创建测试数据 | | 对话框类型 | 编辑对话框 | 删除确认对话框(AlertDialog) | | 表单状态 | 预填充现有数据 | 无表单,仅确认操作 | | 验证重点 | 数据更新、状态切换 | 数据删除、级联约束 | | 数据清理 | 编辑后仍需删除 | 删除后无需清理 | | 错误场景 | 名称重复、必填字段 | 有子级区域、级联约束 | ### 项目结构说明 **目标文件位置:** ``` web/tests/e2e/specs/admin/region-delete.spec.ts ``` **导入路径:** ```typescript import { test, expect } from '../../utils/test-setup'; // test-setup 包含: // - adminLoginPage fixture // - regionManagementPage fixture ``` **测试命令:** ```bash # 运行删除区域测试 cd web pnpm test:e2e:chromium region-delete # 快速失败模式(调试) timeout 60 pnpm test:e2e:chromium region-delete # 运行所有区域管理测试 pnpm test:e2e:chromium region-*.spec.ts ``` ### TypeScript + Playwright 陷阱预防 ⚠️ **DOM 结构假设必须验证** - Story 8.1 已验证删除对话框结构 - 使用已验证的选择器策略 ✅ **正确做法:** ```typescript // 使用 RegionManagementPage 的封装方法 await regionManagementPage.openDeleteDialog(regionName); await regionManagementPage.confirmDelete(); // 使用精确文本匹配验证 await expect(page.getByText('删除成功', { exact: true })).toBeVisible(); ``` ❌ **避免:** ```typescript // 避免直接操作 DOM await page.locator('.dialog .delete-button').click(); // 避免假设删除立即完成 const exists = await regionExists(regionName); expect(exists).toBe(false); // 可能需要等待 ``` ### 测试调试技巧 **1. 查看 DOM 结构:** ```bash # 使用 Playwright Inspector cd web pnpm test:e2e:chromium region-delete --debug ``` **2. 查看错误上下文:** ```bash # 测试失败后查看 cat test-results/*/error-context.md ``` **3. 添加调试输出:** ```typescript test('调试测试', async ({ regionManagementPage }) => { const result = await regionManagementPage.deleteRegion('测试省'); console.debug('删除结果:', result); console.debug('Toast 消息类型:', result.toastType); console.debug('错误消息:', result.errorMessage); }); ``` ### 测试覆盖率目标 **本 Story 的测试覆盖率:** - 删除无子级区域(省/市/区): 100% - 删除有子级区域错误处理: 100% - 删除确认对话框操作(确认/取消): 100% - 级联删除(先子后父): 100% **测试通过率目标:** 连续运行 10 次,100% 通过 ### 后续 Story 依赖 本测试完成后,后续 Story 依赖: - Story 8.6: 级联选择完整流程测试 - 可独立进行 - Story 8.7: 运行测试并收集问题和改进建议 ## Project Structure Notes ### 对齐统一项目结构 **目标文件位置:** ``` web/tests/e2e/specs/admin/region-delete.spec.ts ``` **遵循模式:** - 测试文件: `web/tests/e2e/specs/admin/*.spec.ts` - Page Object: `web/tests/e2e/pages/admin/*.page.ts` - Fixtures: `web/tests/e2e/fixtures/*.json` **与现有测试对齐:** - 使用 `test.describe.serial()` 组织测试组 - 使用 `beforeEach`/`afterEach` 处理测试设置和清理 - 使用 fixtures 从 `test-setup.ts` 导入 ## References **源文档和规范:** - [Source: `_bmad-output/planning-artifacts/epics.md`](Epic 8 - 区域管理 E2E 测试) - [Source: `_bmad-output/planning-artifacts/architecture.md`](测试架构和标准) - [Source: `_bmad-output/project-context.md`](项目上下文和规则) **前置 Story 参考:** - [Source: `_bmad-output/implementation-artifacts/8-1-region-page-object.md`](RegionManagementPage 实现) - [Source: `_bmad-output/implementation-artifacts/8-3-add-region-test.md`](添加区域测试实现) - [Source: `_bmad-output/implementation-artifacts/8-4-edit-region-test.md`](编辑区域测试实现) **代码参考:** - [Source: `web/tests/e2e/pages/admin/region-management.page.ts`](Page Object 实现) - [Source: `web/tests/e2e/specs/admin/region-add.spec.ts`](添加区域测试) - [Source: `web/tests/e2e/specs/admin/region-edit.spec.ts`](编辑区域测试) - [Source: `web/tests/e2e/utils/test-setup.ts`](Test fixtures) ## Dev Agent Record ### Agent Model Used - Model: Claude (Sonnet) - Date: 2026-01-11 ### Debug Log References 无调试问题(Story 创建阶段) ### Completion Notes List **Story 实现完成 (2026-01-11):** - ✅ 创建了 `web/tests/e2e/specs/admin/region-delete.spec.ts` 测试文件 - ✅ 实现了删除区域的所有核心测试场景: - 删除无子级的省级区域(通过) - 删除成功后显示成功提示消息(通过) - 阻止删除包含子级区域的父区域(通过) - 显示清晰的错误消息(通过) - 有子级的区域删除后仍存在(通过) - 取消删除应保持区域存在(通过) - 确认删除应移除区域(通过) - 取消后再次删除应该可以成功(通过) - 每个测试使用唯一的区域名称(通过) - 删除不存在的区域应该失败(通过) - ✅ 修复了 `RegionManagementPage.cancelDelete()` 方法中的选择器问题 **代码审查修复 (2026-01-11):** - ✅ **[HIGH] 增强 `openDeleteDialog` 方法**:支持展开父节点查找子区域(市/区/街道),类似 `openEditDialog` 的实现 - ✅ **[HIGH] 修复测试数据清理逻辑**:移除了测试执行过程中修改清理数组的代码,避免跳过清理 - ✅ **[MEDIUM] 替换固定等待为条件等待**:Toast 消息检查使用 `waitFor()` 而非 `waitForTimeout()` - ✅ **[MEDIUM] 改进错误消息断言**:添加对"子区域"等关键词的验证 - ✅ **[MEDIUM] 添加 `waitForTreeLoaded()`**:在 `createChildRegion` 后等待树形结构刷新 - ✅ 所有之前跳过的 5 个测试现已启用 **测试结果:** - 15 个测试通过(所有测试) - 0 个测试跳过 - 0 个测试失败 **技术问题记录:** 1. **[已修复] 子区域查找问题**: `openDeleteDialog` 方法已增强,现在支持展开父节点查找子区域 2. **[已修复] cancelDelete 选择器问题**: 已修复为使用 `.locator().getByRole()` 模式 ### File List **Story 文档:** - `_bmad-output/implementation-artifacts/8-5-delete-region-test.md` (本文件) **已创建文件:** - `web/tests/e2e/specs/admin/region-delete.spec.ts` (测试文件) **已修改文件:** - `web/tests/e2e/pages/admin/region-management.page.ts` (修复了 `cancelDelete()` 方法的选择器问题;增强了 `openDeleteDialog()` 方法以支持查找子区域) **参考文件 (只读):** - `web/tests/e2e/specs/admin/region-add.spec.ts` - `web/tests/e2e/specs/admin/region-edit.spec.ts` - `web/tests/e2e/utils/test-setup.ts` ## Project Context Reference ### 关键项目规则摘要 **技术栈:** - Playwright 1.55.0 - E2E 测试框架 - TypeScript 5.9.3 - 严格模式 - @d8d/e2e-test-utils - 内部测试工具包 - Node.js 20.19.2 - pnpm 10.18.3 - 包管理 **测试命令:** ```bash # 运行删除区域测试 cd web pnpm test:e2e:chromium region-delete # 快速失败模式(调试) timeout 60 pnpm test:e2e:chromium region-delete # 运行所有区域管理测试 pnpm test:e2e:chromium region-*.spec.ts # 运行所有 E2E 测试 pnpm test:e2e:chromium ``` **命名约定:** - 测试文件名: kebab-case + `.spec.ts` 后缀 - 测试组: 使用 `test.describe.serial()` 分组 - 测试名称: 中文描述,格式 "应该..." ### 必须遵循的架构决策 **来自 Architecture.md 的关键决策:** 1. **选择器策略(混合策略优先级):** - `data-testid` - 最高优先级 - `aria-label` + role - 无障碍标准 - Text content + role - 兜底方案 2. **错误处理策略:** - 使用 `E2ETestError` 类(来自 e2e-test-utils) - 包含完整 ErrorContext 3. **测试隔离:** - 每个测试使用独立数据 - 测试后清理数据 - 支持并行执行(使用 `test.describe.serial` 时串行) 4. **TypeScript 严格模式:** - 所有变量必须有明确类型 - 禁止使用 `any` 类型 - 使用 `import` 配合 `vi.mocked`(Vitest) ### TypeScript + Playwright 陷阱预防 **来自 Architecture.md "TypeScript + Playwright 常见陷阱" 部分:** ⚠️ **DOM 结构假设必须验证** - Story 8.1 已验证 DOM 结构 - 使用 RegionManagementPage 的封装方法 ✅ **正确做法:** ```typescript // 使用 Page Object 封装的方法 await regionManagementPage.openDeleteDialog(regionName); await regionManagementPage.confirmDelete(); // 验证删除后等待树形结构刷新 await regionManagementPage.waitForTreeLoaded(); const exists = await regionManagementPage.regionExists(regionName); ``` ❌ **避免:** ```typescript // 避免直接操作 DOM await page.locator('.dialog .delete-button').click(); // 避免假设状态立即更新 const exists = await regionExists(regionName); expect(exists).toBe(false); // 可能需要等待 ``` ### 代码质量检查清单 **代码质量:** - [ ] 测试用例有清晰的描述 - [ ] 使用 `test.describe.serial()` 组织相关测试 - [ ] 每个测试独立运行,不依赖其他测试 **测试数据:** - [ ] 使用唯一标识符避免数据冲突 - [ ] 测试后清理测试数据 - [ ] 使用 `beforeEach`/`afterEach` 钩子 **错误处理:** - [ ] 失败时有清晰的错误消息 - [ ] 使用 try-catch 处理清理操作 ### 参考文档位置 | 文档 | 路径 | |------|------| | PRD | `_bmad-output/planning-artifacts/prd.md` | | Architecture | `_bmad-output/planning-artifacts/architecture.md` | | Epics | `_bmad-output/planning-artifacts/epics.md` | | Project Context | `_bmad-output/project-context.md` | | Story 8.1 | `_bmad-output/implementation-artifacts/8-1-region-page-object.md` | | Story 8.3 | `_bmad-output/implementation-artifacts/8-3-add-region-test.md` | | Story 8.4 | `_bmad-output/implementation-artifacts/8-4-edit-region-test.md` | | RegionManagementPage | `web/tests/e2e/pages/admin/region-management.page.ts` | | 参考测试 | `web/tests/e2e/specs/admin/region-add.spec.ts` | | test-setup | `web/tests/e2e/utils/test-setup.ts` | ### 相关 Epic 和 Story **前置 Epic:** - Epic 1: ✅ 完成 - Select 工具基础框架 - Epic 2: ✅ 完成 - Select 工具在真实 E2E 测试中验证 **当前 Epic (Epic 8):** - Story 8.1: ✅ 完成 - 创建区域管理 Page Object - Story 8.2: ✅ 完成 - 编写区域列表查看测试 - Story 8.3: ✅ 完成 - 编写添加区域测试 - Story 8.4: ✅ 完成 - 编写编辑区域测试 - Story 8.5: 📝 当前 - 编写删除区域测试 - Story 8.6: ⏳ 待开始 - 编写级联选择完整流程测试 **后续 Epic:** - Epic 9: 🔄 进行中 - 残疾人管理完整 E2E 测试覆盖 - Epic 10: 🔄 进行中 - 订单管理 E2E 测试 ## Completion Status **Story ID:** 8.5 **Story Key:** 8-5-delete-region-test **Epic:** Epic 8 - 区域管理 E2E 测试 (Epic B) **Status:** done **交付物:** - [x] Story 文档创建完成 - [x] 删除区域测试实现 - [x] 测试在真实浏览器中通过 **实现摘要:** - 创建了 `web/tests/e2e/specs/admin/region-delete.spec.ts` 测试文件 - 实现了以下测试场景: - 删除无子级的省级区域 - 删除成功后显示成功提示消息 - 阻止删除包含子级区域的父区域 - 显示清晰的错误消息 - 有子级的区域删除后仍存在 - 取消删除应保持区域存在 - 确认删除应移除区域 - 取消后再次删除应该可以成功 - 每个测试使用唯一的区域名称 - 删除不存在的区域应该失败 - 修复了 `RegionManagementPage.cancelDelete()` 方法中的选择器问题 **测试结果:** - 15 个测试通过 - 0 个测试跳过 - 0 个测试失败 **下一步操作:** 1. ✅ 代码审查已完成,所有 HIGH 和 MEDIUM 问题已修复 2. ✅ 15 个测试全部通过,0 个跳过,0 个失败 3. ➡️ 进入 Story 8.6(级联选择完整流程测试)