# Story 1.4: 实现异步 Select 工具函数

Status: done

<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->

## Story

作为测试开发者，
我想要使用 `selectRadixOptionAsync()` 函数选择异步加载的下拉框，
以便测试省份、城市等动态加载的选项。

## Acceptance Criteria

**Given** 静态 Select 函数已实现（Story 1.3 完成）
**When** 实现 `selectRadixOptionAsync(page, label, value, options?)` 函数
**Then** 支持 `AsyncSelectOptions` 配置（timeout, waitForOption）
**And** 使用 `waitForLoadState('networkidle')` 等待异步加载
**And** 默认超时 5 秒，可配置
**And** 超时时提供清晰错误消息（标签、期望值、超时时间、可能原因）
**And** 所有导出函数有完整的 JSDoc 注释

## Tasks / Subtasks

- [x] 实现 `src/radix-select.ts` - 异步 Select 工具函数 (AC: 1, 2, 3, 4, 5)
  - [x] 实现 `selectRadixOptionAsync(page, label, value, options?)` 主函数
  - [x] 复用 `findTrigger` 逻辑查找下拉框触发器
  - [x] 实现异步选项等待逻辑（网络空闲 + 选项可见）
  - [x] 实现可配置超时机制（默认 5000ms）
  - [x] 添加超时错误处理（包含超时时间、可能原因）
  - [x] 为所有导出函数添加完整 JSDoc 注释
- [x] 更新 `src/index.ts` 导出新增函数 (AC: 6)
- [x] 类型检查通过验证
  - [x] 运行 `pnpm typecheck` 确保无类型错误

## Dev Notes

### Epic 1 背景

**Epic 1 目标：** 测试开发者可以安装 `@d8d/e2e-test-utils` 包，立即使用 Select 工具测试 Radix UI Select 组件。

**本故事在 Epic 中的位置：** 第四个故事，实现异步 Select 工具函数。这是在静态 Select 基础上的扩展，处理动态加载选项的场景。

### 架构约束和模式

**从架构文档中必须遵循的决策：**

**API 设计模式：4 个参数（包含可选配置对象）**
- 必需参数：`page`, `label`, `value`
- 可选参数：`options?: AsyncSelectOptions`
- 与静态 Select 保持一致的接口风格

**类型定义：已在 Story 1.2 中定义**
```typescript
export interface AsyncSelectOptions extends BaseOptions {
  /** 是否等待选项加载完成（默认：true）*/
  waitForOption?: boolean;
  /** 等待网络空闲后再操作（默认：false）*/
  waitForNetworkIdle?: boolean;
}
```

**错误处理策略：超时错误需要特殊处理**
- 超时错误应包含：超时时间、期望值、可能原因
- 区分"选项未找到"和"等待超时"两种错误场景
- 使用 `E2ETestError` 类保持错误格式一致

### 技术实现要求

**`src/radix-select.ts` - 异步 Select 工具函数实现指南：**

```typescript
/**
 * 选择 Radix UI 下拉框的异步加载选项
 *
 * @description
 * 用于选择通过 API 异步加载的 Radix UI Select 选项。
 * 自动等待网络请求完成和选项出现在 DOM 中。
 *
 * 支持的选择器策略（按优先级）：
 * 1. `data-testid="${label}-trigger"` - 推荐，最稳定
 * 2. `aria-label="${label}"` + `role="combobox"` - 无障碍属性
 * 3. `text="${label}"` - 文本匹配（兜底）
 *
 * @param page - Playwright Page 对象
 * @param label - 下拉框的标签文本（用于定位触发器）
 * @param value - 要选择的选项值
 * @param options - 可选配置
 * @throws {E2ETestError} 当触发器未找到或等待超时时
 *
 * @example
 * ```ts
 * // 选择省份（异步加载）
 * await selectRadixOptionAsync(page, '省份', '广东省');
 *
 * // 选择城市（自定义超时）
 * await selectRadixOptionAsync(page, '城市', '深圳市', {
 *   timeout: 10000,
 *   waitForNetworkIdle: true
 * });
 * ```
 */
export async function selectRadixOptionAsync(
  page: Page,
  label: string,
  value: string,
  options?: AsyncSelectOptions
): Promise<void> {
  // TODO: 实现异步选项选择逻辑
}
```

