2-2-rewrite-static-select.md 19 KB

Story 2.2: 使用 selectRadixOption 重写残疾类型选择

Status: ready-for-dev

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. 导入工具函数

在文件顶部添加导入:

// web/tests/e2e/pages/admin/disability-person.page.ts
import { Page, Locator } from '@playwright/test';
import { selectRadixOption } from '@d8d/e2e-test-utils';  // 新增

2. 替换调用方式

原实现(自定义方法):

// this.selectRadixOption 是类方法
await this.selectRadixOption('残疾类型 *', data.disabilityType);

新实现(工具函数):

// 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 方法:

// 删除以下代码(第 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,如果需要保留调试输出,可以在调用后添加:

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. 代码修改完成后:

    # 类型检查
    pnpm typecheck
    
  2. 运行测试:

    # 运行残疾人管理完整流程测试
    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 包:

import { selectRadixOption } from '@d8d/e2e-test-utils';

// 函数签名
selectRadixOption(page: Page, label: string, value: string): Promise<void>

参数说明:

  • page: Playwright Page 对象
  • label: 下拉框的标签文本(用于定位触发器)
  • value: 要选择的选项值

返回值: Promise

异常: 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

现有代码(需要修改的部分):

// 第 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. 完整修改示例

修改前:

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}`);
}

修改后:

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 严格模式

工具包类型定义:

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

导出的函数:

// 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. 代码修改完成后:

# 类型检查
pnpm typecheck

2. 运行测试:

# 运行残疾人管理完整流程测试
pnpm test:e2e:chromium disability-person-complete.spec.ts

3. 观察点:

  • ✅ 所有 Select 操作正常完成
  • ✅ 无 E2ETestError 错误
  • ✅ 测试通过
  • ✅ 控制台输出正常(如有添加 console.debug)

预期测试行为

测试场景:

// 测试代码会调用 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 情报和项目上下文引用