# Radix UI E2E 测试标准

## 版本信息
| 版本 | 日期 | 描述 | 作者 |
|------|------|------|------|
| 1.0 | 2026-01-07 | 从 PRD 提取，定义 Radix UI 组件 E2E 测试标准 | Claude Code |

## 概述

本文档定义了 Radix UI 组件的 E2E 测试标准，提供可复用的测试模式和最佳实践。

**适用范围：**
- Playwright E2E 测试中对 Radix UI 组件的测试
- 所有管理功能的表单测试（用户管理、残疾人管理等）
- 任何使用 Radix UI Select、Dialog、Tabs 等组件的功能测试

**核心价值：**
- 统一的测试模式，减少重复代码
- 自动处理 Radix UI 的复杂 DOM 结构和时序问题
- 清晰的错误提示，便于调试
- 提高测试稳定性，减少 flaky 测试

## 测试工具包

### 安装

```bash
# 在 web/ 目录下
pnpm add -D @d8d/e2e-test-utils@workspace:*
```

### 导入

```typescript
import {
  selectRadixOption,
  selectRadixOptionAsync,
  uploadFileToField,
  fillMultiStepForm,
  addDynamicListItem,
  deleteDynamicListItem,
  handleDialog,
  waitForDialogClosed,
  cancelDialog
} from '@d8d/e2e-test-utils';
```

## Radix UI Select 测试

### Select 组件类型

Radix UI Select 分为两种类型，测试方式不同：

| 类型 | 特征 | 测试方法 |
|------|------|----------|
| **静态枚举型** | 选项固定在代码中，无需异步加载 | `selectRadixOption()` |
| **异步加载型** | 选项通过 API 动态加载 | `selectRadixOptionAsync()` |

### 1. 静态枚举型 Select

**使用场景：** 残疾类型、残疾等级、性别等固定枚举值

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

// 基础用法
await selectRadixOption(page, '残疾类型', '视力残疾');

// 选择性别
await selectRadixOption(page, '性别', '男');

// 选择残疾等级
await selectRadixOption(page, '残疾等级', '一级');
```

**DOM 结构示例：**
```html
<!-- Radix UI Select 的典型 DOM 结构 -->
<div data-radix-select-trigger="">
  <span>残疾类型</span>
  <svg><!-- 下箭头 --></svg>
</div>

<!-- 点击后展开的选项列表 -->
<div role="listbox">
  <div role="option" data-value="blind">视力残疾</div>
  <div role="option" data-value="hearing">听力残疾</div>
  <div role="option" data-value="physical">肢体残疾</div>
</div>
```

### 2. 异步加载型 Select

**使用场景：** 省份、城市、通过 API 搜索的选项

```typescript
import { selectRadixOptionAsync } from '@d8d/e2e-test-utils';

// 基础用法
await selectRadixOptionAsync(page, '省份', '广东省');

// 带自定义超时
await selectRadixOptionAsync(page, '城市', '深圳市', {
  timeout: 10000,
  waitForOption: true
});

// 选择异步加载的区县
await selectRadixOptionAsync(page, '区县', '南山区');
```

**等待策略：**
- 默认等待 5 秒（`timeout: 5000`）
- 使用 `waitForLoadState('networkidle')` 确保数据加载完成
- 等待选项出现在 DOM 中后再点击

### 3. 错误处理

工具函数提供清晰的错误提示：

```typescript
// 如果选项不存在
Error: Radix Select 选项 "xxx" 未找到
  标签: 残疾类型
  期望值: xxx
  可用选项: 视力残疾, 听力残疾, 肢体残疾, 智力残疾

// 如果超时
Error: Radix Select 等待超时
  标签: 省份
  期望值: 广东省
  超时时间: 5000ms
  可能原因: 网络请求过慢或选项未加载
```

### 4. 选择器策略

工具函数使用稳定的选择器：

```typescript
// 触发器选择器
const triggerSelector = [
  `[data-testid="${label}-trigger"]`,
  `text="${label}"`,
  `[role="combobox"]`
].join(', ');

// 选项选择器
const optionSelector = [
  `[role="option"][data-value="${value}"]`,
  `[role="option"]:has-text("${value}")`
].join(', ');
```

## 文件上传测试

### 单文件上传

```typescript
import { uploadFileToField } from '@d8d/e2e-test-utils';

// 上传身份证照片
await uploadFileToField(
  page,
  '[data-testid="id-card-photo-input"]',
  'sample-id-card.jpg'
);

