Просмотр исходного кода

docs(e2e-test-utils): 创建 Story 2.2 重写静态 Select 工具

创建完整的 Story 2.2 文档,包含:
- 用户故事和验收标准(替换静态 Select 调用)
- 详细的任务分解(7 个修改步骤)
- 开发者上下文(技术需求、架构合规性、测试要求)
- Epic 1 回顾经验总结(TypeScript + Playwright 陷阱)
- Git 情报和项目上下文引用

更新 sprint-status.yaml:Story 2.2 状态 backlog → ready-for-dev

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 1 неделя назад
Родитель
Сommit
07814c2795

+ 607 - 0
_bmad-output/implementation-artifacts/2-2-rewrite-static-select.md

@@ -0,0 +1,607 @@
+# Story 2.2: 使用 selectRadixOption 重写残疾类型选择
+
+Status: ready-for-dev
+
+<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
+
+## Story
+
+作为测试开发者,
+我想要使用 `selectRadixOption()` 替换 Page Object 中的 Select 操作,
+以便验证工具在静态 Select 场景中的可用性。
+
+## Acceptance Criteria
+
+1. **Given** @d8d/e2e-test-utils 已安装(Story 2.1 完成)
+2. **When** 修改 `web/tests/e2e/pages/admin/disability-person.page.ts`
+3. **Then** `fillBasicForm()` 中的残疾类型选择使用 `selectRadixOption()`
+4. **And** `fillBasicForm()` 中的残疾等级选择使用 `selectRadixOption()`
+5. **And** `addBankCard()` 中的银行卡名称选择使用 `selectRadixOption()`
+6. **And** `addBankCard()` 中的银行卡类型选择使用 `selectRadixOption()`(如适用)
+7. **And** `addVisit()` 中的回访类型选择使用 `selectRadixOption()`
+8. **And** 移除原有的 `selectRadixOption()` 方法(第 96-108 行)
+9. **And** 测试通过,功能正常
+
+## Tasks / Subtasks
+
+- [ ] 导入 selectRadixOption 工具函数 (AC: #1)
+  - [ ] 在文件顶部添加 `import { selectRadixOption } from '@d8d/e2e-test-utils'`
+- [ ] 替换 fillBasicForm 中的静态 Select 调用 (AC: #3, #4)
+  - [ ] 替换残疾类型选择:`await selectRadixOption(this.page, '残疾类型 *', data.disabilityType)`
+  - [ ] 替换残疾等级选择:`await selectRadixOption(this.page, '残疾等级 *', data.disabilityLevel)`
+- [ ] 替换 addBankCard 中的静态 Select 调用 (AC: #5, #6)
+  - [ ] 替换银行名称选择:`await selectRadixOption(this.page, '银行名称', bankCard.bankName)`
+  - [ ] 替换银行卡类型选择(如适用):`await selectRadixOption(this.page, '银行卡类型', bankCard.cardType)`
+- [ ] 替换 addVisit 中的静态 Select 调用 (AC: #7)
+  - [ ] 替换回访类型选择:`await selectRadixOption(this.page, '回访类型', visit.visitType)`
+- [ ] 移除原有的 selectRadixOption 方法 (AC: #8)
+  - [ ] 删除第 96-108 行的自定义方法实现
+- [ ] 验证测试通过 (AC: #9)
+  - [ ] 运行 `pnpm test:e2e:chromium disability-person-complete.spec.ts`
+  - [ ] 确认所有 Select 操作正常工作
+
+## Dev Notes
+
+### Epic Context
+
+**Epic 2 目标:** 在 `web/tests/e2e/` 的现有残疾人管理测试中使用 Select 工具,验证工具在真实场景中的可用性和稳定性。
+
+**Epic 2 范围:**
+- ✅ 使用现有 `web/tests/e2e/` 测试基础设施
+- ✅ 使用现有的残疾人管理测试场景
+- ❌ 不创建新的测试应用
+- ❌ 不添加新功能(仅验证现有功能)
+
+### 验证场景
+
+**静态 Select 场景(本故事):**
+- 残疾类型:视力残疾、听力残疾、肢体残疾、言语残疾、智力残疾、精神残疾
+- 残疾等级:一级、二级、三级、四级
+- 银行名称(addBankCard 方法)
+- 银行卡类型(addBankCard 方法,可选)
+- 回访类型(addVisit 方法)
+
+**异步 Select 场景(Story 2.3):**
+- 省份选择
+- 城市选择(根据省份动态加载)
+
+### 实现要点
+
+#### 1. 导入工具函数
+
+在文件顶部添加导入:
+
+```typescript
+// web/tests/e2e/pages/admin/disability-person.page.ts
+import { Page, Locator } from '@playwright/test';
+import { selectRadixOption } from '@d8d/e2e-test-utils';  // 新增
+```
+
+#### 2. 替换调用方式
+
+**原实现(自定义方法):**
+```typescript
+// this.selectRadixOption 是类方法
+await this.selectRadixOption('残疾类型 *', data.disabilityType);
+```
+
+**新实现(工具函数):**
+```typescript
+// selectRadixOption 是导入的工具函数,需要传入 page 对象
+await selectRadixOption(this.page, '残疾类型 *', data.disabilityType);
+```
+
+**关键差异:**
+- 工具函数需要显式传入 `page` 对象作为第一个参数
+- 函数签名:`selectRadixOption(page: Page, label: string, value: string): Promise<void>`
+
+#### 3. 需要替换的位置
+
+| 方法 | 行号 | Select 字段 | 标签文本 |
+|------|------|-------------|----------|
+| `fillBasicForm` | 85 | 残疾类型 | `残疾类型 *` |
+| `fillBasicForm` | 86 | 残疾等级 | `残疾等级 *` |
+| `addBankCard` | 239 | 银行名称 | `银行名称` |
+| `addBankCard` | 246 | 银行卡类型 | `银行卡类型` |
+| `addVisit` | 310 | 回访类型 | `回访类型` |
+
+#### 4. 移除自定义方法
+
+删除第 96-108 行的自定义 `selectRadixOption` 方法:
+
+```typescript
+// 删除以下代码(第 96-108 行)
+async selectRadixOption(label: string, value: string) {
+  const combobox = this.page.getByRole('combobox', { name: label });
+  await combobox.click({ timeout: 2000 });
+  const option = this.page.getByRole('option', { name: value }).first();
+  await option.click({ timeout: 3000 });
+  console.log(`  ✓ ${label} 选中: ${value}`);
+}
+```
+
+#### 5. 保留 console.log 输出
+
+工具函数内部没有 console.log,如果需要保留调试输出,可以在调用后添加:
+
+```typescript
+await selectRadixOption(this.page, '残疾类型 *', data.disabilityType);
+console.debug(`  ✓ 残疾类型选中: ${data.disabilityType}`);
+```
+
+### 依赖关系
+
+**前置依赖:**
+- Epic 1: ✅ 已完成(Select 工具已开发)
+- Story 2.1: ✅ 已完成(@d8d/e2e-test-utils 已安装)
+
+**后续故事:**
+- Story 2.3: 使用 selectRadixOptionAsync 重写省份/城市选择
+- Story 2.4: 运行测试并收集问题和改进建议
+
+### 验证步骤
+
+1. **代码修改完成后:**
+   ```bash
+   # 类型检查
+   pnpm typecheck
+   ```
+
+2. **运行测试:**
+   ```bash
+   # 运行残疾人管理完整流程测试
+   pnpm test:e2e:chromium disability-person-complete.spec.ts
+   ```
+
+3. **观察点:**
+   - Select 操作是否正常完成
+   - 是否有任何错误消息
+   - 测试是否通过
+
+### 错误处理
+
+如果工具函数抛出 `E2ETestError`:
+
+1. **触发器未找到:**
+   ```
+   Error: Radix Select 触发器未找到
+     标签: 残疾类型 *
+     期望值: 视力残疾
+     建议: 检查下拉框标签是否正确,或添加 data-testid 属性
+   ```
+   - 检查标签文本是否正确(注意空格和星号)
+   - 确认元素是否在页面上可见
+
+2. **选项未找到:**
+   ```
+   Error: Radix Select 选项 "xxx" 未找到
+     标签: 残疾类型
+     期望值: xxx
+     可用选项: 视力残疾, 听力残疾, 肢体残疾, ...
+     建议: 检查选项值是否正确,或确认选项已加载到 DOM 中
+   ```
+   - 检查选项值是否与页面显示完全一致
+   - 确认选项是否已加载到 DOM 中
+
+### Project Structure Notes
+
+**文件路径:**
+```
+web/tests/e2e/pages/admin/disability-person.page.ts
+```
+
+**相关测试文件:**
+```
+web/tests/e2e/specs/admin/disability-person-complete.spec.ts
+```
+
+**工具包位置:**
+```
+packages/e2e-test-utils/
+```
+
+### References
+
+**Epic 2 详情:** `_bmad-output/planning-artifacts/epics.md#Epic-2`
+
+**Story 2.1(安装):** `_bmad-output/implementation-artifacts/2-1-install-e2e-utils.md`
+
+**Epic 1 回顾(技术经验):** `_bmad-output/implementation-artifacts/epic-1-retrospective.md`
+
+**工具包源码:** `packages/e2e-test-utils/src/radix-select.ts`
+
+**项目上下文:** `_bmad-output/project-context.md`
+
+---
+
+## Developer Context
+
+> **重要提示:** 本部分包含开发者实现此故事所需的所有关键上下文和约束条件。
+
+### 技术需求
+
+#### 1. 工具函数签名
+
+`selectRadixOption` 函数来自 `@d8d/e2e-test-utils` 包:
+
+```typescript
+import { selectRadixOption } from '@d8d/e2e-test-utils';
+
+// 函数签名
+selectRadixOption(page: Page, label: string, value: string): Promise<void>
+```
+
+**参数说明:**
+- `page`: Playwright Page 对象
+- `label`: 下拉框的标签文本(用于定位触发器)
+- `value`: 要选择的选项值
+
+**返回值:** Promise<void>
+
+**异常:** `E2ETestError` - 当触发器或选项未找到时
+
+#### 2. 选择器策略
+
+工具函数按以下优先级查找触发器:
+
+1. **data-testid(推荐):** `[data-testid="${label}-trigger"]`
+2. **aria-label + role:** `[aria-label="${label}"][role="combobox"]`
+3. **文本匹配(兜底):** `text="${label}"`
+
+**选项选择策略:**
+1. **data-value(精确匹配):** `[role="option"][data-value="${value}"]`
+2. **精确文本匹配:** `[role="option"]:text-is("${value}")`
+
+#### 3. 当前实现分析
+
+**文件位置:** `web/tests/e2e/pages/admin/disability-person.page.ts`
+
+**现有代码(需要修改的部分):**
+
+```typescript
+// 第 1-2 行:导入部分
+import { Page, Locator } from '@playwright/test';
+// 需要添加:import { selectRadixOption } from '@d8d/e2e-test-utils';
+
+// 第 64-94 行:fillBasicForm 方法
+async fillBasicForm(data: {
+  // ... 数据字段
+}) {
+  await this.page.waitForSelector('form#create-form', { state: 'visible', timeout: 5000 });
+  await this.page.getByLabel('姓名 *').fill(data.name);
+  await this.page.getByLabel('性别 *').selectOption(data.gender);  // 原生 select,不需要改
+  await this.page.getByLabel('身份证号 *').fill(data.idCard);
+  await this.page.getByLabel('残疾证号 *').fill(data.disabilityId);
+  await this.selectRadixOption('残疾类型 *', data.disabilityType);  // ❌ 需要替换
+  await this.selectRadixOption('残疾等级 *', data.disabilityLevel); // ❌ 需要替换
+  // ... 其他代码
+  await this.selectRadixOption('省份 *', data.province);  // Story 2.3 处理
+  await this.page.waitForTimeout(500);  // Story 2.3 会移除这个 hack
+  await this.selectRadixOption('城市', data.city);  // Story 2.3 处理
+}
+
+// 第 96-108 行:自定义方法(需要删除)
+async selectRadixOption(label: string, value: string) {
+  const combobox = this.page.getByRole('combobox', { name: label });
+  await combobox.click({ timeout: 2000 });
+  const option = this.page.getByRole('option', { name: value }).first();
+  await option.click({ timeout: 3000 });
+  console.log(`  ✓ ${label} 选中: ${value}`);
+}
+
+// 第 225-261 行:addBankCard 方法
+async addBankCard(bankCard: {
+  bankName: string;
+  subBankName: string;
+  cardNumber: string;
+  cardholderName: string;
+  cardType?: string;
+  photoFileName?: string;
+}) {
+  await this.page.getByRole('button', { name: /添加银行卡/ }).click();
+  await this.page.waitForTimeout(300);
+  await this.selectRadixOption('银行名称', bankCard.bankName);  // ❌ 需要替换
+  await this.page.getByLabel(/发卡支行/).fill(bankCard.subBankName);
+  await this.page.getByLabel(/银行卡号/).fill(bankCard.cardNumber);
+  await this.page.getByLabel(/持卡人姓名/).fill(bankCard.cardholderName);
+  if (bankCard.cardType) {
+    await this.selectRadixOption('银行卡类型', bankCard.cardType);  // ❌ 需要替换
+  }
+  // ... 其他代码
+}
+
+// 第 296-332 行:addVisit 方法
+async addVisit(visit: {
+  visitDate: string;
+  visitType: string;
+  visitContent: string;
+  visitResult?: string;
+  nextVisitDate?: string;
+}) {
+  await this.page.getByRole('button', { name: /添加回访/ }).click();
+  await this.page.waitForTimeout(300);
+  await this.page.getByLabel(/回访日期/).fill(visit.visitDate);
+  await this.selectRadixOption('回访类型', visit.visitType);  // ❌ 需要替换
+  // ... 其他代码
+}
+```
+
+#### 4. 修改清单
+
+| 步骤 | 操作 | 位置 | 详情 |
+|------|------|------|------|
+| 1 | 添加导入 | 第 3 行后 | `import { selectRadixOption } from '@d8d/e2e-test-utils';` |
+| 2 | 替换调用 | 第 85 行 | `await selectRadixOption(this.page, '残疾类型 *', data.disabilityType)` |
+| 3 | 替换调用 | 第 86 行 | `await selectRadixOption(this.page, '残疾等级 *', data.disabilityLevel)` |
+| 4 | 替换调用 | 第 239 行 | `await selectRadixOption(this.page, '银行名称', bankCard.bankName)` |
+| 5 | 替换调用 | 第 246 行 | `await selectRadixOption(this.page, '银行卡类型', bankCard.cardType)` |
+| 6 | 替换调用 | 第 310 行 | `await selectRadixOption(this.page, '回访类型', visit.visitType)` |
+| 7 | 删除方法 | 第 96-108 行 | 删除整个 `selectRadixOption` 方法 |
+
+#### 5. 完整修改示例
+
+**修改前:**
+```typescript
+async fillBasicForm(data: { /* ... */ }) {
+  // ...
+  await this.selectRadixOption('残疾类型 *', data.disabilityType);
+  await this.selectRadixOption('残疾等级 *', data.disabilityLevel);
+  // ...
+}
+
+async selectRadixOption(label: string, value: string) {
+  const combobox = this.page.getByRole('combobox', { name: label });
+  await combobox.click({ timeout: 2000 });
+  const option = this.page.getByRole('option', { name: value }).first();
+  await option.click({ timeout: 3000 });
+  console.log(`  ✓ ${label} 选中: ${value}`);
+}
+```
+
+**修改后:**
+```typescript
+import { selectRadixOption } from '@d8d/e2e-test-utils';  // 新增
+
+async fillBasicForm(data: { /* ... */ }) {
+  // ...
+  await selectRadixOption(this.page, '残疾类型 *', data.disabilityType);  // 修改
+  await selectRadixOption(this.page, '残疾等级 *', data.disabilityLevel); // 修改
+  // ...
+}
+
+// 删除 selectRadixOption 方法
+```
+
+### 架构合规性
+
+#### 工具包设计原则
+
+**单一职责:**
+- `selectRadixOption` 专注于静态 Select 选择
+- 不处理异步加载场景(使用 `selectRadixOptionAsync`)
+
+**错误处理:**
+- 使用 `E2ETestError` 提供结构化错误信息
+- 错误包含:操作类型、目标、期望值、可用选项、修复建议
+
+**超时配置:**
+- 静态 Select 默认超时:2000ms(`DEFAULT_TIMEOUTS.static`)
+- 使用 Playwright 的 auto-waiting 机制
+
+#### TypeScript 严格模式
+
+**工具包类型定义:**
+```typescript
+// packages/e2e-test-utils/src/radix-select.ts
+export async function selectRadixOption(
+  page: Page,
+  label: string,
+  value: string
+): Promise<void>
+```
+
+**使用时类型检查:**
+- IDE 自动补全函数参数
+- 编译时检查参数类型
+- 错误时抛出 `E2ETestError`
+
+### 库和框架要求
+
+#### Playwright 版本
+
+| 包 | 版本 | 要求 |
+|---|------|------|
+| @playwright/test (web) | 1.55.0 | ✅ 满足 ^1.40.0 |
+| @d8d/e2e-test-utils | - | peer dependency: ^1.40.0 |
+
+#### 工具包 API
+
+**导出的函数:**
+```typescript
+// packages/e2e-test-utils/src/index.ts
+export { selectRadixOption } from './radix-select.js';
+export { selectRadixOptionAsync } from './radix-select.js';
+
+// 类型
+export type { BaseOptions } from './types.js';
+export type { AsyncSelectOptions } from './radix-select.js';
+
+// 错误
+export { E2ETestError } from './errors.js';
+export type { ErrorContext } from './errors.js';
+```
+
+### 文件结构要求
+
+#### 修改文件清单
+
+**主要修改:**
+- `web/tests/e2e/pages/admin/disability-person.page.ts`
+
+**不影响其他文件:**
+- `web/tests/e2e/specs/admin/disability-person-complete.spec.ts`(测试文件不需要修改)
+- `web/package.json`(依赖已在 Story 2.1 添加)
+
+#### 工具包位置
+
+```
+packages/e2e-test-utils/
+├── src/
+│   ├── radix-select.ts      # selectRadixOption 实现
+│   ├── types.ts             # 类型定义
+│   ├── errors.ts            # 错误处理
+│   ├── constants.ts         # 常量(超时配置)
+│   └── index.ts             # 主导出
+├── package.json
+└── README.md
+```
+
+### 测试要求
+
+#### 验证步骤
+
+**1. 代码修改完成后:**
+```bash
+# 类型检查
+pnpm typecheck
+```
+
+**2. 运行测试:**
+```bash
+# 运行残疾人管理完整流程测试
+pnpm test:e2e:chromium disability-person-complete.spec.ts
+```
+
+**3. 观察点:**
+- ✅ 所有 Select 操作正常完成
+- ✅ 无 `E2ETestError` 错误
+- ✅ 测试通过
+- ✅ 控制台输出正常(如有添加 console.debug)
+
+#### 预期测试行为
+
+**测试场景:**
+```typescript
+// 测试代码会调用 fillBasicForm
+await page.fillBasicForm({
+  name: '测试用户',
+  gender: '男',
+  idCard: '110101199001011234',
+  disabilityId: '1101011990',
+  disabilityType: '视力残疾',  // 使用 selectRadixOption
+  disabilityLevel: '一级',     // 使用 selectRadixOption
+  phone: '13800138000',
+  idAddress: '北京市东城区',
+  province: '广东省',          // Story 2.3 处理
+  city: '深圳市',              // Story 2.3 处理
+});
+```
+
+**预期结果:**
+- 残疾类型下拉框展开并选中"视力残疾"
+- 残疾等级下拉框展开并选中"一级"
+- 测试继续执行,无错误
+
+### 上一个故事的经验(Epic 1 Retrospective)
+
+#### 关键经验总结
+
+**1. TypeScript + Playwright 陷阱 [来源: epic-1-retrospective.md]**
+- ❌ 避免使用 `page.evaluate()` 获取文本
+- ✅ 使用 Playwright API:`page.locator().allTextContents()`
+
+**2. 精确文本匹配 [来源: epic-1-retrospective.md]**
+- ❌ 使用 `:has-text()` 会部分匹配,可能误选
+- ✅ 使用 `:text-is()` 进行精确匹配
+- 工具函数已内置此修复,无需担心
+
+**3. 代码审查发现的问题类型:**
+- HIGH: DOM 类型问题、精确文本匹配
+- MEDIUM: 错误消息不清晰
+- LOW: 代码风格
+
+**4. 测试覆盖率目标:**
+- ≥80% 覆盖率(Epic 1 已达 93.65%)
+
+#### 需要注意的技术决策
+
+**选择器策略优先级:**
+1. `data-testid` - 最高优先级(推荐在 Radix 组件上添加)
+2. `aria-label` + role - 无障碍标准
+3. Text content + role - 兜底方案
+
+**错误处理模式:**
+- 使用 `E2ETestError` 而非原生 `Error`
+- 提供结构化的错误上下文(标签、期望值、可用选项)
+
+### Git Intelligence
+
+**最近 5 次提交:**
+```
+02ece3b fix(e2e-test-utils): 完成 Story 2.1 代码审查修复
+35bde40 docs(e2e-test-utils): 创建 Story 2.1 安装 e2e-utils 包
+54bded5 docs: 完成 Epic 1 回顾及技术改进
+f72ea78 test(e2e-test-utils): 完成 Story 1.6 Select 工具单元测试及代码审查
+5350962 ✨ feat(rpc-client): 添加辅助函数以不区分大小写获取响应头
+```
+
+**相关文件修改历史:**
+- `web/tests/e2e/pages/admin/disability-person.page.ts` - 在 Story 2.1 中添加了注释说明工具包已安装
+
+**代码模式:**
+- 提交信息使用中文
+- 使用 conventional commits 格式(feat, fix, docs 等)
+
+### 项目上下文引用
+
+**完整项目上下文:** `_bmad-output/project-context.md`
+
+**关键规范:**
+- 测试框架:Playwright 1.55.0
+- 包管理:pnpm workspace 协议
+- TypeScript:严格模式,无 `any` 类型
+- 测试命令:`pnpm test:e2e:chromium`
+
+**相关文档:**
+- Epic 2 详情:`_bmad-output/planning-artifacts/epics.md#Epic-2`
+- Story 2.1:`_bmad-output/implementation-artifacts/2-1-install-e2e-utils.md`
+- Epic 1 回顾:`_bmad-output/implementation-artifacts/epic-1-retrospective.md`
+
+### 下一步
+
+完成本故事后,继续:
+- Story 2.3: 使用 selectRadixOptionAsync 重写省份/城市选择
+- Story 2.4: 运行测试并收集问题和改进建议
+
+---
+
+## Dev Agent Record
+
+### Agent Model Used
+
+Claude Opus 4.5 (claude-opus-4-5-20251101)
+
+### Debug Log References
+
+(开发过程中添加调试日志引用)
+
+### Completion Notes List
+
+(实现完成后添加完成备注)
+
+### File List
+
+(实现完成后添加修改文件列表)
+
+### Change Log
+
+**创建时间:** 2026-01-09
+
+**创建内容:**
+- 完整的 Story 2.2 文档,包含:
+  - 用户故事和验收标准
+  - 详细的任务分解
+  - 开发者上下文(技术需求、架构合规性、测试要求)
+  - Epic 1 回顾经验总结
+  - Git 情报和项目上下文引用
+

+ 1 - 1
_bmad-output/implementation-artifacts/sprint-status.yaml

@@ -54,7 +54,7 @@ development_status:
   # 详情参见: _bmad-output/implementation-artifacts/epic-1-retrospective.md
   epic-2: in-progress
   2-1-install-e2e-utils: done
-  2-2-rewrite-static-select: backlog
+  2-2-rewrite-static-select: ready-for-dev
   2-3-rewrite-async-select: backlog
   2-4-run-tests-collect-feedback: backlog
   2-5-fix-found-issues: backlog