**异步等待策略实现：**

```typescript
/**
 * 等待异步选项加载并完成选择
 *
 * @internal
 *
 * @param page - Playwright Page 对象
 * @param value - 选项值
 * @param timeout - 超时时间
 * @throws {E2ETestError} 当等待超时时
 */
async function waitForOptionAndSelect(
  page: Page,
  value: string,
  timeout: number
): Promise<void> {
  const startTime = Date.now();

  // 等待选项出现（使用重试机制）
  while (Date.now() - startTime < timeout) {
    try {
      // 尝试查找选项
      const option = await page.waitForSelector(
        `[role="option"][data-value="${value}"]`,
        { timeout: 1000, state: 'visible' }
      );

      if (option) {
        await option.click();
        return; // 成功选择
      }
    } catch {
      // 选项还未出现，继续等待
    }

    // 等待一小段时间后重试
    await page.waitForTimeout(100);
  }

  // 超时：获取当前可用的选项用于错误提示
  const availableOptions = await page.locator('[role="option"]').allTextContents();

  throwError({
    operation: 'selectRadixOptionAsync',
    target: `选项 "${value}"`,
    expected: `在 ${timeout}ms 内加载`,
    available: availableOptions,
    suggestion: '检查网络请求是否正常，或增加超时时间'
  });
}
```

**主函数完整实现：**

```typescript
export async function selectRadixOptionAsync(
  page: Page,
  label: string,
  value: string,
  options?: AsyncSelectOptions
): Promise<void> {
  // 1. 合并默认配置
  const config = {
    timeout: options?.timeout ?? DEFAULT_TIMEOUTS.async,
    waitForOption: options?.waitForOption ?? true,
    waitForNetworkIdle: options?.waitForNetworkIdle ?? false
  };

  // 2. 查找触发器（复用静态 Select 的逻辑）
  const trigger = await findTrigger(page, label, value);

  // 3. 点击触发器展开选项列表
  await trigger.click();

  // 4. 等待选项列表容器出现
  await page.waitForSelector('[role="listbox"]', {
    timeout: DEFAULT_TIMEOUTS.static,
    state: 'visible'
  });

  // 5. 可选：等待网络空闲（处理大量数据加载）
  if (config.waitForNetworkIdle) {
    try {
      await page.waitForLoadState('networkidle', { timeout: config.timeout });
    } catch (err) {
      console.debug('网络空闲等待超时，继续尝试选择选项', err);
    }
  }

  // 6. 等待选项出现并选择
  await waitForOptionAndSelect(page, value, config.timeout);
}
```

### 与前一个故事的集成

**Story 1.3 已完成的工作：**
- ✅ 静态 Select 函数 `selectRadixOption()` 已实现
- ✅ 触发器查找逻辑 `findTrigger()` 已实现
- ✅ 选项查找逻辑 `findAndClickOption()` 已实现
- ✅ 选择器策略（testid → ARIA → text）已实现
- ✅ 错误处理和 JSDoc 注释模式已建立

**本故事需要做的：**
- 复用 `findTrigger()` 函数查找下拉框触发器
- 实现 `waitForOptionAndSelect()` 函数处理异步等待
- 实现 `selectRadixOptionAsync()` 主函数
- 处理网络空闲等待（`waitForLoadState`）
- 更新 `src/index.ts` 导出新函数

### 前一个故事的关键经验

**从 Story 1.3 代码审查中学习到的经验：**

1. **DOM 类型问题解决：** 使用 `page.locator().allTextContents()` 替代 `page.evaluate()` 避免 TypeScript DOM 类型问题

2. **精确文本匹配：** 选项选择器使用 `:text-is()` 而非 `:has-text()` 避免部分匹配误选