// 上传残疾证照片
await uploadFileToField(
  page,
  '[data-testid="disability-card-input"]',
  'disability-cert.pdf'
);
```

### Fixtures 目录结构

```
web/tests/
└── fixtures/
    ├── images/
    │   ├── sample-id-card.jpg
    │   ├── passport.jpg
    │   └── profile.png
    └── documents/
        ├── disability-cert.pdf
        └── bank-statement.pdf
```

### 多文件上传

```typescript
// 多个文件输入框
await uploadFileToField(page, '#photo1', 'photo1.jpg');
await uploadFileToField(page, '#photo2', 'photo2.jpg');
await uploadFileToField(page, '#photo3', 'photo3.jpg');
```

## 表单测试模式

### 多步骤表单

```typescript
import { fillMultiStepForm, scrollToSection } from '@d8d/e2e-test-utils';

// 填写多步骤表单
await fillMultiStepForm(page, [
  // 步骤 1: 基本信息
  {
    section: '基本信息',
    fields: [
      { selector: '#name', value: '张三' },
      { selector: '#idCard', value: '123456789012345678' }
    ]
  },
  // 步骤 2: 联系方式
  {
    section: '联系方式',
    fields: [
      { selector: '#phone', value: '13800138000' },
      { selector: '#address', value: '北京市朝阳区' }
    ]
  }
]);

// 滚动到特定区域
await scrollToSection(page, '银行卡信息');
```

### 表单验证错误

```typescript
// 测试必填字段验证
await page.click('button[type="submit"]');

// 检查错误提示
await expect(page.locator('text=请输入姓名')).toBeVisible();

// 填写后重试
await page.fill('#name', '张三');
await page.click('button[type="submit"]');

// 验证成功
await expect(page.locator('text=保存成功')).toBeVisible();
```

## 动态列表测试

### 添加列表项

```typescript
import { addDynamicListItem } from '@d8d/e2e-test-utils';

// 添加银行卡
await addDynamicListItem(page, '银行卡', {
  bankName: '中国工商银行',
  accountNumber: '6222021234567890',
  accountHolder: '张三'
});

// 添加备注
await addDynamicListItem(page, '备注', {
  content: '这是测试备注',
  createdAt: new Date().toISOString()
});
```

### 删除列表项

```typescript
import { deleteDynamicListItem } from '@d8d/e2e-test-utils';

// 删除第一个银行卡
await deleteDynamicListItem(page, '银行卡', 0);

// 删除指定备注
await deleteDynamicListItem(page, '备注', 1);
```

### 验证列表状态

```typescript
// 验证银行卡数量
const bankCards = page.locator('[data-testid="bank-card-item"]');
await expect(bankCards).toHaveCount(2);

// 验证银行卡内容
await expect(page.locator('text=中国工商银行')).toBeVisible();
await expect(page.locator('text=6222021234567890')).toBeVisible();
```

## 对话框测试

### 打开对话框

```typescript
import { handleDialog } from '@d8d/e2e-test-utils';

// 点击按钮打开对话框
await page.click('button:has-text("添加银行卡")');

// 等待对话框打开
await expect(page.locator('[role="dialog"]')).toBeVisible();
```

### 填写并提交对话框

```typescript
// 填写对话框表单
await page.fill('[data-testid="bank-name"]', '中国建设银行');
await page.fill('[data-testid="account-number"]', '6217001234567890');

// 点击确认
await handleDialog(page, 'confirm');

// 或使用通用操作
await page.click('button:has-text("确认")');
```

### 取消对话框

```typescript
import { cancelDialog } from '@d8d/e2e-test-utils';

// 方法 1: 使用取消函数
await cancelDialog(page);

// 方法 2: 点击取消按钮
await page.click('button:has-text("取消")');

// 方法 3: 按 ESC 键
await page.keyboard.press('Escape');
```

### 等待对话框关闭

```typescript
import { waitForDialogClosed } from '@d8d/e2e-test-utils';

// 等待对话框关闭
await waitForDialogClosed(page);

// 验证对话框已关闭
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
```

## 完整测试示例

### 残疾人管理 E2E 测试

```typescript
import { test, expect } from '@playwright/test';
import { DisabilityPersonPage } from '../pages/admin/disability-person.page';
import {
  selectRadixOption,
  selectRadixOptionAsync,
  uploadFileToField
} from '@d8d/e2e-test-utils';

