Compare commits
6 Commits
12db1bb9fb
...
0f4a8bff09
| Author | SHA1 | Date |
|---|---|---|
|
|
0f4a8bff09 | 2 days ago |
|
|
5dbcabd69c | 2 days ago |
|
|
8711cd2f04 | 2 days ago |
|
|
9d5ae553a6 | 2 days ago |
|
|
58e71ebcef | 2 days ago |
|
|
0f93039dc3 | 2 days ago |
@ -1,4 +1,410 @@ |
|||
# Agents 开发规范 |
|||
|
|||
- 涉及到任何代码修改,记得更新此文档和设计文档 |
|||
- |
|||
- 需求记录文档: [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md) |
|||
|
|||
## 前端自动化测试规范 |
|||
|
|||
**重要**: 前端进行功能更新和修改后,必须使用 Playwright 进行自动化测试验证。 |
|||
|
|||
**详细文档**: 参见 [`tests/README.md`](tests/README.md) |
|||
|
|||
### 测试目录结构 |
|||
|
|||
``` |
|||
tests/ |
|||
├── README.md # 测试使用文档 |
|||
├── constitution.test.js # 体质分析功能测试 |
|||
├── profile.test.js # "我的"页面功能测试 |
|||
├── health-profile-complete.test.js # 健康档案完整功能测试(推荐) |
|||
└── screenshots/ # 测试截图目录 |
|||
``` |
|||
|
|||
### 测试流程 |
|||
|
|||
1. **修改代码后**:编写或运行 Playwright 测试脚本验证功能 |
|||
2. **测试通过后**:才能确认修复完成 |
|||
3. **不要让用户手动测试**:自动化测试能发现的问题应自行解决 |
|||
|
|||
### 测试脚本示例 |
|||
|
|||
```javascript |
|||
// test-功能名.js |
|||
const { chromium } = require("playwright"); |
|||
|
|||
(async () => { |
|||
const browser = await chromium.launch({ headless: false }); |
|||
const page = await browser.newPage(); |
|||
|
|||
// 1. 打开应用 |
|||
await page.goto("http://localhost:8081"); |
|||
await page.waitForTimeout(2000); |
|||
|
|||
// 2. 执行测试步骤... |
|||
// 3. 截图验证 |
|||
await page.screenshot({ path: "test-screenshot.png" }); |
|||
|
|||
// 4. 断言检查 |
|||
const element = await page.locator("text=期望文本").first(); |
|||
if (await element.isVisible()) { |
|||
console.log("✓ 测试通过"); |
|||
} else { |
|||
console.log("✗ 测试失败"); |
|||
} |
|||
|
|||
await browser.close(); |
|||
})(); |
|||
``` |
|||
|
|||
### 运行测试 |
|||
|
|||
```bash |
|||
# 安装 (首次) |
|||
npm install playwright |
|||
npx playwright install chromium |
|||
|
|||
# 运行测试 |
|||
node test-功能名.js |
|||
``` |
|||
|
|||
### 测试检查清单 |
|||
|
|||
1. [ ] 功能正常工作 |
|||
2. [ ] 截图验证 UI 显示正确 |
|||
3. [ ] 错误场景处理正确 |
|||
4. [ ] 清理测试文件 (`rm test-*.js test-*.png`) |
|||
|
|||
--- |
|||
|
|||
## 前后端 API 响应格式规范 |
|||
|
|||
**重要**: 所有 API 必须遵循此格式约定,避免响应格式不匹配问题。 |
|||
|
|||
### 统一响应结构 |
|||
|
|||
```json |
|||
{ |
|||
"code": 0, // 0=成功,其他=错误码 |
|||
"message": "success", |
|||
"data": <T> // 业务数据 |
|||
} |
|||
``` |
|||
|
|||
### data 字段约定 |
|||
|
|||
| 接口类型 | data 格式 | 示例 | |
|||
| -------- | ------------------ | -------------------------- | |
|||
| 列表接口 | 直接返回数组 `T[]` | `data: [{id:1}, {id:2}]` | |
|||
| 详情接口 | 返回对象 `T` | `data: {id:1, name:"xxx"}` | |
|||
| 创建接口 | 返回创建的对象 | `data: {id:1, ...}` | |
|||
| 删除接口 | `null` 或空 | `data: null` | |
|||
|
|||
### 字段命名约定 |
|||
|
|||
- **后端 API**: 使用 snake_case(`created_at`, `user_id`) |
|||
- **前端应用**: 使用 camelCase(`createdAt`, `userId`) |
|||
- **前端需做转换**: 在 Store 层统一转换 |
|||
|
|||
### 类型定义文件 |
|||
|
|||
- **后端**: `server/docs/API.md` - API 文档 |
|||
- **前端**: `app/src/api/types.ts` - 统一类型定义 |
|||
|
|||
### 新增 API 检查清单 |
|||
|
|||
1. [ ] 查看 `server/docs/API.md` 确认后端响应格式 |
|||
2. [ ] 在 `app/src/api/types.ts` 添加类型定义 |
|||
3. [ ] 在 API 模块中使用正确的泛型类型 |
|||
4. [ ] 在 Store 中添加 snake_case → camelCase 转换 |
|||
|
|||
--- |
|||
|
|||
## 开发记录 |
|||
|
|||
### 2026-02-02: Expo Web 兼容性修复 |
|||
|
|||
**问题**: React Native 的 `Alert.alert()` 在 Web 上无法正常显示弹窗 |
|||
|
|||
**解决方案**: 创建跨平台的 `AlertProvider` 组件,使用 react-native-paper 的 Snackbar 和 Dialog 替代原生 Alert |
|||
|
|||
**新增文件**: |
|||
|
|||
- `app/src/components/AlertProvider.tsx` - 全局 Alert Context 和组件 |
|||
- `app/src/components/index.ts` - 组件导出 |
|||
|
|||
**修改文件**: |
|||
|
|||
- `app/App.tsx` - 集成 AlertProvider |
|||
- `app/src/screens/auth/LoginScreen.tsx` - 替换 Alert 调用 |
|||
- `app/src/screens/chat/ChatDetailScreen.tsx` - 替换 Alert 调用 |
|||
- `app/src/screens/chat/ChatListScreen.tsx` - 替换 Alert 调用 |
|||
- `app/src/screens/constitution/ConstitutionTestScreen.tsx` - 替换 Alert 调用 |
|||
- `app/src/screens/profile/ProfileScreen.tsx` - 替换 Alert 调用 |
|||
|
|||
**使用方式**: |
|||
|
|||
```tsx |
|||
import { useAlert } from "../../components"; |
|||
|
|||
const { showAlert, showToast } = useAlert(); |
|||
|
|||
// Toast 简单提示 |
|||
showToast("请输入正确的手机号"); |
|||
|
|||
// Dialog 确认弹窗 |
|||
showAlert("确认删除", "确定要删除吗?", [ |
|||
{ text: "取消", style: "cancel" }, |
|||
{ text: "删除", style: "destructive", onPress: () => handleDelete() }, |
|||
]); |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 2026-02-02: 健康档案功能对接 |
|||
|
|||
**内容**: 对接后端真实 API,实现健康档案功能 |
|||
|
|||
**新增文件**: |
|||
|
|||
- `app/src/stores/healthStore.ts` - 健康档案状态管理 |
|||
- `app/src/screens/profile/HealthProfileScreen.tsx` - 健康档案页面 |
|||
|
|||
**修改文件**: |
|||
|
|||
- `app/src/api/user.ts` - 添加健康档案相关 API(病史、家族病史、过敏记录) |
|||
- `app/src/navigation/index.tsx` - 添加 ProfileStack 和健康档案路由 |
|||
- `app/src/screens/profile/ProfileScreen.tsx` - 对接健康档案导航和真实病史数据 |
|||
|
|||
**功能说明**: |
|||
|
|||
1. **健康档案页面**: 展示基础信息、生活习惯、病史记录、家族病史、过敏记录 |
|||
2. **用药记录**: 改用后端病史数据,显示"治疗中"状态的记录 |
|||
3. **支持下拉刷新和长按删除** |
|||
|
|||
**API 对接**: |
|||
|
|||
- `GET /api/user/health-profile` - 获取完整健康档案 |
|||
- `GET /api/user/lifestyle` - 获取生活习惯 |
|||
- `GET /api/user/medical-history` - 获取病史列表 |
|||
- `GET /api/user/family-history` - 获取家族病史 |
|||
- `GET /api/user/allergy-records` - 获取过敏记录 |
|||
- `DELETE /api/user/medical-history/:id` - 删除病史 |
|||
- `DELETE /api/user/family-history/:id` - 删除家族病史 |
|||
- `DELETE /api/user/allergy-records/:id` - 删除过敏记录 |
|||
|
|||
--- |
|||
|
|||
### 2026-02-02: API 响应格式统一规范 |
|||
|
|||
**问题**: 前后端响应格式不匹配导致数据解析失败(已出现两次:Token 获取、对话列表) |
|||
|
|||
**根本原因**: |
|||
|
|||
- 前端期望: `data: { conversations: [...], total: number }` |
|||
- 后端实际: `data: [...]` (直接返回数组) |
|||
|
|||
**解决方案**: |
|||
|
|||
1. 创建统一类型定义文件 `app/src/api/types.ts` |
|||
2. 明确约定:列表接口 data 直接返回数组,不包装 |
|||
3. Store 层统一做 snake_case → camelCase 转换 |
|||
|
|||
**新增文件**: |
|||
|
|||
- `app/src/api/types.ts` - 统一 API 类型定义 |
|||
|
|||
**修改文件**: |
|||
|
|||
- `app/src/api/conversation.ts` - 使用新类型定义 |
|||
- `app/src/stores/chatStore.ts` - 添加转换函数 |
|||
|
|||
**关键代码**: |
|||
|
|||
```typescript |
|||
// app/src/api/types.ts - API 响应类型与后端保持一致 |
|||
export interface ConversationItem { |
|||
id: number; |
|||
title: string; |
|||
created_at: string; // snake_case |
|||
updated_at: string; |
|||
} |
|||
|
|||
// app/src/stores/chatStore.ts - 转换函数 |
|||
const convertConversation = (item: ConversationItem): Conversation => ({ |
|||
id: String(item.id), |
|||
title: item.title, |
|||
createdAt: item.created_at, // 转为 camelCase |
|||
updatedAt: item.updated_at, |
|||
}); |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 2026-02-02: 体质分析功能修复 |
|||
|
|||
**问题**: 体质分析功能多处 API 格式不匹配导致运行时错误 |
|||
|
|||
**错误信息**: |
|||
|
|||
1. `Cannot read properties of undefined (reading 'length')` - questions 字段 |
|||
2. `currentQuestion.options.map is not a function` - options 字段格式 |
|||
3. `Cannot read properties of undefined (reading 'suggestions')` - 结果格式 |
|||
|
|||
**根本原因**: |
|||
|
|||
- 问卷题目:后端直接返回数组 `data: [...]`,前端期望 `data: { questions: [...] }` |
|||
- 选项格式:后端存储 `["没有","很少","有时","经常","总是"]`,前端期望 `[{value, label}]` |
|||
- 结果格式:后端返回 `{ primary_constitution: {...}, all_scores: [...] }`,前端期望 `{ primaryType, scores }` |
|||
|
|||
**修改文件**: |
|||
|
|||
- `app/src/stores/constitutionStore.ts` - 添加数据格式转换 |
|||
- `app/src/api/constitution.ts` - 更新类型定义 |
|||
- `app/src/screens/constitution/ConstitutionResultScreen.tsx` - 添加防御性代码 |
|||
|
|||
**关键修复代码**: |
|||
|
|||
```typescript |
|||
// 问卷数据转换 |
|||
const questions = rawQuestions.map((q: any) => { |
|||
let parsedOptions = |
|||
typeof q.options === "string" ? JSON.parse(q.options) : q.options; |
|||
|
|||
// 字符串数组转为 {value, label} 格式 |
|||
if (Array.isArray(parsedOptions) && typeof parsedOptions[0] === "string") { |
|||
parsedOptions = parsedOptions.map((label: string, index: number) => ({ |
|||
value: index + 1, |
|||
label, |
|||
})); |
|||
} |
|||
|
|||
return { |
|||
id: q.id || q.ID, |
|||
constitution_type: q.constitution_type, |
|||
question: q.question_text || q.question, |
|||
options: parsedOptions, |
|||
order_num: q.order_num, |
|||
}; |
|||
}); |
|||
|
|||
// 结果数据转换 |
|||
const scores: Record<string, number> = {}; |
|||
apiResult.all_scores?.forEach((item: any) => { |
|||
scores[item.type] = item.score; |
|||
}); |
|||
|
|||
const result = { |
|||
primaryType: apiResult.primary_constitution?.type, |
|||
scores, |
|||
// ... |
|||
}; |
|||
``` |
|||
|
|||
**测试验证**: |
|||
|
|||
- 测试脚本: `tests/constitution.test.js` |
|||
- 测试结果: 12/13 通过,67 道题完整测试 |
|||
- 测试截图: `tests/screenshots/constitution-result.png` |
|||
|
|||
--- |
|||
|
|||
### 2026-02-02: "我的"页面功能测试 |
|||
|
|||
**测试内容**: |
|||
|
|||
1. 用户信息显示(昵称、手机号、体质标签) |
|||
2. 编辑昵称功能 |
|||
3. 适老模式开关 |
|||
4. 健康管理菜单(健康档案、用药记录、体质报告、对话历史) |
|||
5. 用药/治疗记录弹窗 |
|||
6. 关于我们弹窗 |
|||
7. 退出登录功能 |
|||
8. 健康档案导航 |
|||
|
|||
**测试结果**: 16/18 通过 |
|||
|
|||
**通过项目**: |
|||
|
|||
- ✓ 用户信息显示完整 |
|||
- ✓ 编辑昵称功能正常 |
|||
- ✓ 适老模式开关正常 |
|||
- ✓ 所有菜单项显示正常 |
|||
- ✓ 用药记录弹窗正常 |
|||
- ✓ 退出登录按钮显示 |
|||
- ✓ 健康档案导航正常 |
|||
|
|||
**测试文件**: |
|||
|
|||
- 测试脚本: `tests/profile.test.js` |
|||
- 测试截图: `tests/screenshots/profile-*.png`, `tests/screenshots/health-profile-*.png` |
|||
- 测试文档: `tests/README.md` |
|||
|
|||
--- |
|||
|
|||
### 2026-02-02: 健康档案编辑功能 |
|||
|
|||
**新增功能**: |
|||
|
|||
1. 基础信息编辑弹窗 - 编辑姓名、性别、出生日期、身高、体重、血型、职业、婚姻状况、地区 |
|||
2. 生活习惯编辑弹窗 - 编辑睡眠时间、睡眠质量、饮食、运动、吸烟、饮酒等 |
|||
3. 病史记录新增功能 - 添加疾病名称、类型、诊断日期、状态、备注 |
|||
4. 家族病史新增功能 - 添加亲属关系、疾病名称、备注 |
|||
5. 过敏记录新增功能 - 添加过敏类型、过敏原、严重程度、反应描述 |
|||
|
|||
**修改文件**: |
|||
|
|||
- `app/src/api/user.ts` - 添加 addMedicalHistory, addFamilyHistory, addAllergyRecord API |
|||
- `app/src/stores/healthStore.ts` - 添加创建记录的方法 |
|||
- `app/src/screens/profile/HealthProfileScreen.tsx` - 添加编辑弹窗和新增功能 |
|||
|
|||
**测试结果**: 23/25 通过 |
|||
|
|||
**通过项目**: |
|||
|
|||
- ✓ 健康档案页面打开 |
|||
- ✓ 基础信息卡片显示 |
|||
- ✓ 基础信息编辑弹窗打开 |
|||
- ✓ 生活习惯卡片显示 |
|||
- ✓ 病史记录卡片显示 |
|||
- ✓ 病史记录新增弹窗打开 |
|||
- ✓ 家族病史卡片显示 |
|||
- ✓ 过敏记录卡片显示 |
|||
|
|||
--- |
|||
|
|||
### 2026-02-02: 健康档案保存功能修复 |
|||
|
|||
**问题描述**: |
|||
|
|||
1. 基础信息保存后页面不显示更新的数据 |
|||
2. 病史记录、家族病史、过敏记录添加失败 |
|||
3. 后端返回的数据格式与前端期望不匹配 |
|||
|
|||
**根本原因**: |
|||
|
|||
1. `fetchHealthProfile` 错误地将 `response.data` 直接赋给 `profile`,但后端返回的是嵌套结构 `{ profile, lifestyle, medical_history, ... }` |
|||
2. 后端字段名 `medical_history`(单数)与前端使用的 `medical_histories`(复数)不匹配 |
|||
3. 添加记录时后端返回 `data: null`,但前端检查 `response.code === 0 && response.data`,导致误判为失败 |
|||
|
|||
**修复内容**: |
|||
|
|||
- `app/src/stores/healthStore.ts`: |
|||
- `fetchHealthProfile`: 修复数据解析,支持嵌套结构 `data.profile || data` |
|||
- `fetchHealthProfile`: 兼容两种字段名 `data.medical_history || data.medical_histories` |
|||
- `updateHealthProfile`: 修改判断条件,只检查 `response.code === 0` |
|||
- `updateLifestyle`: 同上 |
|||
- `addMedicalHistory`: 修改判断条件,只检查 `response.code === 0`,并在成功后调用 `fetchHealthProfile()` 刷新数据 |
|||
- `addFamilyHistory`: 同上 |
|||
- `addAllergyRecord`: 同上 |
|||
|
|||
**测试验证**: |
|||
|
|||
- 测试脚本: `tests/health-profile-complete.test.js` |
|||
- 测试范围: |
|||
- 基础信息: 9 个字段(姓名、性别、出生日期、身高、体重、血型、职业、婚姻状况、地区) |
|||
- 生活习惯: 10 个字段(入睡时间、起床时间、睡眠质量、三餐规律、饮食偏好、日饮水量、运动频率、运动类型、吸烟、饮酒) |
|||
- 病史记录: 5 个字段(疾病名称、疾病类型、诊断日期、治疗状态、备注) |
|||
- 家族病史: 3 个字段(亲属关系、疾病名称、备注) |
|||
- 过敏记录: 4 个字段(过敏类型、过敏原、严重程度、过敏反应描述) |
|||
- 测试结果: **58/58 通过** ✓ |
|||
- 测试截图: `tests/screenshots/hp-*.png` |
|||
|
|||
@ -1 +1 @@ |
|||
Subproject commit f17111e186475083360ebb9bdadfe75be6163c11 |
|||
Subproject commit 4045a0873a31f059adf14e7f2752f35befc756fc |
|||
@ -0,0 +1,235 @@ |
|||
# 健康应用需求记录文档 |
|||
|
|||
> 本文档记录了项目开发过程中用户提出的所有需求和功能相关提示词,按时间顺序整理。 |
|||
|
|||
--- |
|||
|
|||
## 一、平台兼容性需求 |
|||
|
|||
### 1.1 RN 转 Web 开发 |
|||
|
|||
> 当前的 APP 使用了 rn 进行开发,但是目前无法接上真机设备,需要把 rn 先转成 react h5 进行开发,后续再转到设备 |
|||
|
|||
**解决方案**:使用 Expo Web (`expo start --web`) 在浏览器中运行 React Native 应用 |
|||
|
|||
### 1.2 Web 端兼容问题 |
|||
|
|||
> expo start --web 好像在浏览器上有些兼容问题,比如当前 rn APP 的登录页,错误弹窗一直无法显示 |
|||
|
|||
**解决方案**:创建自定义 `AlertProvider` 组件,使用 `react-native-paper` 的 `Snackbar` 和 `Dialog` 替代原生 `Alert` |
|||
|
|||
--- |
|||
|
|||
## 二、API 接口对接需求 |
|||
|
|||
### 2.1 真实接口对接 |
|||
|
|||
> 下面继续完成真实接口的对接,首先是对话记录的管理,和"我的"页面上未完成的功能,很多数据都没有接入真实数据 |
|||
|
|||
### 2.2 统一响应格式 |
|||
|
|||
> 要和后端约定一个统一的响应格式,这个响应格式不匹配问题已经出现了两次,第一次是 token 获取不到 |
|||
|
|||
**解决方案**: |
|||
|
|||
- 创建统一的 `ApiResponse<T>` 类型 |
|||
- 处理 `snake_case`(后端)与 `camelCase`(前端)的命名转换 |
|||
|
|||
--- |
|||
|
|||
## 三、对话功能需求 |
|||
|
|||
### 3.1 历史对话持久化 |
|||
|
|||
> 历史对话,每次刷新页面都会丢失 |
|||
|
|||
> 还是没有,使用后端存储,这样可以避免本地存储的兼容问题 |
|||
|
|||
**解决方案**:移除本地缓存,完全依赖后端存储对话记录 |
|||
|
|||
### 3.2 对话删除功能 |
|||
|
|||
> 历史对话里的删除功能,点击无响应,应该是先要弹出确认弹窗,然后执行记录删除 |
|||
|
|||
> 点击删除,记录弹窗不关闭,确认弹窗被遮挡 |
|||
|
|||
**解决方案**:使用 `setTimeout` 在关闭记录弹窗后再显示确认弹窗 |
|||
|
|||
### 3.3 问答页面 UI 调整 |
|||
|
|||
> 问答页面的 新建悬浮窗 移除,右上的历史按钮 改成 "对话管理" |
|||
|
|||
**修改内容**: |
|||
|
|||
- 移除右下角 "新建对话" FAB 悬浮按钮 |
|||
- 右上角按钮文字从 "历史" 改为 "对话管理" |
|||
- 弹窗标题从 "历史对话" 改为 "对话管理" |
|||
|
|||
--- |
|||
|
|||
## 四、用户中心需求 |
|||
|
|||
### 4.1 用户信息编辑 |
|||
|
|||
> 我的 页面,用户信息编辑功能点击无效 |
|||
|
|||
> 点击保存显示失败,你应该测试完功能再交付 |
|||
|
|||
**解决方案**: |
|||
|
|||
- 实现 `handleOpenEdit` 和 `handleSaveProfile` 方法 |
|||
- 修复 `authStore.updateProfile` 处理后端返回 `data: null` 的情况 |
|||
|
|||
### 4.2 首页欢迎语 |
|||
|
|||
> 首页欢迎词语,现在显示的是健康达人,应该显示的是用户的昵称 |
|||
|
|||
**解决方案**:修改 `HomeScreen.tsx`,动态显示 `user?.nickname` |
|||
|
|||
--- |
|||
|
|||
## 五、健康档案需求 |
|||
|
|||
### 5.1 健康档案编辑功能 |
|||
|
|||
> 我的->健康档案里的信息只有显示,没有编辑功能,在上一个测试文件中添加健康档案的功能测试,并且添加编辑功能 |
|||
|
|||
**实现内容**: |
|||
|
|||
- 基础信息编辑(9 个字段) |
|||
- 生活习惯编辑(10 个字段) |
|||
- 病史记录添加 |
|||
- 家族病史添加 |
|||
- 过敏记录添加 |
|||
|
|||
### 5.2 健康档案保存问题 |
|||
|
|||
> 健康档案测试->每一项可填写或可选择内容都需要进行相关测试,特别是基础信息填写,并且填写保存后查看是否正确保存,现在有些功能并不能正确保存 |
|||
|
|||
**问题根因**: |
|||
|
|||
1. 后端返回嵌套结构,前端解析错误 |
|||
2. 字段命名不一致(`medical_history` vs `medical_histories`) |
|||
3. 后端添加操作返回 `data: null`,前端判断失败 |
|||
|
|||
**解决方案**:修复 `healthStore.ts` 的数据解析和成功判断逻辑 |
|||
|
|||
### 5.3 病史记录删除 |
|||
|
|||
> 健康档案里的病史记录,长按删除显示失败,修复后添加到测试 |
|||
|
|||
**问题根因**:GORM 的 `ID` 字段没有 `json` 标签,序列化为大写 `ID`,前端获取不到正确的 id |
|||
|
|||
**解决方案**:修改后端模型,显式定义 `ID` 字段并添加 `json:"id"` 标签 |
|||
|
|||
--- |
|||
|
|||
## 六、体质分析需求 |
|||
|
|||
### 6.1 体质结果显示问题 |
|||
|
|||
> 体质页面,显示已完成体测,但是体质判断结果没显示,点击计入也是白屏,请测试并修复 |
|||
|
|||
**问题根因**: |
|||
|
|||
- `constitutionDescriptions[result.primaryType]` 访问 undefined |
|||
- 问题选项格式转换错误 |
|||
- 后端返回数据结构与前端期望不匹配 |
|||
|
|||
**解决方案**: |
|||
|
|||
- 添加防御性检查 |
|||
- 修复数据格式转换 |
|||
- 完善 `submitAnswers` 和 `fetchResult` 的数据处理 |
|||
|
|||
--- |
|||
|
|||
## 七、自动化测试需求 |
|||
|
|||
### 7.1 引入自动化测试 |
|||
|
|||
> 还是不行,你应该可以自己进行测试,使用 PLAYWRIGHT MCP,而不是让我重复测试 |
|||
|
|||
### 7.2 体质分析测试 |
|||
|
|||
> 建立测试脚本,进行体质分析功能的测试 |
|||
|
|||
### 7.3 保留测试脚本 |
|||
|
|||
> 测试脚本不要删除,整理出一个测试脚本使用文档 |
|||
|
|||
### 7.4 "我的"页面测试 |
|||
|
|||
> 编写测试脚本,对 "我的" 页面上的功能进行完成测试,有错误或者未完成的,进行修复。完成测试和修复,整理测试使用文档 |
|||
|
|||
### 7.5 健康档案完整测试 |
|||
|
|||
> 脚本需要先进行健康档案的测试,再到用药和治疗记录 |
|||
|
|||
> 1.在我的观测中,简况管理测试后的测试界面一直卡在图片这里闪烁,2,健康管理测试,只是测试了页面上的卡片是否存在,没有测试具体编辑和保存功能,添加具体功能测试 |
|||
|
|||
**测试脚本**: |
|||
|
|||
- `tests/constitution.test.js` - 体质分析测试 |
|||
- `tests/profile.test.js` - "我的"页面测试 |
|||
- `tests/health-profile-complete.test.js` - 健康档案完整测试 |
|||
|
|||
--- |
|||
|
|||
## 八、开发规范需求 |
|||
|
|||
### 8.1 前端自动化测试规范 |
|||
|
|||
> 前端进行功能更新和修改后,使用 PLAYWRIGHT MCP 进行自动化测试 |
|||
|
|||
**已添加到 AGENTS.md 开发规范** |
|||
|
|||
--- |
|||
|
|||
## 需求统计 |
|||
|
|||
| 类别 | 数量 | |
|||
| ---------- | ------ | |
|||
| 平台兼容性 | 2 | |
|||
| API 接口 | 2 | |
|||
| 对话功能 | 3 | |
|||
| 用户中心 | 2 | |
|||
| 健康档案 | 3 | |
|||
| 体质分析 | 1 | |
|||
| 自动化测试 | 5 | |
|||
| 开发规范 | 1 | |
|||
| **总计** | **19** | |
|||
|
|||
--- |
|||
|
|||
## 附:原始需求记录(按时间顺序) |
|||
|
|||
1. 当前的 APP 使用了 rn 进行开发,但是目前无法接上真机设备,需要把 rn 先转成 react h5 进行开发,后续再转到设备 |
|||
2. expo start --web 好像在浏览器上有些兼容问题,比如当前 rn APP 的登录页,错误弹窗一直无法显示 |
|||
3. 弹窗已经显示,可以继续使用这种方式 |
|||
4. 下面继续完成真实接口的对接,首先是对话记录的管理,和"我的"页面上未完成的功能,很多数据都没有接入真实数据 |
|||
5. 历史对话,每次刷新页面都会丢失 |
|||
6. 还是没有,使用后端存储,这样可以避免本地存储的兼容问题 |
|||
7. 还是没有历史对话 |
|||
8. 要和后端约定一个统一的响应格式,这个响应格式不匹配问题已经出现了两次,第一次是 token 获取不到 |
|||
9. 历史对话里的删除功能,点击无响应,应该是先要弹出确认弹窗,然后执行记录删除 |
|||
10. 点击删除,记录弹窗不关闭,确认弹窗被遮挡 |
|||
11. 还是不行,你应该可以自己进行测试,使用 PLAYWRIGHT MCP,而不是让我重复测试 |
|||
12. 我的 页面,用户信息编辑功能点击无效 |
|||
13. 点击保存显示失败,你应该测试完功能再交付 |
|||
14. 首页欢迎词语,现在显示的是健康达人,应该显示的是用户的昵称 |
|||
15. 建立测试脚本,进行体质分析功能的测试 |
|||
16. http://localhost:8081/ 当前访问项目为白屏 |
|||
17. 体质页面,显示已完成体测,但是体质判断结果没显示,点击计入也是白屏,请测试并修复 |
|||
18. 测试脚本不要删除,整理出一个测试脚本使用文档 |
|||
19. 编写测试脚本,对 "我的" 页面上的功能进行完成测试,有错误或者未完成的,进行修复。完成测试和修复,整理测试使用文档 |
|||
20. 我的->健康档案里的信息只有显示,没有编辑功能,在上一个测试文件中添加健康档案的功能测试,并且添加编辑功能 |
|||
21. 脚本需要先进行健康档案的测试,再到用药和治疗记录 |
|||
22. 在我的观测中,简况管理测试后的测试界面一直卡在图片这里闪烁;健康管理测试只是测试了页面上的卡片是否存在,没有测试具体编辑和保存功能,添加具体功能测试 |
|||
23. 健康档案测试->每一项可填写或可选择内容都需要进行相关测试,特别是基础信息填写,并且填写保存后查看是否正确保存,现在有些功能并不能正确保存 |
|||
24. 问答页面的 新建悬浮窗 移除,右上的历史按钮 改成 "对话管理" |
|||
25. 健康档案里的病史记录,长按删除显示失败,修复后添加到测试 |
|||
|
|||
--- |
|||
|
|||
_文档最后更新:2026-02-02_ |
|||
@ -0,0 +1,75 @@ |
|||
{ |
|||
"name": "healthApps", |
|||
"lockfileVersion": 2, |
|||
"requires": true, |
|||
"packages": { |
|||
"": { |
|||
"dependencies": { |
|||
"playwright": "^1.58.1" |
|||
} |
|||
}, |
|||
"node_modules/fsevents": { |
|||
"version": "2.3.2", |
|||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", |
|||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", |
|||
"hasInstallScript": true, |
|||
"optional": true, |
|||
"os": [ |
|||
"darwin" |
|||
], |
|||
"engines": { |
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" |
|||
} |
|||
}, |
|||
"node_modules/playwright": { |
|||
"version": "1.58.1", |
|||
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.58.1.tgz", |
|||
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", |
|||
"dependencies": { |
|||
"playwright-core": "1.58.1" |
|||
}, |
|||
"bin": { |
|||
"playwright": "cli.js" |
|||
}, |
|||
"engines": { |
|||
"node": ">=18" |
|||
}, |
|||
"optionalDependencies": { |
|||
"fsevents": "2.3.2" |
|||
} |
|||
}, |
|||
"node_modules/playwright-core": { |
|||
"version": "1.58.1", |
|||
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.58.1.tgz", |
|||
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", |
|||
"bin": { |
|||
"playwright-core": "cli.js" |
|||
}, |
|||
"engines": { |
|||
"node": ">=18" |
|||
} |
|||
} |
|||
}, |
|||
"dependencies": { |
|||
"fsevents": { |
|||
"version": "2.3.2", |
|||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", |
|||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", |
|||
"optional": true |
|||
}, |
|||
"playwright": { |
|||
"version": "1.58.1", |
|||
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.58.1.tgz", |
|||
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", |
|||
"requires": { |
|||
"fsevents": "2.3.2", |
|||
"playwright-core": "1.58.1" |
|||
} |
|||
}, |
|||
"playwright-core": { |
|||
"version": "1.58.1", |
|||
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.58.1.tgz", |
|||
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==" |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,5 @@ |
|||
{ |
|||
"dependencies": { |
|||
"playwright": "^1.58.1" |
|||
} |
|||
} |
|||
@ -0,0 +1,476 @@ |
|||
# 自动化测试文档 |
|||
|
|||
本目录包含基于 Playwright 的端到端 (E2E) 自动化测试脚本。 |
|||
|
|||
## 目录结构 |
|||
|
|||
``` |
|||
tests/ |
|||
├── README.md # 本文档 |
|||
├── constitution.test.js # 体质分析功能测试 |
|||
├── profile.test.js # "我的"页面功能测试 |
|||
├── health-profile-complete.test.js # 健康档案完整功能测试(推荐) |
|||
└── screenshots/ # 测试截图目录 |
|||
├── constitution-result.png # 体质测试结果截图 |
|||
├── profile-page.png # 我的页面截图 |
|||
├── hp-basic-*.png # 基础信息编辑截图 |
|||
├── hp-lifestyle-*.png # 生活习惯编辑截图 |
|||
├── hp-medical-*.png # 病史记录添加截图 |
|||
└── hp-allergy-*.png # 过敏记录添加截图 |
|||
``` |
|||
|
|||
## 环境准备 |
|||
|
|||
### 1. 安装依赖 |
|||
|
|||
```bash |
|||
# 安装 Playwright |
|||
npm install playwright |
|||
|
|||
# 安装浏览器(首次运行) |
|||
npx playwright install chromium |
|||
``` |
|||
|
|||
### 2. 启动应用 |
|||
|
|||
测试前需要确保前端和后端服务都在运行: |
|||
|
|||
```bash |
|||
# 终端1: 启动后端服务 |
|||
cd server |
|||
go run main.go |
|||
|
|||
# 终端2: 启动前端服务 |
|||
cd app |
|||
npm start |
|||
# 或 |
|||
npx expo start --web |
|||
``` |
|||
|
|||
确保应用可以通过 `http://localhost:8081` 访问。 |
|||
|
|||
## 运行测试 |
|||
|
|||
### 运行所有测试 |
|||
|
|||
```bash |
|||
# 从项目根目录运行 |
|||
node tests/constitution.test.js # 体质分析测试 |
|||
node tests/profile.test.js # "我的"页面测试 |
|||
node tests/health-profile-complete.test.js # 健康档案完整测试(推荐) |
|||
``` |
|||
|
|||
### 运行体质分析测试 |
|||
|
|||
```bash |
|||
node tests/constitution.test.js |
|||
``` |
|||
|
|||
### 运行"我的"页面测试 |
|||
|
|||
```bash |
|||
node tests/profile.test.js |
|||
``` |
|||
|
|||
### 运行健康档案完整测试(推荐) |
|||
|
|||
```bash |
|||
node tests/health-profile-complete.test.js |
|||
``` |
|||
|
|||
### 测试配置 |
|||
|
|||
测试脚本中的配置项(位于文件开头): |
|||
|
|||
```javascript |
|||
const APP_URL = "http://localhost:8081"; // 应用地址 |
|||
const TEST_PHONE = "13800138000"; // 测试手机号 |
|||
const TEST_CODE = "123456"; // 测试验证码 |
|||
``` |
|||
|
|||
## 测试脚本说明 |
|||
|
|||
### constitution.test.js - 体质分析功能测试 |
|||
|
|||
**测试流程:** |
|||
|
|||
1. **登录** - 使用测试账号登录应用 |
|||
2. **导航** - 进入"体质"Tab |
|||
3. **开始测试** - 点击"开始测试"按钮 |
|||
4. **回答问题** - 自动回答 67 道体质问卷题目 |
|||
5. **提交** - 提交答案获取结果 |
|||
6. **验证结果** - 检查结果页面各元素 |
|||
7. **重新测评** - 测试重新测评功能 |
|||
|
|||
**验证项目:** |
|||
|
|||
| 检查项 | 说明 | |
|||
| ---------------- | ---------------------- | |
|||
| 登录 | 验证登录流程正常 | |
|||
| 导航到体质页面 | 验证 Tab 导航正常 | |
|||
| 进入测试页面 | 验证开始测试按钮可点击 | |
|||
| 回答所有问题 | 验证 67 道题目全部完成 | |
|||
| 提交并查看结果 | 验证提交后跳转到结果页 | |
|||
| 体质分析报告标题 | 验证结果页标题显示 | |
|||
| 主体质名称 | 验证显示体质类型名称 | |
|||
| 体质得分卡片 | 验证得分区域显示 | |
|||
| 体质特征卡片 | 验证特征区域显示 | |
|||
| 调理建议卡片 | 验证建议区域显示 | |
|||
| 咨询 AI 助手按钮 | 验证功能按钮显示 | |
|||
| 重新测评按钮 | 验证重新测评按钮显示 | |
|||
| 重新测评导航 | 验证重新测评功能正常 | |
|||
|
|||
**输出示例:** |
|||
|
|||
``` |
|||
═══════════════════════════════════════════════════════════ |
|||
体质分析功能自动化测试 |
|||
═══════════════════════════════════════════════════════════ |
|||
|
|||
打开应用... |
|||
|
|||
【步骤1】检查登录状态... |
|||
执行登录流程... |
|||
✓ 登录 |
|||
|
|||
【步骤2】导航到体质Tab... |
|||
✓ 导航到体质页面 |
|||
|
|||
【步骤3】开始体质测试... |
|||
✓ 进入测试页面 |
|||
|
|||
【步骤4】回答问题... |
|||
回答第 1/67 题... |
|||
回答第 2/67 题... |
|||
... |
|||
回答第 67/67 题... |
|||
所有题目已回答,准备提交... |
|||
✓ 回答所有问题: 共 67 题 |
|||
|
|||
【步骤5】提交测试... |
|||
✓ 提交并查看结果 |
|||
|
|||
【步骤6】验证结果页面内容... |
|||
✓ 体质分析报告标题 |
|||
✓ 主体质名称 |
|||
✓ 体质得分卡片 |
|||
✓ 体质特征卡片 |
|||
✓ 调理建议卡片 |
|||
✓ 咨询AI助手按钮 |
|||
✓ 重新测评按钮 |
|||
|
|||
检测到体质类型: 特禀质 |
|||
|
|||
【步骤7】测试重新测评功能... |
|||
✓ 重新测评导航 |
|||
|
|||
═══════════════════════════════════════════════════════════ |
|||
测试结果摘要 |
|||
═══════════════════════════════════════════════════════════ |
|||
通过: 13 失败: 0 |
|||
─────────────────────────────────────────────────────────── |
|||
✓ 登录 |
|||
✓ 导航到体质页面 |
|||
✓ 进入测试页面 |
|||
✓ 回答所有问题 - 共 67 题 |
|||
✓ 提交并查看结果 |
|||
✓ 体质分析报告标题 |
|||
✓ 主体质名称 |
|||
✓ 体质得分卡片 |
|||
✓ 体质特征卡片 |
|||
✓ 调理建议卡片 |
|||
✓ 咨询AI助手按钮 |
|||
✓ 重新测评按钮 |
|||
✓ 重新测评导航 |
|||
═══════════════════════════════════════════════════════════ |
|||
``` |
|||
|
|||
### profile.test.js - "我的"页面功能测试 |
|||
|
|||
**测试流程:** |
|||
|
|||
1. **登录** - 使用测试账号登录 |
|||
2. **导航** - 进入"我的"Tab 页面 |
|||
3. **用户信息显示** - 验证昵称、手机号、编辑按钮 |
|||
4. **编辑昵称** - 打开弹窗、修改昵称、保存 |
|||
5. **适老模式** - 验证开关功能 |
|||
6. **健康管理菜单** - 验证四个菜单项显示 |
|||
7. **健康档案导航** - 跳转到健康档案页面 |
|||
8. **健康档案编辑功能** - 测试基础信息/生活习惯编辑、病史/家族病史/过敏记录新增 |
|||
9. **用药/治疗记录** - 打开弹窗查看 |
|||
10. **关于我们** - 打开弹窗查看 |
|||
11. **退出登录** - 点击并验证确认弹窗 |
|||
|
|||
**验证项目:** |
|||
|
|||
| 检查项 | 说明 | |
|||
| -------------------- | -------------------- | |
|||
| 登录 | 使用测试账号登录 | |
|||
| 导航到"我的"页面 | Tab 导航正常 | |
|||
| 用户昵称显示 | 显示用户昵称 | |
|||
| 手机号显示 | 显示手机号 | |
|||
| 编辑按钮显示 | 编辑图标可见 | |
|||
| 编辑弹窗打开 | 点击编辑打开弹窗 | |
|||
| 保存昵称 | 修改昵称并保存成功 | |
|||
| 适老模式卡片显示 | 适老模式开关可见 | |
|||
| 适老模式开关 | 开关可切换 | |
|||
| 健康档案菜单显示 | 健康档案菜单可见 | |
|||
| 用药记录菜单显示 | 用药记录菜单可见 | |
|||
| 体质报告菜单显示 | 体质报告菜单可见 | |
|||
| 对话历史菜单显示 | 对话历史菜单可见 | |
|||
| 健康档案页面打开 | 导航到健康档案页 | |
|||
| 基础信息卡片显示 | 基础信息卡片可见 | |
|||
| 基础信息编辑弹窗打开 | 点击编辑按钮打开弹窗 | |
|||
| 生活习惯卡片显示 | 生活习惯卡片可见 | |
|||
| 病史记录卡片显示 | 病史记录卡片可见 | |
|||
| 病史记录新增弹窗打开 | 点击新增按钮打开弹窗 | |
|||
| 家族病史卡片显示 | 家族病史卡片可见 | |
|||
| 过敏记录卡片显示 | 过敏记录卡片可见 | |
|||
| 用药记录弹窗打开 | 点击打开用药记录弹窗 | |
|||
| 退出登录按钮显示 | 退出按钮可见 | |
|||
|
|||
**运行命令:** |
|||
|
|||
```bash |
|||
node tests/profile.test.js |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### health-profile-complete.test.js - 健康档案完整功能测试(推荐) |
|||
|
|||
这是最全面的健康档案测试脚本,覆盖所有可编辑字段的输入和保存验证。 |
|||
|
|||
**测试范围:** |
|||
|
|||
| 功能模块 | 测试字段数 | 测试内容 | |
|||
| -------- | ---------- | ------------------------------------------------------------------------------------------ | |
|||
| 基础信息 | 9 个字段 | 姓名、性别、出生日期、身高、体重、血型、职业、婚姻状况、地区 | |
|||
| 生活习惯 | 10 个字段 | 入睡时间、起床时间、睡眠质量、三餐规律、饮食偏好、日饮水量、运动频率、运动类型、吸烟、饮酒 | |
|||
| 病史记录 | 5 个字段 | 疾病名称、疾病类型、诊断日期、治疗状态、备注 | |
|||
| 家族病史 | 3 个字段 | 亲属关系、疾病名称、备注 | |
|||
| 过敏记录 | 4 个字段 | 过敏类型、过敏原、严重程度、过敏反应描述 | |
|||
|
|||
**测试流程:** |
|||
|
|||
1. **登录** - 使用测试账号登录 |
|||
2. **导航** - 进入健康档案页面 |
|||
3. **基础信息编辑** - 测试所有 9 个字段的输入和保存 |
|||
4. **生活习惯编辑** - 测试所有 10 个字段的输入和保存 |
|||
5. **病史记录添加** - 测试添加新病史记录(5 个字段) |
|||
6. **家族病史添加** - 测试添加新家族病史(3 个字段) |
|||
7. **过敏记录添加** - 测试添加新过敏记录(4 个字段) |
|||
8. **数据验证** - 刷新页面后验证所有数据是否正确保存 |
|||
|
|||
**验证项目:** |
|||
|
|||
| 检查项 | 说明 | |
|||
| ----------------------- | ------------------------- | |
|||
| 导航到健康档案页面 | Tab 和菜单导航正常 | |
|||
| 打开基础信息编辑弹窗 | 编辑按钮可点击 | |
|||
| 基础信息-姓名输入 | TextInput 输入正常 | |
|||
| 基础信息-性别选择 | SegmentedButtons 选择正常 | |
|||
| 基础信息-出生日期输入 | 日期格式输入正常 | |
|||
| 基础信息-身高/体重输入 | 数字输入正常 | |
|||
| 基础信息-血型输入 | 文本输入正常 | |
|||
| 基础信息-职业输入 | 文本输入正常 | |
|||
| 基础信息-婚姻状况选择 | SegmentedButtons 选择正常 | |
|||
| 基础信息-地区输入 | 文本输入正常 | |
|||
| 基础信息-保存成功 | API 调用成功,显示提示 | |
|||
| 基础信息-保存后数据显示 | 页面正确显示保存的数据 | |
|||
| 生活习惯-所有字段测试 | 同上,共 10 个字段 | |
|||
| 病史记录-添加新记录 | 弹窗、输入、添加、显示 | |
|||
| 家族病史-添加新记录 | 弹窗、输入、添加、显示 | |
|||
| 过敏记录-添加新记录 | 弹窗、输入、添加、显示 | |
|||
| 刷新后数据验证 | 页面刷新后数据仍然正确 | |
|||
|
|||
**输出示例:** |
|||
|
|||
``` |
|||
═══════════════════════════════════════════════════════════ |
|||
健康档案完整功能自动化测试 |
|||
═══════════════════════════════════════════════════════════ |
|||
|
|||
测试范围: |
|||
- 基础信息: 9个字段(姓名、性别、出生日期、身高、体重、血型、职业、婚姻、地区) |
|||
- 生活习惯: 10个字段(入睡时间、起床时间、睡眠质量、三餐规律、饮食偏好、 |
|||
日饮水量、运动频率、运动类型、吸烟、饮酒) |
|||
- 病史记录: 5个字段(疾病名称、疾病类型、诊断日期、治疗状态、备注) |
|||
- 家族病史: 3个字段(亲属关系、疾病名称、备注) |
|||
- 过敏记录: 4个字段(过敏类型、过敏原、严重程度、过敏反应描述) |
|||
|
|||
【步骤2】测试基础信息编辑(9个字段)... |
|||
✓ 打开基础信息编辑弹窗 |
|||
✓ 基础信息-姓名输入 |
|||
✓ 基础信息-性别选择 |
|||
✓ 基础信息-出生日期输入 |
|||
✓ 基础信息-身高输入 |
|||
✓ 基础信息-体重输入 |
|||
✓ 基础信息-血型输入 |
|||
✓ 基础信息-职业输入 |
|||
✓ 基础信息-婚姻状况选择 |
|||
✓ 基础信息-地区输入 |
|||
✓ 基础信息-保存成功 - 显示保存成功提示 |
|||
✓ 基础信息-保存后姓名显示 |
|||
✓ 基础信息-保存后地区显示 |
|||
✓ 基础信息-保存后职业显示 |
|||
|
|||
... (更多测试输出) |
|||
|
|||
═══════════════════════════════════════════════════════════ |
|||
测试结果摘要 |
|||
═══════════════════════════════════════════════════════════ |
|||
通过: 58 失败: 0 |
|||
═══════════════════════════════════════════════════════════ |
|||
``` |
|||
|
|||
**运行命令:** |
|||
|
|||
```bash |
|||
node tests/health-profile-complete.test.js |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 编写新测试 |
|||
|
|||
### 基础模板 |
|||
|
|||
```javascript |
|||
const { chromium } = require("playwright"); |
|||
|
|||
const APP_URL = "http://localhost:8081"; |
|||
|
|||
// 测试结果统计 |
|||
const testResults = { passed: 0, failed: 0, tests: [] }; |
|||
|
|||
function logTest(name, passed, detail = "") { |
|||
const status = passed ? "✓" : "✗"; |
|||
console.log(`${status} ${name}${detail ? ": " + detail : ""}`); |
|||
testResults.tests.push({ name, passed, detail }); |
|||
if (passed) testResults.passed++; |
|||
else testResults.failed++; |
|||
} |
|||
|
|||
async function runTests() { |
|||
const browser = await chromium.launch({ headless: false }); |
|||
const page = await browser.newPage(); |
|||
|
|||
// 监听错误 |
|||
page.on("console", (msg) => { |
|||
if (msg.type() === "error") { |
|||
console.log("[Console Error]", msg.text()); |
|||
} |
|||
}); |
|||
|
|||
page.on("pageerror", (error) => { |
|||
console.log("[Page Error]", error.message); |
|||
}); |
|||
|
|||
try { |
|||
await page.goto(APP_URL); |
|||
await page.waitForTimeout(2000); |
|||
|
|||
// 添加测试步骤... |
|||
} catch (error) { |
|||
console.error("测试中断:", error.message); |
|||
await page.screenshot({ path: "tests/screenshots/error.png" }); |
|||
} finally { |
|||
// 打印结果 |
|||
console.log(`\n通过: ${testResults.passed} 失败: ${testResults.failed}`); |
|||
await browser.close(); |
|||
process.exit(testResults.failed > 0 ? 1 : 0); |
|||
} |
|||
} |
|||
|
|||
runTests(); |
|||
``` |
|||
|
|||
### 常用操作 |
|||
|
|||
#### 点击元素 |
|||
|
|||
```javascript |
|||
// 方式1: 文本定位 |
|||
await page.locator("text=按钮文字").click(); |
|||
|
|||
// 方式2: 角色定位 |
|||
await page.getByRole("button", { name: "提交" }).click(); |
|||
|
|||
// 方式3: 坐标点击 (适用于 React Native Web) |
|||
const pos = await page.evaluate(() => { |
|||
const el = document.querySelector("text=按钮"); |
|||
const rect = el.getBoundingClientRect(); |
|||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; |
|||
}); |
|||
await page.mouse.click(pos.x, pos.y); |
|||
|
|||
// 强制点击 (忽略可见性检查) |
|||
await page.locator("text=按钮").click({ force: true }); |
|||
``` |
|||
|
|||
#### 输入文本 |
|||
|
|||
```javascript |
|||
const input = page.locator("input").first(); |
|||
await input.fill("输入内容"); |
|||
``` |
|||
|
|||
#### 等待元素 |
|||
|
|||
```javascript |
|||
// 等待元素可见 |
|||
await page.locator("text=内容").waitFor({ state: "visible", timeout: 5000 }); |
|||
|
|||
// 检查元素是否可见 |
|||
const visible = await page |
|||
.locator("text=内容") |
|||
.isVisible({ timeout: 2000 }) |
|||
.catch(() => false); |
|||
``` |
|||
|
|||
#### 截图 |
|||
|
|||
```javascript |
|||
await page.screenshot({ path: "tests/screenshots/截图名.png" }); |
|||
``` |
|||
|
|||
## 注意事项 |
|||
|
|||
### React Native Web 特殊处理 |
|||
|
|||
由于 React Native Web 的渲染机制,某些标准选择器可能不起作用: |
|||
|
|||
1. **优先使用坐标点击** - 通过 `evaluate` 获取元素位置后用 `mouse.click` |
|||
2. **使用 force 选项** - 某些元素可能被遮挡,使用 `{ force: true }` |
|||
3. **增加等待时间** - React Native 动画可能需要更长时间完成 |
|||
|
|||
### 测试账号 |
|||
|
|||
测试使用的账号: |
|||
|
|||
- 手机号: `13800138000` |
|||
- 验证码: `123456` |
|||
|
|||
### 截图目录 |
|||
|
|||
测试截图保存在 `tests/screenshots/` 目录下,包括: |
|||
|
|||
- 测试过程截图 |
|||
- 错误截图(当测试失败时自动保存) |
|||
|
|||
## CI/CD 集成 |
|||
|
|||
可以在 CI/CD 流程中使用 headless 模式运行测试: |
|||
|
|||
```javascript |
|||
// 修改 browser launch 配置 |
|||
const browser = await chromium.launch({ |
|||
headless: true, // 无头模式 |
|||
}); |
|||
``` |
|||
|
|||
退出码说明: |
|||
|
|||
- `0` - 所有测试通过 |
|||
- `1` - 存在测试失败 |
|||
@ -0,0 +1,387 @@ |
|||
/** |
|||
* 体质分析功能自动化测试脚本 |
|||
* 测试流程:登录 → 进入体质Tab → 开始测试 → 回答问题 → 提交 → 查看结果 |
|||
*/ |
|||
const { chromium } = require('playwright'); |
|||
|
|||
const APP_URL = 'http://localhost:8081'; |
|||
const TEST_PHONE = '13800138000'; |
|||
const TEST_CODE = '123456'; |
|||
|
|||
// 测试结果统计
|
|||
const testResults = { |
|||
passed: 0, |
|||
failed: 0, |
|||
tests: [] |
|||
}; |
|||
|
|||
function logTest(name, passed, detail = '') { |
|||
const status = passed ? '✓' : '✗'; |
|||
const msg = `${status} ${name}${detail ? ': ' + detail : ''}`; |
|||
console.log(msg); |
|||
testResults.tests.push({ name, passed, detail }); |
|||
if (passed) testResults.passed++; |
|||
else testResults.failed++; |
|||
} |
|||
|
|||
async function login(page) { |
|||
console.log('\n【步骤1】检查登录状态...'); |
|||
|
|||
const loginBtn = page.locator('text=登录').first(); |
|||
if (!(await loginBtn.isVisible({ timeout: 2000 }).catch(() => false))) { |
|||
logTest('已登录状态', true, '跳过登录流程'); |
|||
return true; |
|||
} |
|||
|
|||
console.log(' 执行登录流程...'); |
|||
|
|||
// 输入手机号
|
|||
const phoneInput = page.locator('input').first(); |
|||
await phoneInput.fill(TEST_PHONE); |
|||
await page.waitForTimeout(300); |
|||
|
|||
// 获取验证码
|
|||
const getCodeBtn = page.locator('text=获取验证码').first(); |
|||
if (await getCodeBtn.isVisible()) { |
|||
await getCodeBtn.click(); |
|||
await page.waitForTimeout(1000); |
|||
} |
|||
|
|||
// 输入验证码
|
|||
const codeInput = page.locator('input').nth(1); |
|||
await codeInput.fill(TEST_CODE); |
|||
await page.waitForTimeout(300); |
|||
|
|||
// 点击登录
|
|||
await loginBtn.click(); |
|||
await page.waitForTimeout(3000); |
|||
|
|||
// 验证登录成功
|
|||
const homeVisible = await page.locator('text=/.*好,.*$/').first().isVisible({ timeout: 5000 }).catch(() => false); |
|||
logTest('登录', homeVisible); |
|||
|
|||
return homeVisible; |
|||
} |
|||
|
|||
async function navigateToConstitution(page) { |
|||
console.log('\n【步骤2】导航到体质Tab...'); |
|||
|
|||
// 点击体质Tab
|
|||
const constitutionTab = page.locator('text=体质').first(); |
|||
if (await constitutionTab.isVisible()) { |
|||
await constitutionTab.click(); |
|||
await page.waitForTimeout(1500); |
|||
|
|||
// 验证进入体质首页
|
|||
const pageTitle = await page.locator('text=体质分析').first().isVisible({ timeout: 3000 }).catch(() => false); |
|||
logTest('导航到体质页面', pageTitle); |
|||
return pageTitle; |
|||
} |
|||
|
|||
logTest('导航到体质页面', false, '未找到体质Tab'); |
|||
return false; |
|||
} |
|||
|
|||
async function startTest(page) { |
|||
console.log('\n【步骤3】开始体质测试...'); |
|||
|
|||
// 截图当前状态
|
|||
await page.screenshot({ path: 'tests/screenshots/before-start.png' }); |
|||
|
|||
// 滚动到页面底部确保按钮可见
|
|||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); |
|||
await page.waitForTimeout(500); |
|||
|
|||
// 获取按钮元素位置并使用鼠标点击
|
|||
const btnBox = await page.evaluate(() => { |
|||
const allElements = document.querySelectorAll('*'); |
|||
for (const el of allElements) { |
|||
const text = el.textContent?.trim(); |
|||
if (text === '开始测试' || text === '重新测评') { |
|||
// 确保是按钮内的文本元素
|
|||
if (el.tagName === 'DIV' && el.children.length === 0) { |
|||
const rect = el.getBoundingClientRect(); |
|||
return { |
|||
x: rect.x + rect.width / 2, |
|||
y: rect.y + rect.height / 2, |
|||
text: text |
|||
}; |
|||
} |
|||
} |
|||
} |
|||
return null; |
|||
}); |
|||
|
|||
if (btnBox) { |
|||
console.log(` 找到按钮: ${btnBox.text} at (${btnBox.x}, ${btnBox.y})`); |
|||
|
|||
// 使用鼠标点击坐标
|
|||
await page.mouse.click(btnBox.x, btnBox.y); |
|||
console.log(' 已执行鼠标点击,等待页面加载...'); |
|||
await page.waitForTimeout(3000); |
|||
} else { |
|||
console.log(' 未找到按钮'); |
|||
logTest('进入测试页面', false, '未找到开始测试按钮'); |
|||
return false; |
|||
} |
|||
|
|||
// 截图点击后状态
|
|||
await page.screenshot({ path: 'tests/screenshots/after-start-click.png' }); |
|||
|
|||
// 验证进入测试页面 - 检查是否有"← 返回"按钮和进度条
|
|||
const backBtn = await page.locator('text=← 返回').first().isVisible({ timeout: 3000 }).catch(() => false); |
|||
const progressText = await page.locator('text=/第 \\d+ 题 \\/ 共 \\d+ 题/').first().isVisible({ timeout: 5000 }).catch(() => false); |
|||
const loadingText = await page.locator('text=加载题目中').first().isVisible({ timeout: 1000 }).catch(() => false); |
|||
|
|||
console.log(` 返回按钮: ${backBtn}, 进度显示: ${progressText}, 加载中: ${loadingText}`); |
|||
|
|||
// 如果显示加载中,等待更长时间
|
|||
if (loadingText) { |
|||
console.log(' 等待题目加载完成...'); |
|||
await page.waitForTimeout(8000); |
|||
await page.screenshot({ path: 'tests/screenshots/after-loading.png' }); |
|||
const progressNow = await page.locator('text=/第 \\d+ 题 \\/ 共 \\d+ 题/').first().isVisible({ timeout: 5000 }).catch(() => false); |
|||
logTest('进入测试页面', progressNow); |
|||
return progressNow; |
|||
} |
|||
|
|||
logTest('进入测试页面', backBtn && progressText); |
|||
return backBtn && progressText; |
|||
} |
|||
|
|||
async function answerQuestions(page) { |
|||
console.log('\n【步骤4】回答问题...'); |
|||
|
|||
let questionCount = 0; |
|||
let maxQuestions = 70; // 安全上限(实际67题)
|
|||
|
|||
while (questionCount < maxQuestions) { |
|||
questionCount++; |
|||
|
|||
// 获取当前题号信息
|
|||
const progressText = await page.locator('text=/第 \\d+ 题 \\/ 共 \\d+ 题/').first().textContent().catch(() => ''); |
|||
const match = progressText.match(/第 (\d+) 题 \/ 共 (\d+) 题/); |
|||
|
|||
if (match) { |
|||
const current = parseInt(match[1]); |
|||
const total = parseInt(match[2]); |
|||
console.log(` 回答第 ${current}/${total} 题...`); |
|||
|
|||
// 使用坐标点击选项 - 更可靠的方式
|
|||
const optionClicked = await page.evaluate(() => { |
|||
const optionTexts = ['没有', '很少', '有时', '经常', '总是']; |
|||
const randomText = optionTexts[Math.floor(Math.random() * optionTexts.length)]; |
|||
|
|||
// 查找包含选项文本的元素
|
|||
const allElements = document.querySelectorAll('*'); |
|||
for (const el of allElements) { |
|||
if (el.textContent?.trim() === randomText && el.children.length === 0) { |
|||
const rect = el.getBoundingClientRect(); |
|||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, text: randomText }; |
|||
} |
|||
} |
|||
|
|||
// 备用:点击第一个选项区域(查找选项卡片)
|
|||
const cards = document.querySelectorAll('[style*="border"][style*="padding"]'); |
|||
for (const card of cards) { |
|||
const rect = card.getBoundingClientRect(); |
|||
if (rect.width > 100 && rect.height > 30) { |
|||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, text: 'card' }; |
|||
} |
|||
} |
|||
return null; |
|||
}); |
|||
|
|||
if (optionClicked) { |
|||
await page.mouse.click(optionClicked.x, optionClicked.y); |
|||
await page.waitForTimeout(500); |
|||
} |
|||
|
|||
// 检查是否是最后一题
|
|||
if (current === total) { |
|||
// 最后一题,点击提交
|
|||
console.log(' 所有题目已回答,准备提交...'); |
|||
logTest('回答所有问题', true, `共 ${total} 题`); |
|||
return true; |
|||
} else { |
|||
// 点击下一题 - 使用坐标点击
|
|||
const nextBtnPos = await page.evaluate(() => { |
|||
const allElements = document.querySelectorAll('*'); |
|||
for (const el of allElements) { |
|||
if (el.textContent?.trim() === '下一题' && el.children.length === 0) { |
|||
const rect = el.getBoundingClientRect(); |
|||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; |
|||
} |
|||
} |
|||
return null; |
|||
}); |
|||
|
|||
if (nextBtnPos) { |
|||
await page.mouse.click(nextBtnPos.x, nextBtnPos.y); |
|||
await page.waitForTimeout(600); |
|||
} |
|||
} |
|||
} else { |
|||
console.log(' 无法解析题号,尝试继续...'); |
|||
await page.waitForTimeout(500); |
|||
} |
|||
} |
|||
|
|||
logTest('回答所有问题', false, '超过最大题目数量'); |
|||
return false; |
|||
} |
|||
|
|||
async function submitTest(page) { |
|||
console.log('\n【步骤5】提交测试...'); |
|||
|
|||
const submitBtn = page.getByRole('button', { name: '提交' }).first(); |
|||
if (await submitBtn.isVisible({ timeout: 3000 }).catch(() => false)) { |
|||
await submitBtn.click({ force: true }); |
|||
await page.waitForTimeout(3000); |
|||
|
|||
// 验证跳转到结果页面
|
|||
const resultPage = await page.locator('text=体质分析报告').first().isVisible({ timeout: 8000 }).catch(() => false); |
|||
logTest('提交并查看结果', resultPage); |
|||
|
|||
return resultPage; |
|||
} |
|||
|
|||
logTest('提交并查看结果', false, '未找到提交按钮'); |
|||
return false; |
|||
} |
|||
|
|||
async function verifyResult(page) { |
|||
console.log('\n【步骤6】验证结果页面内容...'); |
|||
|
|||
// 截图保存
|
|||
await page.screenshot({ path: 'tests/screenshots/constitution-result.png' }); |
|||
|
|||
// 验证关键元素
|
|||
const checks = [ |
|||
{ name: '体质分析报告标题', selector: 'text=体质分析报告' }, |
|||
{ name: '主体质名称', selector: 'text=/平和质|气虚质|阳虚质|阴虚质|痰湿质|湿热质|血瘀质|气郁质|特禀质/' }, |
|||
{ name: '体质得分卡片', selector: 'text=📊 体质得分' }, |
|||
{ name: '体质特征卡片', selector: 'text=📋 体质特征' }, |
|||
{ name: '调理建议卡片', selector: 'text=💡 调理建议' }, |
|||
{ name: '咨询AI助手按钮', selector: 'text=咨询AI助手' }, |
|||
{ name: '重新测评按钮', selector: 'text=重新测评' } |
|||
]; |
|||
|
|||
for (const check of checks) { |
|||
const visible = await page.locator(check.selector).first().isVisible({ timeout: 2000 }).catch(() => false); |
|||
logTest(check.name, visible); |
|||
} |
|||
|
|||
// 获取体质类型
|
|||
const typeText = await page.locator('text=/平和质|气虚质|阳虚质|阴虚质|痰湿质|湿热质|血瘀质|气郁质|特禀质/').first().textContent().catch(() => '未知'); |
|||
console.log(`\n 检测到体质类型: ${typeText}`); |
|||
|
|||
return true; |
|||
} |
|||
|
|||
async function testRetest(page) { |
|||
console.log('\n【步骤7】测试重新测评功能...'); |
|||
|
|||
const retestBtn = page.getByRole('button', { name: '重新测评' }).first(); |
|||
if (await retestBtn.isVisible({ timeout: 3000 }).catch(() => false)) { |
|||
await retestBtn.click({ force: true }); |
|||
await page.waitForTimeout(2000); |
|||
|
|||
// 验证返回测试页面
|
|||
const backToTest = await page.locator('text=体质测试').first().isVisible({ timeout: 3000 }).catch(() => false); |
|||
logTest('重新测评导航', backToTest); |
|||
|
|||
// 返回结果页面(为了完成测试)
|
|||
const backBtn = page.locator('text=← 返回').first(); |
|||
if (await backBtn.isVisible({ timeout: 2000 }).catch(() => false)) { |
|||
await backBtn.click({ force: true }); |
|||
await page.waitForTimeout(1000); |
|||
} |
|||
|
|||
return backToTest; |
|||
} |
|||
|
|||
logTest('重新测评导航', false, '未找到重新测评按钮'); |
|||
return false; |
|||
} |
|||
|
|||
async function runTests() { |
|||
console.log('═══════════════════════════════════════════════════════════'); |
|||
console.log(' 体质分析功能自动化测试'); |
|||
console.log('═══════════════════════════════════════════════════════════'); |
|||
|
|||
const browser = await chromium.launch({ headless: false }); |
|||
const context = await browser.newContext({ |
|||
viewport: { width: 1280, height: 800 } |
|||
}); |
|||
const page = await context.newPage(); |
|||
|
|||
// 监听控制台日志
|
|||
page.on('console', msg => { |
|||
if (msg.type() === 'error') { |
|||
console.log(' [Console Error]', msg.text()); |
|||
} |
|||
}); |
|||
|
|||
// 监听网络请求错误
|
|||
page.on('requestfailed', request => { |
|||
console.log(' [Request Failed]', request.url(), request.failure().errorText); |
|||
}); |
|||
|
|||
// 监听页面错误
|
|||
page.on('pageerror', error => { |
|||
console.log(' [Page Error]', error.message); |
|||
}); |
|||
|
|||
try { |
|||
console.log('\n打开应用...'); |
|||
await page.goto(APP_URL); |
|||
await page.waitForTimeout(2000); |
|||
|
|||
// 执行测试步骤
|
|||
const loginOk = await login(page); |
|||
if (!loginOk) throw new Error('登录失败'); |
|||
|
|||
const navOk = await navigateToConstitution(page); |
|||
if (!navOk) throw new Error('导航失败'); |
|||
|
|||
const startOk = await startTest(page); |
|||
if (!startOk) throw new Error('无法开始测试'); |
|||
|
|||
const answerOk = await answerQuestions(page); |
|||
if (!answerOk) throw new Error('回答问题失败'); |
|||
|
|||
const submitOk = await submitTest(page); |
|||
if (!submitOk) throw new Error('提交失败'); |
|||
|
|||
await verifyResult(page); |
|||
await testRetest(page); |
|||
|
|||
} catch (error) { |
|||
console.error('\n测试中断:', error.message); |
|||
await page.screenshot({ path: 'tests/screenshots/constitution-error.png' }); |
|||
} finally { |
|||
// 打印测试摘要
|
|||
console.log('\n═══════════════════════════════════════════════════════════'); |
|||
console.log(' 测试结果摘要'); |
|||
console.log('═══════════════════════════════════════════════════════════'); |
|||
console.log(`通过: ${testResults.passed} 失败: ${testResults.failed}`); |
|||
console.log('───────────────────────────────────────────────────────────'); |
|||
|
|||
for (const test of testResults.tests) { |
|||
const icon = test.passed ? '✓' : '✗'; |
|||
console.log(`${icon} ${test.name}${test.detail ? ' - ' + test.detail : ''}`); |
|||
} |
|||
|
|||
console.log('═══════════════════════════════════════════════════════════'); |
|||
|
|||
await page.waitForTimeout(3000); |
|||
await browser.close(); |
|||
|
|||
// 返回退出码
|
|||
process.exit(testResults.failed > 0 ? 1 : 0); |
|||
} |
|||
} |
|||
|
|||
// 运行测试
|
|||
runTests(); |
|||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 52 KiB |