3. **完整的 JSDoc 注释：** 内部函数需要完整的 JSDoc（@param, @returns, @throws）

4. **空 catch 块处理：** 添加 `console.debug` 改善调试体验

5. **等待策略优化：** 所有 `waitForSelector` 调用添加 `state: "visible"`

6. **错误上下文完整性：** 确保错误上下文包含所有必要参数

### 异步 Select 与静态 Select 的区别

| 特性 | 静态 Select | 异步 Select |
|------|------------|------------|
| **选项加载时机** | 页面加载时已存在 | 点击触发器后 API 加载 |
| **等待策略** | 立即查找选项 | 等待网络请求 + 选项出现 |
| **默认超时** | 2000ms | 5000ms |
| **错误类型** | 选项未找到 | 等待超时 |
| **配置对象** | 无 | `AsyncSelectOptions` |

### DOM 结构理解

**异步 Select 的 DOM 结构与静态 Select 相同：**

```html
<!-- 触发器 -->
<button
  data-testid="省份-trigger"
  role="combobox"
  aria-label="省份"
>
  省份
</button>

<!-- 选项列表（初始为空或显示加载状态） -->
<div role="listbox">
  <!-- API 请求完成后动态添加选项 -->
  <div role="option" data-value="guangdong">广东省</div>
  <div role="option" data-value="beijing">北京市</div>
  <div role="option" data-value="shanghai">上海市</div>
</div>
```

**关键区别：**
- 选项不是一开始就存在于 DOM 中
- 需要等待 API 请求完成后选项才出现
- 可能需要处理加载状态显示

### Playwright API 参考

**等待网络空闲：**
```typescript
// 等待网络空闲（所有网络请求完成）
await page.waitForLoadState('networkidle', { timeout: 5000 });

// 等待特定类型的加载状态
await page.waitForLoadState('domcontentloaded'); // DOM 加载完成
await page.waitForLoadState('load');            // 页面 load 事件
await page.waitForLoadState('networkidle');     // 网络空闲（至少 500ms 无网络活动）
```

**重试模式：**
```typescript
// 使用重试机制等待异步元素
const maxRetries = 10;
const retryDelay = 500;

for (let i = 0; i < maxRetries; i++) {
  try {
    const element = await page.waitForSelector(selector, { timeout: 1000 });
    await element.click();
    break; // 成功，退出循环
  } catch {
    if (i < maxRetries - 1) {
      await page.waitForTimeout(retryDelay);
    } else {
      throw new Error('重试失败');
    }
  }
}
```

### 项目标准对齐

**与项目标准对齐：**
- 遵循 `docs/standards/testing-standards.md` 中的测试规范
- 遵循 `docs/standards/e2e-radix-testing.md` 中的 Radix UI E2E 测试标准
- 遵循 `docs/standards/coding-standards.md` 中的编码标准

**TypeScript 配置：**
- 严格模式已启用（`strict: true`），禁止 `any` 类型
- 所有函数参数和返回值必须有明确类型注解
- 可选参数使用 `?:` 标记

**JSDoc 注释标准：**
- 所有导出函数必须有完整 JSDoc
- 使用 `@param`, `@throws`, `@example` 标签
- 添加实际使用示例代码
- 内部函数使用 `@internal` 标记

**命名约定：**
- 函数：camelCase，动词+名词，异步函数添加 `Async` 后缀
- 内部函数：camelCase，动词开头（如 `waitForOptionAndSelect`）
- 私有函数使用 `@internal` JSDoc 标记

### 性能约束

**从 NFR 提取的性能要求：**
- **NFR9**: 异步 Select 选择操作应在 5 秒内完成（默认超时）
- **NFR10**: 工具函数本身的开销不超过 100ms（不包括 Playwright 操作时间）
- **NFR13**: 异步选项提供可配置的超时参数，默认值为 5 秒
- **NFR14**: 工具函数使用 Playwright 的 auto-waiting 机制，减少显式等待的需要