test.describe('残疾人管理 E2E 测试', () => {
  let pageObject: DisabilityPersonPage;

  test.beforeEach(async ({ page }) => {
    pageObject = new DisabilityPersonPage(page);
    await pageObject.goto();
    await pageObject.clickAddButton();
  });

  test('完整流程：新增残疾人', async ({ page }) => {
    // 基本信息
    await page.fill('#name', '张三');
    await page.fill('#idCard', '123456789012345678');

    // 选择静态枚举型 Select
    await selectRadixOption(page, '残疾类型', '视力残疾');
    await selectRadixOption(page, '残疾等级', '一级');

    // 选择异步加载型 Select
    await selectRadixOptionAsync(page, '省份', '广东省');
    await selectRadixOptionAsync(page, '城市', '深圳市');
    await selectRadixOptionAsync(page, '区县', '南山区');

    // 上传照片
    await uploadFileToField(page, '[data-testid="id-card-input"]', 'id-card.jpg');
    await uploadFileToField(page, '[data-testid="disability-cert-input"]', 'cert.pdf');

    // 添加银行卡
    await pageObject.addBankCard({
      bankName: '中国工商银行',
      accountNumber: '6222021234567890'
    });

    // 添加备注
    await pageObject.addRemark('这是测试备注');

    // 提交表单
    await page.click('button[type="submit"]');

    // 验证成功
    await expect(page.locator('text=保存成功')).toBeVisible();
  });

  test('编辑残疾人信息', async ({ page }) => {
    // 点击编辑按钮
    await pageObject.clickEdit('张三');

    // 修改残疾等级
    await selectRadixOption(page, '残疾等级', '二级');

    // 提交
    await page.click('button[type="submit"]');

    // 验证
    await expect(page.locator('text=残疾等级: 二级')).toBeVisible();
  });
});
```

## 测试稳定性最佳实践

### 1. 等待策略

```typescript
// ✅ 推荐：使用工具函数的自动等待
await selectRadixOption(page, '残疾类型', '视力残疾');

// ❌ 不推荐：手动等待
await page.click(`text=残疾类型`);
await page.waitForTimeout(1000); // 不可靠
await page.click(`text=视力残疾`);
```

### 2. 选择器策略

```typescript
// ✅ 推荐：使用 data-testid
await page.click('[data-testid="submit-button"]');

// ⚠️ 谨慎：使用文本选择器
await page.click('text=提交'); // 可能匹配多个元素

// ❌ 不推荐：使用不稳定的 CSS 类
await page.click('.btn-primary.ant-btn'); // 类名可能变化
```

### 3. 错误断言

```typescript
// ✅ 推荐：明确的错误消息
await expect(page.locator('[data-testid="error-message"]'))
  .toHaveText('请输入姓名');

// ❌ 不推荐：模糊的错误检查
await expect(page.locator('.error')).toBeVisible();
```

### 4. 测试隔离

```typescript
test.beforeEach(async ({ page }) => {
  // 每个测试前清理数据
  await cleanupTestData(page);
});

test.afterEach(async ({ page }) => {
  // 每个测试后截图（失败时）
  if (test.info().status !== 'passed') {
    await page.screenshot({ path: `failure-${test.info().title}.png` });
  }
});
```

## 常见问题

### Q: Select 组件点击后选项列表没有展开？

A: 可能是时序问题，工具函数已处理。如果仍有问题，检查：
- 页面是否有遮挡层（如 loading 遮罩）
- Select 组件是否禁用
- 网络请求是否完成

### Q: 异步 Select 选项加载很慢？

A: 可以增加超时时间：

```typescript
await selectRadixOptionAsync(page, '省份', '新疆维吾尔自治区', {
  timeout: 10000 // 10 秒
});
```

### Q: 文件上传失败？

A: 检查：
- 文件路径是否正确（相对于 fixtures 目录）
- 文件是否存在
- 输入框是否被禁用

### Q: 对话框操作不稳定？

A: 使用工具函数的等待机制：

```typescript
// 等待对话框完全关闭
await waitForDialogClosed(page);

// 然后再继续操作
await page.click('[data-testid="next-button"]');
```

## 性能标准

| 操作 | 目标时间 | 最大可接受时间 |
|------|---------|---------------|
| 静态 Select 选择 | < 1s | 2s |
| 异步 Select 选择 | < 3s | 5s |
| 文件上传 | < 2s | 5s |
| 表单提交 | < 2s | 5s |

## 参考资料

- **PRD 文档**: `_bmad-output/planning-artifacts/prd.md`
- **测试工具包**: `packages/e2e-test-utils/`
- **示例测试**: `web/tests/e2e/specs/admin/disability-person-complete.spec.ts`
- **Radix UI 文档**: https://www.radix-ui.com/
- **Playwright 文档**: https://playwright.dev/

## 更新日志

| 日期 | 版本 | 变更内容 |
|------|------|---------|
| 2026-01-07 | 1.0 | 初始版本，从 PRD 提取测试标准 |
