Status: ready-for-dev
作为测试开发者, 我想要编写回访记录管理的测试, 以便验证回访记录的创建、查看、编辑功能。
Given 残疾人管理 Page Object 已存在 When 编写回访记录测试 Then 包含以下测试场景:
创建回访记录
查看回访历史
编辑回访记录
回访记录状态管理
[ ] Task 1: 分析回访管理功能的 DOM 结构 (AC: #1, #2, #3, #4)
[ ] Task 2: 创建回访记录测试文件 (AC: #1, #2, #3, #4)
web/tests/e2e/specs/admin/disability-person-visit.spec.ts[ ] Task 3: 更新 Page Object (AC: #1, #2, #3, #4)
DisabilityPersonManagementPageaddVisit() 方法editVisit() 方法deleteVisit() 方法getVisitList() 方法用于验证getVisitCount() 方法[ ] Task 4: 运行测试并验证通过 (AC: #1, #2, #3, #4)
pnpm test:e2e:chromium disability-person-visit 运行测试Epic 9: 残疾人管理完整 E2E 测试覆盖(含并行隔离)
为残疾人管理功能编写完整的、真正验证业务功能的 E2E 测试,并确保测试可以与未来的区域管理测试并行运行。
Epic 9 的 Story 依赖关系:
回访记录管理功能概述:
回访记录用于记录对残疾人的回访活动,包括电话回访、上门回访、视频回访等多种形式。每条回访记录包含:
业务规则:
组件位置: allin-packages/disability-person-management-ui/src/components/VisitManagement.tsx
关键选择器(已验证):
| 元素 | data-testid | 类型 |
|---|---|---|
| 添加回访按钮 | add-visit-button |
Button |
| 删除回访按钮 | remove-visit-{index} |
Button |
| 回访日期输入 | visit-date-input-{index} |
Input[type="date"] |
| 回访类型选择器 | visit-type-select-{index} |
Select |
| 回访内容文本域 | visit-content-textarea-{index} |
Textarea |
| 回访结果输入 | visit-result-input-{index} |
Input |
| 下次回访日期输入 | next-visit-date-input-{index} |
Input[type="date"] |
| 回访人ID输入 | visitor-id-input-{index} |
Input[type="number"] |
Entity 位置: allin-packages/disability-module/src/entities/disabled-visit.entity.ts
interface VisitItem {
visitDate: string; // ISO格式日期字符串
visitType: string; // 回访类型
visitContent: string; // 回访内容
visitResult?: string; // 回访结果(可选)
nextVisitDate?: string; // 下次回访日期(可选)
visitorId: number; // 回访人ID
tempId?: string; // 临时ID用于React key
}
回访类型选项:
当前 Page Object 位置: web/tests/e2e/pages/admin/disability-person.page.ts
现有的 addVisit() 方法(需要完善):
当前实现(第466-505行):
async addVisit(visit: {
visitDate: string;
visitType: string;
visitContent: string;
visitResult?: string;
nextVisitDate?: string;
}) {
// 点击"添加回访"按钮
const addVisitButton = this.page.getByRole('button', { name: /添加回访/ });
await addVisitButton.click();
await this.page.waitForTimeout(TIMEOUTS.SHORT);
// 填写回访信息
await this.page.getByLabel(/回访日期/).fill(visit.visitDate);
await selectRadixOption(this.page, '回访类型', visit.visitType);
// 查找回访内容输入框(可能有多个,使用最后一个)
const visitContentTextarea = this.page.locator('textarea').filter({ hasText: '' }).last();
await visitContentTextarea.fill(visit.visitContent);
// 填写回访结果(可选)
if (visit.visitResult) {
const resultTextareas = this.page.locator('textarea');
const count = await resultTextareas.count();
if (count > 0) {
await resultTextareas.nth(count - 1).fill(visit.visitResult);
}
}
// 填写下一次回访日期(可选)
if (visit.nextVisitDate) {
const nextDateInput = this.page.getByLabel(/下次回访日期/);
await nextDateInput.fill(visit.nextVisitDate);
}
console.log(` ✓ 添加回访: ${visit.visitType} - ${visit.visitDate}`);
}
问题分析:
getByRole 和 getByLabel),不够稳定data-testid 选择器需要在 DisabilityPersonManagementPage 中添加/完善的方法:
/**
* 添加回访记录(内联表单模式)
* @param visit 回访信息
* @returns 添加的回访记录索引
*/
async addVisit(visit: {
visitDate: string;
visitType: string;
visitContent: string;
visitResult?: string;
nextVisitDate?: string;
visitorId?: number;
}): Promise<number> {
// 1. 点击"添加回访"按钮
// 2. 等待新的回访卡片出现
// 3. 获取当前回访数量,新添加的索引
// 4. 填写回访日期、类型、内容等
// 5. 返回新添加的回访索引
}
/**
* 编辑指定索引的回访记录
* @param index 回访索引(从0开始)
* @param updatedData 更新的回访数据
*/
async editVisit(index: number, updatedData: {
visitDate?: string;
visitType?: string;
visitContent?: string;
visitResult?: string;
nextVisitDate?: string;
}): Promise<void> {
// 1. 直接在对应的输入框中修改值
// 2. 验证修改完成
}
/**
* 删除指定索引的回访记录
* @param index 回访索引(从0开始)
*/
async deleteVisit(index: number): Promise<void> {
// 1. 点击删除按钮
// 2. 等待删除完成
}
/**
* 获取回访记录列表
* @returns 回访信息数组
*/
async getVisitList(): Promise<Array<{
visitDate: string;
visitType: string;
visitContent: string;
visitResult?: string;
nextVisitDate?: string;
}>> {
// 1. 遍历所有回访卡片
// 2. 读取每个字段的值
// 3. 返回回访信息数组
}
/**
* 获取当前回访记录数量
* @returns 回访数量
*/
async getVisitCount(): Promise<number> {
return await this.page.locator('[data-testid^="remove-visit-"]').count();
}
/**
* 检查添加回访按钮是否被禁用
* @returns 是否禁用
*/
async isAddVisitButtonDisabled(): Promise<boolean> {
const addButton = this.page.locator('[data-testid="add-visit-button"]');
return await addButton.isDisabled();
}
/**
* 滚动到回访记录区域
*/
async scrollToVisitSection(): Promise<void> {
const visitLabel = this.page.getByText('回访记录管理');
await visitLabel.scrollIntoViewIfNeeded();
await this.page.waitForTimeout(TIMEOUTS.SHORT);
}
测试文件: web/tests/e2e/specs/admin/disability-person-visit.spec.ts
测试用例模板:
import { test, expect } from '@playwright/test';
import { DisabilityPersonManagementPage } from '../../pages/admin/disability-person.page';
test.describe('残疾人管理 - 回访记录管理功能', () => {
let pageObject: DisabilityPersonManagementPage;
const TIMESTAMP = Date.now();
const UNIQUE_ID = `test_visit_${TIMESTAMP}`;
test.beforeEach(async ({ page }) => {
pageObject = new DisabilityPersonManagementPage(page);
await pageObject.goto();
await pageObject.openCreateDialog();
await pageObject.fillBasicInfo({
name: UNIQUE_ID,
gender: '男',
idCard: `110101199001011234`,
disabilityId: `1234567890${TIMESTAMP}`,
disabilityType: '肢体残疾',
disabilityLevel: '一级',
phone: '13800138000',
idAddress: '北京市东城区测试街道1号',
province: '北京市',
city: '北京市',
});
await pageObject.scrollToVisitSection();
});
test('应该成功创建电话回访记录', async ({ page }) => {
const today = new Date().toISOString().split('T')[0];
await pageObject.addVisit({
visitDate: today,
visitType: '电话回访',
visitContent: `电话回访测试内容_${UNIQUE_ID}`,
visitResult: '良好',
visitorId: 1,
});
const visitCount = await pageObject.getVisitCount();
expect(visitCount).toBe(1);
});
test('应该成功创建上门回访记录', async ({ page }) => {
const today = new Date().toISOString().split('T')[0];
await pageObject.addVisit({
visitDate: today,
visitType: '上门回访',
visitContent: `上门回访测试内容_${UNIQUE_ID}`,
visitResult: '需要跟进',
visitorId: 1,
});
const visitCount = await pageObject.getVisitCount();
expect(visitCount).toBe(1);
});
test('应该成功编辑回访记录内容', async ({ page }) => {
const today = new Date().toISOString().split('T')[0];
await pageObject.addVisit({
visitDate: today,
visitType: '电话回访',
visitContent: `原始内容_${UNIQUE_ID}`,
visitorId: 1,
});
const updatedContent = `更新后的回访内容_${UNIQUE_ID}`;
await pageObject.editVisit(0, { visitContent: updatedContent });
const visitList = await pageObject.getVisitList();
expect(visitList[0].visitContent).toContain('更新后的回访内容');
});
test('应该成功删除回访记录', async ({ page }) => {
const today = new Date().toISOString().split('T')[0];
await pageObject.addVisit({
visitDate: today,
visitType: '电话回访',
visitContent: `待删除内容_${UNIQUE_ID}`,
visitorId: 1,
});
let visitCount = await pageObject.getVisitCount();
expect(visitCount).toBe(1);
await pageObject.deleteVisit(0);
visitCount = await pageObject.getVisitCount();
expect(visitCount).toBe(0);
});
test('应该支持添加多条回访记录', async ({ page }) => {
const today = new Date().toISOString().split('T')[0];
await pageObject.addVisit({
visitDate: today,
visitType: '电话回访',
visitContent: `回访1_${UNIQUE_ID}`,
visitorId: 1,
});
await pageObject.addVisit({
visitDate: today,
visitType: '上门回访',
visitContent: `回访2_${UNIQUE_ID}`,
visitorId: 1,
});
await pageObject.addVisit({
visitDate: today,
visitType: '视频回访',
visitContent: `回访3_${UNIQUE_ID}`,
visitorId: 1,
});
const visitCount = await pageObject.getVisitCount();
expect(visitCount).toBe(3);
});
test('应该限制最多添加10条回访记录', async ({ page }) => {
const today = new Date().toISOString().split('T')[0];
// 添加10条回访记录
for (let i = 0; i < 10; i++) {
await pageObject.addVisit({
visitDate: today,
visitType: '电话回访',
visitContent: `回访${i + 1}_${UNIQUE_ID}`,
visitorId: 1,
});
}
// 验证按钮被禁用
const isDisabled = await pageObject.isAddVisitButtonDisabled();
expect(isDisabled).toBe(true);
});
test('应该正确显示回访记录历史', async ({ page }) => {
const today = new Date().toISOString().split('T')[0];
await pageObject.addVisit({
visitDate: today,
visitType: '电话回访',
visitContent: `第一次回访_${UNIQUE_ID}`,
visitResult: '良好',
visitorId: 1,
});
await pageObject.addVisit({
visitDate: today,
visitType: '上门回访',
visitContent: `第二次回访_${UNIQUE_ID}`,
visitResult: '需要跟进',
visitorId: 1,
});
const visitList = await pageObject.getVisitList();
expect(visitList).toHaveLength(2);
expect(visitList[0].visitContent).toContain('第一次回访');
expect(visitList[1].visitContent).toContain('第二次回访');
});
});
参考 Story 9.1、9.2、9.3 的经验,使用 data-testid 选择器:
// 添加回访按钮
const addButton = this.page.locator('[data-testid="add-visit-button"]');
// 删除回访按钮(索引)
const removeButton = this.page.locator(`[data-testid="remove-visit-${index}"]`);
// 回访日期输入
const dateInput = this.page.locator(`[data-testid="visit-date-input-${index}"]`);
// 回访类型选择器 - 使用 Radix Select 工具
const typeSelectTrigger = this.page.locator(`[data-testid="visit-type-select-${index}"]`);
// 回访内容文本域
const contentTextarea = this.page.locator(`[data-testid="visit-content-textarea-${index}"]`);
// 回访结果输入
const resultInput = this.page.locator(`[data-testid="visit-result-input-${index}"]`);
// 下次回访日期输入
const nextDateInput = this.page.locator(`[data-testid="next-visit-date-input-${index}"]`);
// 回访人ID输入
const visitorIdInput = this.page.locator(`[data-testid="visitor-id-input-${index}"]`);
参考 Story 9.1、9.2、9.3 的数据隔离模式:
test.beforeEach(async ({ page }) => {
const timestamp = Date.now();
const uniqueId = `test_visit_${timestamp}`;
pageObject = new DisabilityPersonManagementPage(page);
await pageObject.goto();
await pageObject.openCreateDialog();
// 使用唯一ID确保数据隔离
});
test.afterEach(async ({ page }) => {
// 清理测试数据(如需要)
});
测试目录组织:
.spec.ts 后缀无冲突检测:
文件组织:
web/tests/e2e/specs/admin/disability-person-visit.spec.tsweb/tests/e2e/pages/admin/disability-person.page.tsStory 9.1 (照片上传功能测试) 的关键经验:
form.getByLabel() 限制查找范围在表单内Date.now() 生成唯一 IDTIMEOUTS 常量统一管理Story 9.2 (银行卡管理功能测试) 的关键经验:
nth(index) 定位列表项getVisitList() 获取列表后验证内容Story 9.3 (备注管理功能测试) 的关键经验:
console.debug 而非 console.log(Vitest 中只有 debug 会显示)TIMEOUTS 常量替换所有魔法数字Recent Commits:
e589d94b - test(e2e): 完成 Story 9.3 - 备注管理功能测试2be3dd84 - test(e2e): 完成 Story 10.2 代码审查 - 订单列表查看测试8c36a373 - test(e2e): 完成 Story 10.2 - 订单列表查看测试Code Patterns Observed:
disability-person-{feature}.spec.tsaddVisit, editVisit)data-testid 选择器确保稳定性基于架构文档的陷阱章节和前置 Story 的经验:
陷阱 1: DOM 结构假设必须验证 ⚠️
VisitManagement.tsx 中验证data-testid 选择器(最稳定)陷阱 2: 精确文本匹配
:text-is() 进行精确文本匹配,而非 :has-text()陷阱 3: 网络空闲等待
陷阱 4: 避免使用 page.evaluate()
page.evaluate() 获取元素内容陷阱 5: Select 组件操作
selectRadixOption 工具data-testid="visit-type-select-{index}"源文档引用:
前置 Story 参考:
相关组件源码:
Monorepo 结构对齐:
web/tests/e2e/ 目录@d8d/e2e-test-utils遵循的项目标准:
.spec.ts 后缀(Playwright 测试)specs/ 分离,pages/ Page Object@d8d/e2e-test-utils 工具函数(selectRadixOption)docs/standards/e2e-radix-testing.md 标准Claude Opus 4 (claude-opus-4-5-20251101)
待创建的文件:
_bmad-output/implementation-artifacts/9-4-visit-tests.md - 本 story 文档web/tests/e2e/specs/admin/disability-person-visit.spec.ts - 回访测试文件(待实现)待修改的文件:
_bmad-output/implementation-artifacts/sprint-status.yaml - 更新 Story 9.4 状态web/tests/e2e/pages/admin/disability-person.page.ts - Page Object 扩展(待实现)