**实现时需要考虑：**
- 使用 `DEFAULT_TIMEOUTS.async` (5000ms) 作为默认超时
- 支持自定义超时配置
- 使用重试机制而非一次性 `waitForTimeout`
- 优先使用 Playwright 的 `waitForSelector` 而不是 `waitForTimeout`

### 文件结构约束

**必须遵循的文件结构：**
```
packages/e2e-test-utils/src/
├── index.ts          # 主导出（需要更新）
├── types.ts          # 共享类型定义（已完成，AsyncSelectOptions 已存在）
├── errors.ts         # 错误类（已完成）
├── constants.ts      # 常量定义（已完成，DEFAULT_TIMEOUTS.async 已存在）
└── radix-select.ts   # Radix UI Select 工具（本故事修改，添加异步函数）
```

**禁止事项（Anti-Patterns）：**
- ❌ 使用 `any` 类型
- ❌ 硬编码超时值（必须使用 `DEFAULT_TIMEOUTS.async`）
- ❌ 抛出原生 `Error`（必须使用 `throwError` 辅助函数）
- ❌ 缺少 JSDoc 注释
- ❌ 使用无限 `while(true)` 循环（必须有超时限制）
- ❌ 使用 `page.waitForTimeout(5000)` 作为主要等待策略（应该使用重试机制）

### 测试要求

**单元测试（在 Story 1.6 中实现）：**
本故事创建的 `selectRadixOptionAsync` 函数将在 Story 1.6 中进行单元测试。

**当前验证方法：**
- 类型检查：`pnpm typecheck`
- JSDoc 验证：手动检查所有导出都有完整注释
- 手动测试：在真实 E2E 测试场景中验证（如残疾人管理的省份选择）

### Project Structure Notes

**对齐项目 Monorepo 架构：**
- 包位于 `packages/e2e-test-utils/`
- 使用 workspace 协议安装：`@d8d/e2e-test-utils@workspace:*`
- 与现有 `@d8d/shared-test-util`（后端集成测试）分离

**与项目标准对齐：**
- 遵循 `docs/standards/testing-standards.md` 中的测试规范
- 遵循 `docs/standards/web-ui-testing-standards.md` 中的 Web UI 测试规范
- 遵循 `docs/standards/e2e-radix-testing.md` 中的 Radix UI E2E 测试标准（核心标准文档）

### References

**PRD 来源：**
- [PRD - E2E测试工具包](_bmad-output/planning-artifacts/prd.md) - 项目需求概述
- [PRD - Radix UI Select 测试需求](_bmad-output/planning-artifacts/epics.md#radix-ui-select-测试支持-fr1-fr6) - FR1-FR6 需求

**Architecture 来源：**
- [Architecture - API 设计模式](_bmad-output/planning-artifacts/architecture.md#api-design-pattern) - 4参数+配置对象
- [Architecture - 选择器策略](_bmad-output/planning-artifacts/architecture.md#selector-strategy) - 混合策略
- [Architecture - 错误处理策略](_bmad-output/planning-artifacts/architecture.md#error-handling-strategy) - 结构化错误类
- [Architecture - 性能约束](_bmad-output/planning-artifacts/architecture.md#performance-constraints) - NFR8-NFR14
- [Architecture - 实现模式](_bmad-output/planning-artifacts/architecture.md#implementation-patterns--consistency-rules) - 命名和格式约定

**标准文档来源：**
- [E2E Radix UI 测试标准](docs/standards/e2e-radix-testing.md) - 核心测试标准文档
- [Project Context](_bmad-output/project-context.md) - 项目技术栈和规则

**Epic 来源：**
- [Epic 1 - Story 1.4](_bmad-output/planning-artifacts/epics.md#story-14-实现异步-select-工具函数) - 原始用户故事和验收标准

**前一个故事：**
- [Story 1.1 - 创建包基础结构和配置](_bmad-output/implementation-artifacts/1-1-create-package-structure.md) - 包基础设施
- [Story 1.2 - 实现类型定义和错误处理](_bmad-output/implementation-artifacts/1-2-implement-types-errors.md) - 类型、错误、常量
- [Story 1.3 - 实现静态 Select 工具函数](_bmad-output/implementation-artifacts/1-3-static-select-tool.md) - 静态 Select 函数和经验教训

## Dev Agent Record

### Agent Model Used

Claude (d8d-model) via create-story workflow

### Debug Log References

### Completion Notes List

- 故事创建时间: 2026-01-08
- 基于 PRD、Architecture、E2E Radix 测试标准文档创建
- 基于 Story 1.3 的经验教训创建（代码审查发现的问题）
- 包含完整的异步等待策略实现指南
- 包含与前一个故事的集成说明
- 区分异步和静态 Select 的关键差异

**实现建议:**
- 复用 `findTrigger` 函数查找触发器
- 实现 `waitForOptionAndSelect` 函数处理异步等待
- 使用重试机制而非固定超时
- 超时时获取可用选项用于错误提示
- 遵循 Story 1.3 的代码审查经验（`allTextContents`、`:text-is()`、完整 JSDoc）

**实现完成 (2026-01-09):**
- ✅ 实现了 `selectRadixOptionAsync(page, label, value, options?)` 函数
- ✅ 复用了 `findTrigger()` 函数查找下拉框触发器
- ✅ 实现了 `waitForOptionAndSelect()` 内部函数处理异步等待
- ✅ 使用重试机制（100ms 间隔）等待选项出现
- ✅ 支持可配置超时（默认 5000ms）
- ✅ 支持可选的网络空闲等待（`waitForNetworkIdle`）
- ✅ 超时错误包含：超时时间、期望值、可用选项、修复建议
- ✅ 添加了完整的 JSDoc 注释（@description, @param, @throws, @example）
- ✅ 类型检查通过（`pnpm typecheck`）

**代码审查修复 (2026-01-09):**
- ✅ 更新 JSDoc 添加 `waitForOption` 和 `waitForNetworkIdle` 参数说明
- ✅ 将 `waitForNetworkIdle` 默认值改为 `true`（符合 AC 要求）
- ✅ 修复 `waitForLoadState` 超时配置，使用 `DEFAULT_TIMEOUTS.networkIdle`
- ✅ `waitForOptionAndSelect` 添加文本匹配回退策略
- ✅ 添加注释说明网络空闲等待失败后继续执行的原因
- ✅ 更新 `types.ts` 中 `AsyncSelectOptions` JSDoc 补充完整说明
- ✅ 更新 `index.ts` 使用显式导出而非通配符
- ✅ 类型检查通过验证

### File List

**本故事修改的文件：**
- `packages/e2e-test-utils/src/radix-select.ts` - 添加异步 Select 工具函数（修改）
- `packages/e2e-test-utils/src/types.ts` - 更新 AsyncSelectOptions JSDoc（修改）
- `packages/e2e-test-utils/src/index.ts` - 更新导出方式（修改）

**相关文件（已在 Story 1.1、1.2、1.3 中完成，本故事使用）：**
- `packages/e2e-test-utils/src/types.ts` - 共享类型定义（AsyncSelectOptions 已存在）
- `packages/e2e-test-utils/src/errors.ts` - 错误类和错误处理
- `packages/e2e-test-utils/src/constants.ts` - 超时和选择器策略常量（DEFAULT_TIMEOUTS.async 已存在）

**只读参考文件：**
- `_bmad-output/implementation-artifacts/1-3-static-select-tool.md` - 前一个故事（静态 Select 实现）
- `_bmad-output/implementation-artifacts/1-2-implement-types-errors.md` - 类型定义故事
- `_bmad-output/planning-artifacts/epics.md` - Epic 和故事定义
- `_bmad-output/planning-artifacts/architecture.md` - 架构决策和模式
- `docs/standards/e2e-radix-testing.md` - E2E Radix UI 测试标准

