新增测试规范文档、冒烟测试脚本、AGENTS.md铁律第5/6条

main
haoliang 4 days ago
parent 3c40cbb4ab
commit 1edcda9be5

@ -54,6 +54,19 @@
- 改完即验证,不累积到"全部改完" - 改完即验证,不累积到"全部改完"
- **自动化测试**`cd frontend && npx playwright test --project=chromium`运行E2E验证套件 - **自动化测试**`cd frontend && npx playwright test --project=chromium`运行E2E验证套件
### 6. 子代理委托铁律(每次委托必须遵守)
> 子代理Sisyphus-Junior会虚报完成——声称"已写入文件"但实际文件为空或不存在。
- **委托写文件任务时必须在prompt中明确指定使用 Write 或 Edit 工具**,禁止子代理自行选择 apply_patch 等不可用工具
- **收集子代理结果后,必须用 Read 工具验证文件实际内容**,不能信任子代理的文字报告
- **子代理说自己 commit 了,必须用 `git log` 验证**,不能信任其口头声明
- **验证清单(每个委托写文件的任务完成后必查):**
1. 文件是否存在Read工具打开确认
2. 文件内容是否完整不是1行空文件
3. 文件内容是否正确(不是占位符或模板)
- **发现子代理虚报完成时必须立即自己重写不能再次委托给同一个session**
详见 `docs/05-修改请求预处理协议.md`(影响面分类 → 规范路由 → 输出修改计划摘要 → 等待确认) 详见 `docs/05-修改请求预处理协议.md`(影响面分类 → 规范路由 → 输出修改计划摘要 → 等待确认)
--- ---

@ -0,0 +1,459 @@
# 测试规范
**版本:** 2026-05-03
**适用范围:** CNC机床数据采集系统 — 前端管理后台 + 后端API
---
## 一、历史问题复盘与根因分析
### 1.1 问题清单(实际发生)
#### 前后端契约断裂5起
| # | 问题 | 现象 | 教训 |
|---|------|------|------|
| 1 | API返回 `{value, label}`,前端绑定 `{id, name}` | 车间/机床/工人下拉框全部无选项 | 前后端字段名必须对齐,不能各写各的 |
| 2 | DTO新增 `activeMachineCount` 等字段Service未填充值 | 汇总卡片(运行机床/切削总时/平均产量)显示空 | DTO加字段 ≠ 数据会自动填上 |
| 3 | 采集状态API返回 `IsRunning`/`LastCollectTime`,前端期望 `status`/`uptimeSeconds` | 采集服务状态不显示 | 前后端命名风格PascalCase vs camelCase必须统一 |
| 4 | 车间API返回 `{data:{items:[...]}}` 分页格式 | 前端取不到选项列表 | 返回数据结构(数组 vs 分页对象)前后端必须确认 |
| 5 | 修正弹窗HTML模板缺失 | 弹窗打不开 | 前端组件完整性检查 |
#### 测试验证流于形式2起
| # | 问题 | 现象 | 教训 |
|---|------|------|------|
| 6 | Playwright 37项测试全通过但所有功能都不能用 | 断言只检查"元素存在",不检查"数据内容" | 必须验证数据值不能只验证DOM结构 |
| 7 | 没有展开下拉框确认有选项 | 下拉框无数据但测试"通过" | 交互控件必须实际操作验证 |
#### 后端NULL占位符未填充2起
| # | 问题 | 现象 | 教训 |
|---|------|------|------|
| 8 | SQL直接写 `NULL AS TotalRunTime, NULL AS TotalCuttingTime` | 表格运行时间/切削时间全为空 | NULL不报错、不崩溃隐蔽性极强 |
| 9 | `DailySummaryResponse.MachineCount` 硬编码为0 | 运行机床数永远为0 | 禁止用占位值应付编译 |
#### 配置/格式硬编码3起
| # | 问题 | 现象 | 教训 |
|---|------|------|------|
| 10 | Element Plus日期格式用 `yyyy-MM-dd`(错误),应为 `YYYY-MM-DD`dayjs格式 | 日期选择器值无法解析 | 不同库的日期格式不同,必须查阅文档 |
| 11 | 采集服务serviceId为 `collector-service`,代码硬编码 `CncCollector` | 心跳匹配不上,采集状态显示未启动 | 关键配置项不能硬编码,必须与实际值一致 |
| 12 | 产量报表默认日期未设为今天 | 打开页面没有数据 | 页面初始化必须设置合理的默认值 |
#### 开发流程缺陷3起
| # | 问题 | 现象 | 教训 |
|---|------|------|------|
| 13 | 编译通过 = "测试通过" | 功能全部不可用 | 编译通过 ≠ 功能正确 |
| 14 | 改完代码不发布到IIS就宣称完成 | 用户看到的是旧版本 | 每次改动必须发布 |
| 15 | Playwright只访问页面不操作控件 | 控件功能全不可用但测试"通过" | 必须模拟真实用户操作 |
### 1.2 四大根因
#### 根因1前后端接口契约断裂
**为什么反复出现:** 没有统一的接口契约文档。后端改字段名前端不知道前端加绑定后端没实现。DTO只是C#类型定义,不等于运行时有数据。
**改进措施:**
- 每个API端点必须用PowerShell/脚本实际调用确认返回的JSON字段名和结构
- 前端绑定字段名时必须对照API实际返回值不能凭假设
- 新增DTO字段后必须追踪到Service层确认有填充逻辑
#### 根因2测试断言停留在DOM结构层面
**为什么反复出现:** Playwright的 `toBeVisible()` 只检查元素是否渲染不检查内容。元素渲染了但内容是空的、NULL的、占位符的测试照样通过。
**改进措施:**
- 断言必须检查具体文本内容(`toHaveText()`、`textContent()`
- 数值断言必须排除空字符串、`-`、`--`、`undefined`、`null`、`0`(除非业务确认)
- 下拉框必须展开确认有选项不能只检查select元素存在
#### 根因3NULL占位符滥用
**为什么反复出现:** 开发时用 `NULL AS 字段名` 占位编译不报错、API返回200、不崩溃。NULL是"合法的空值",不会被任何自动检查发现。
**改进措施:**
- 禁止SQL中写 `NULL AS 字段名` 作为占位符
- 如果字段暂时无法计算,应抛出 NotImplementedException 而非返回NULL
- Code Review时重点检查SELECT列表中的NULL
#### 根因4缺少强制验证门禁
**为什么反复出现:** 开发完成后没有"必须通过"的验证步骤。编译成功就认为完成了,没有实际操作验证。
**改进措施:**
- 建立4层验证流程编译→API→浏览器→发布每层必须通过才能进入下一层
- Playwright测试脚本覆盖所有交互控件和数据展示
- 每次改动后立即验证,不累积到"全部改完"
---
## 二、测试原则6条铁律
### 铁律1接口先验证
**为什么:** 后端改了API返回格式前端不知道就白改。先验证API再写前端避免返工。
**怎么做:**
- 改了任何API端点立即用PowerShell脚本调用
- 检查返回的JSON字段名、字段值、数据结构、非空字段
- 把实际返回值和前端绑定对比,逐字段确认一致
```powershell
# 登录获取token
$loginRes = Invoke-RestMethod -Uri "http://127.0.0.1/api/admin/login" -Method Post -ContentType "application/json" -Body '{"username":"admin","password":"admin123"}'
$token = $loginRes.data.token
$headers = @{ Authorization = "Bearer $token" }
# 调用目标API
$result = Invoke-RestMethod -Uri "http://127.0.0.1/api/admin/production/daily-summary?date=2026-05-03" -Headers $headers
$result.data | ConvertTo-Json # 逐字段检查
```
### 铁律2数据断言必须具体
**为什么:** `toBeVisible()` 只能证明元素渲染了,不能证明数据正确。空值、占位符、错误值都能通过。
**怎么做:**
- 禁止只检查元素存在
- 必须检查元素的文本内容
- 数值必须是非空、非占位符的具体数字
```typescript
// ❌ 错误:只检查元素存在
await expect(page.locator('.card-value')).toBeVisible()
// ✅ 正确:检查具体数值
const value = await page.locator('.card-value').textContent()
expect(value).toBeTruthy()
expect(value).not.toBe('')
expect(value).not.toBe('-')
expect(value).not.toBe('--')
```
### 铁律3下拉框必须展开验证
**为什么:** 下拉框渲染了不代表有选项。API返回空数组时select元素照样显示只是展开后没有选项。
**怎么做:**
1. 点击下拉框展开
2. 等待下拉选项出现
3. 断言选项数量 >= 1
4. 选择一个选项
5. 确认输入框显示选中值
6. 点查询,确认筛选生效
```typescript
// ❌ 错误只检查select元素存在
await expect(page.locator('.el-select')).toBeVisible()
// ✅ 正确:展开→确认有选项→选择→验证
await page.locator('.el-select').click() // 点击外层wrapper展开
const options = page.locator('.el-select-dropdown__item:visible')
await expect(options.first()).toBeVisible()
const count = await options.count()
expect(count).toBeGreaterThan(0)
await options.first().click()
await page.getByRole('button', { name: '查询' }).click()
```
### 铁律4表格必须检查数据列
**为什么:** 表格渲染了不代表有数据。后端返回空数组时表格只显示表头,但"表格可见"的断言照样通过。
**怎么做:**
- 检查表格有数据行(`.el-table__row` 数量 > 0
- 取第一行,逐列检查文本内容不为空
```typescript
const rows = page.locator('.el-table__body-wrapper .el-table__row')
const rowCount = await rows.count()
expect(rowCount).toBeGreaterThan(0)
// 检查第一行的关键列
const firstRow = rows.first()
const cells = firstRow.locator('td .cell')
const dateText = await cells.nth(0).textContent()
expect(dateText).toBeTruthy()
expect(dateText).not.toBe('')
```
### 铁律5卡片/统计必须检查数值
**为什么:** 卡片渲染了但数值是空、0、undefined、NaN用户看到的是空白或错误。
**怎么做:**
- 每个卡片取数值部分
- 断言不为空字符串、不为 `-`、不为 `--`、不为 `undefined`、不为 `null`
- 如果业务上允许为0需要明确注释说明
### 铁律6改完即验证不等批量
**为什么:** 改了10处再一起验证出问题时无法定位是哪一处改坏的。而且累积到后面容易漏验证。
**怎么做:**
- 每改一处 → 编译 → API验证 → 浏览器验证
- 全部通过后再改下一处
- 如果改动互相关联最多合并2-3处一起验证
---
## 三、验证层级4层
### L1 编译验证
**执行时机:** 每次代码改动后
**命令:**
```bash
dotnet build src\CncWebApi\CncWebApi.csproj # 0错误
cd frontend && npm run build # 0错误
```
**通过标准:** 两个命令都返回退出码0
**注意:** 编译通过 ≠ 功能正确,这只是最基本的大门
### L2 API验证
**执行时机:** 改了后端代码后
**方法:** 用PowerShell调用改动过的API端点
**检查项:**
- HTTP状态码200
- 返回JSON的 `code` 字段为0
- 返回 `data` 不为null
- 关键字段值不为空/NULL
- 字段名和前端绑定一致
**PowerShell模板**
```powershell
# 登录
$loginRes = Invoke-RestMethod -Uri "http://127.0.0.1/api/admin/login" -Method Post -ContentType "application/json" -Body '{"username":"admin","password":"admin123"}'
$token = $loginRes.data.token
$headers = @{ Authorization = "Bearer $token" }
$today = Get-Date -Format "yyyy-MM-dd"
# 示例:验证产量汇总
$sum = Invoke-RestMethod -Uri "http://127.0.0.1/api/admin/production/daily-summary?date=$today" -Headers $headers
echo "总产量: $($sum.data.totalQuantity)" # 应有值
echo "运行机床: $($sum.data.activeMachineCount)" # 应 > 0
echo "切削总时: $($sum.data.totalCuttingTime)" # 应有值
echo "平均产量: $($sum.data.avgQuantityPerMachine)" # 应有值
# 示例:验证下拉选项
$ws = Invoke-RestMethod -Uri "http://127.0.0.1/api/admin/workshop/list" -Headers $headers
echo "车间数量: $($ws.data.items.Count)" # 应 > 0
```
### L3 浏览器验证
**执行时机:** 改了前端代码后、或需要验证用户交互时
**方法:** Playwright打开页面实际操作每个交互控件
**检查项:** 见第五节"Playwright E2E测试断言标准"
**运行命令:**
```bash
cd frontend
npx playwright test e2e/smoke-iis.spec.ts --project=chromium
```
### L4 发布验证
**执行时机:** 所有改动完成后、正式交付前
**步骤:**
1. 回收AppPool`appcmd recycle apppool "haoliang"`
2. 重复L2API验证
3. 重复L3浏览器验证
4. 确认 `http://192.168.1.202/admin/` 局域网可访问
---
## 四、前后端接口契约验证
### 4.1 字段名对齐检查
每次新增或修改API返回字段时必须执行以下检查
| 检查项 | 方法 | 通过标准 |
|--------|------|----------|
| 后端DTO字段名 vs API实际返回JSON | PowerShell调用API查看返回值 | JSON key名与DTO属性名一致考虑camelCase序列化 |
| API返回字段名 vs 前端绑定 | 对比Vue模板中的 `prop`/`:key`/`:label`/`:value` | 完全一致或兼容(支持 `??` 回退) |
| 返回数据结构 | 查看JSON嵌套层级 | 前端取值路径正确(`res.data.items` vs `res.data` |
| 空值处理 | 构造空数据场景 | 前端显示合理的占位符(`-` 或 `暂无数据`),不报错 |
### 4.2 常见陷阱
- **Newtonsoft.Json camelCase序列化**C#的 `TotalQuantity` → JSON的 `totalQuantity`,前端绑定要对应
- **分页格式**部分API返回数组 `[{...}]`,部分返回分页对象 `{items:[], total:0}`,前端必须区分
- **枚举值**:后端枚举可能序列化为整数或字符串,前端必须处理两种情况
- **DateTime序列化**:可能为 `"2026-05-03T00:00:00"``"2026-05-03"`,前端日期解析要兼容
---
## 五、Playwright E2E测试断言标准
### 5.1 交互控件测试矩阵
每个页面的每个交互控件,都必须按此矩阵验证:
| 控件类型 | 验证动作 | 通过标准 | 禁止做法 |
|----------|----------|----------|----------|
| **下拉框** | 1.点击展开 2.等待选项渲染 | 选项数量 ≥ 1 | 只检查select元素可见 |
| **下拉框** | 选择某项 | 输入框文本变为选中项的label | 不验证选择后的显示值 |
| **下拉框** | 选择后点查询 | 表格数据变化(筛选生效) | 不验证筛选是否实际生效 |
| **日期选择器** | 打开选择日期 | 输入框显示 `YYYY-MM-DD` 格式 | 不验证日期格式 |
| **查询按钮** | 点击 | 表格刷新loading出现或数据变化 | 只检查按钮可见 |
| **重置按钮** | 点击 | 所有筛选恢复默认值 | 不验证筛选是否清空 |
| **分页** | 切换页码 | 表格数据变化,页码高亮正确 | 只检查分页组件可见 |
| **弹窗-打开** | 点击触发按钮 | 弹窗可见,标题正确 | 不验证弹窗标题 |
| **弹窗-表单** | 查看表单字段 | 各字段有合理初始值 | 不检查表单初始值 |
| **弹窗-提交** | 填写并提交 | 返回成功提示,列表刷新 | 不验证提交后的状态 |
| **导出** | 点击导出 | 触发下载或显示提示 | 不验证导出行为 |
### 5.2 数据展示验证矩阵
| 展示元素 | 验证标准 | 非法值(不通过) |
|----------|----------|------------------|
| **汇总卡片** | 每个数值:非空 ∧ ≠ `""` ∧ ≠ `"-"` ∧ ≠ `"--"` ∧ ≠ `"undefined"` ∧ ≠ `"null"` | 空字符串、`-`、`--`、`undefined`、`null` |
| **表格数据列** | 每列至少第一行有实际文本值 | 空单元格、`NULL` |
| **状态标签** | 显示正确的中文文案(正常/离线/缺失/告警等) | 空标签、英文文本 |
| **时间列** | 有值且格式正确(`YYYY-MM-DD` 或 `HH:mm:ss` | 空值、格式错误 |
| **空状态** | 无数据时显示"暂无数据"或类似提示 | 空白页面、报错 |
### 5.3 断言红黑榜
#### ✅ 正确的断言(数据层面)
```typescript
// 检查文本不为空且非占位符
const text = await locator.textContent()
expect(text).toBeTruthy()
expect(text!.trim()).not.toBe('')
expect(text!.trim()).not.toBe('-')
expect(text!.trim()).not.toBe('--')
// 检查下拉框有选项
const options = page.locator('.el-select-dropdown__item')
await expect(options.first()).toBeVisible()
const count = await options.count()
expect(count).toBeGreaterThan(0)
// 检查表格有数据行
const rows = page.locator('.el-table__body-wrapper .el-table__row')
expect(await rows.count()).toBeGreaterThan(0)
// 检查卡片数值
const cardValue = await page.locator('.card-value').textContent()
expect(parseFloat(cardValue!)).not.toBeNaN()
```
#### ❌ 错误的断言(结构层面,太宽松)
```typescript
// 这些断言无法发现数据为空的问题
await expect(page.locator('.card')).toBeVisible() // 卡片可见但值可能为空
await expect(page.locator('table')).toBeVisible() // 表格可见但可能没数据
await expect(page.locator('select')).toBeVisible() // 下拉框可见但可能没选项
await expect(page.locator('.el-dialog')).toBeVisible() // 弹窗可见但内容可能错误
```
---
## 六、API验证方法
### 6.1 PowerShell验证脚本模板
```powershell
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# 登录获取Token
$loginRes = Invoke-RestMethod -Uri "http://127.0.0.1/api/admin/login" -Method Post -ContentType "application/json" -Body '{"username":"admin","password":"admin123"}'
$token = $loginRes.data.token
$headers = @{ Authorization = "Bearer $token" }
$today = Get-Date -Format "yyyy-MM-dd"
# === 产量模块 ===
echo "--- 产量汇总 ---"
$sum = Invoke-RestMethod -Uri "http://127.0.0.1/api/admin/production/daily-summary?date=$today" -Headers $headers
echo "总产量: $($sum.data.totalQuantity)(应有值)"
echo "运行机床: $($sum.data.activeMachineCount)(应>0"
echo "切削总时: $($sum.data.totalCuttingTime)(应有值)"
echo "平均产量: $($sum.data.avgQuantityPerMachine)(应有值)"
echo "--- 产量列表前3条 ---"
$prod = Invoke-RestMethod -Uri "http://127.0.0.1/api/admin/production/daily?startDate=$today&endDate=$today&page=1&pageSize=3" -Headers $headers
echo "总数: $($prod.data.total)"
foreach ($item in $prod.data.items) {
echo " 机床=$($item.machineName) 产量=$($item.totalQuantity) 运行=$($item.totalRunTime)h 切削=$($item.totalCuttingTime)h"
}
# === 下拉选项 ===
echo "--- 车间选项 ---"
$ws = Invoke-RestMethod -Uri "http://127.0.0.1/api/admin/workshop/list" -Headers $headers
echo "数量: $($ws.data.items.Count)(应>0"
echo "--- 机床选项 ---"
$mc = Invoke-RestMethod -Uri "http://127.0.0.1/api/admin/machine/list" -Headers $headers
echo "数量: $($mc.data.items.Count)(应>0"
echo "--- 工人选项 ---"
$wr = Invoke-RestMethod -Uri "http://127.0.0.1/api/admin/worker/list" -Headers $headers
echo "数量: $($wr.data.items.Count)(应>0"
```
### 6.2 非空字段验证清单
每个API端点返回后以下字段**必须非空**
| API端点 | 必须非空字段 |
|----------|-------------|
| `/admin/production/daily-summary` | `totalQuantity`, `activeMachineCount`, `totalCuttingTime`, `avgQuantityPerMachine` |
| `/admin/production/daily` | `items[].machineName`, `items[].totalQuantity`, `items[].date` |
| `/admin/workshop/list` | `items[].value`, `items[].label` |
| `/admin/machine/list` | `items[].value`, `items[].label` |
| `/admin/worker/list` | `items[].value`, `items[].label` |
| `/admin/dashboard/statistics` | 各统计卡片字段 |
| `/admin/collector/status` | `status` |
---
## 七、发布前Checklist强制全部打勾
每次交付前必须完成以下所有检查项:
### 编译
- [ ] `dotnet build src\CncWebApi\CncWebApi.csproj` → 0错误
- [ ] `cd frontend && npm run build` → 0错误
### 发布
- [ ] 前端build输出已复制到 `src\CncWebApi\admin\`
- [ ] 回收AppPool`appcmd recycle apppool "haoliang"`
### API验证
- [ ] 改动过的API端点用PowerShell调用返回值正确
- [ ] 新增/修改的字段有实际值非NULL、非空
### 浏览器验证
- [ ] Playwright冒烟测试通过`cd frontend && npx playwright test e2e/smoke-iis.spec.ts --project=chromium`
- [ ] 下拉框展开有选项、选择后筛选生效
- [ ] 表格数据列有值
- [ ] 汇总卡片数值正确
- [ ] 弹窗能打开且有内容
### Git
- [ ] `git add` + `git commit -m "中文描述"` + `git push`
---
## 八、反模式清单(禁止做法)
| # | 反模式 | 正确做法 |
|---|--------|----------|
| 1 | 编译通过 = 测试通过 | 编译通过只说明语法正确,必须验证功能 |
| 2 | 只检查 `toBeVisible()` 不检查内容 | 必须检查文本值,排除空/占位符 |
| 3 | SQL写 `NULL AS 字段名` 占位 | 禁止占位,要么实现要么抛异常 |
| 4 | DTO加字段不填充值 | 加字段必须追踪到Service层实现填充 |
| 5 | 改完代码不发布到IIS | 每次改动必须发布后验证 |
| 6 | Playwright不操作控件只看页面 | 必须模拟用户操作:点击、选择、输入 |
| 7 | 累积改动到"全部完成"再验证 | 改完一处立即验证 |
| 8 | 前端凭假设绑定字段名 | 必须对照API实际返回值 |
| 9 | 硬编码配置值serviceId、端口等 | 从配置文件读取或与实际部署保持一致 |
| 10 | 不检查Element Plus组件库的日期/格式要求 | 查阅文档确认格式(如 dayjs 的 `YYYY-MM-DD` |

@ -0,0 +1,285 @@
/**
* IIS
*
*
* cd frontend
* npx playwright test e2e/smoke-iis.spec.ts --project=chromium
*
*
*/
import { test, expect, type Page } from '@playwright/test'
// === 登录辅助函数 ===
async function login(page: Page) {
// 直接用完整URL不依赖baseURL
await page.goto('http://127.0.0.1/admin/login')
await page.waitForLoadState('networkidle')
// 等待登录表单渲染
await page.waitForSelector('input', { timeout: 10000 })
// 用CSS选择器定位第一个input是用户名第二个是密码
const inputs = page.locator('input')
await inputs.nth(0).fill('admin')
await inputs.nth(1).fill('admin123')
await page.locator('button').last().click()
await page.waitForURL(/\/(dashboard|admin\/?$)/, { timeout: 15000 })
}
// === 辅助:断言文本不为空且非占位符 ===
function assertValidValue(text: string | null, label: string) {
expect(text, `${label} 不应为null`).toBeTruthy()
const trimmed = text!.trim()
expect(trimmed, `${label} 不应为空字符串`).not.toBe('')
expect(trimmed, `${label} 不应为占位符"-"`).not.toBe('-')
expect(trimmed, `${label} 不应为占位符"--"`).not.toBe('--')
expect(trimmed, `${label} 不应为"undefined"`).not.toBe('undefined')
expect(trimmed, `${label} 不应为"null"`).not.toBe('null')
}
// ========================
// 登录测试
// ========================
test.describe('登录', () => {
test('登录成功跳转到仪表盘', async ({ page }) => {
await login(page)
// 断言URL包含dashboard
expect(page.url()).toMatch(/dashboard/)
// 断言:侧边栏有"仪表盘"菜单项
await expect(page.getByRole('menuitem', { name: '仪表盘' })).toBeVisible()
})
})
// ========================
// 仪表盘
// ========================
test.describe('仪表盘', () => {
test.beforeEach(async ({ page }) => {
await login(page)
// 确保在仪表盘页面
if (!page.url().includes('dashboard')) {
await page.goto('http://127.0.0.1/admin/dashboard')
}
// 等待数据加载
await page.waitForTimeout(2000)
})
test('统计卡片有数值', async ({ page }) => {
// 找到所有卡片区域,检查数值部分不为空
const cards = page.locator('.el-card')
const cardCount = await cards.count()
expect(cardCount, '仪表盘应该有统计卡片').toBeGreaterThan(0)
// 检查每个卡片内的数值区域
for (let i = 0; i < cardCount; i++) {
const card = cards.nth(i)
// 卡片内的数值通常在较大的字体div中
const valueEl = card.locator('div').filter({ hasText: /^\d/ }).first()
if (await valueEl.isVisible()) {
const text = await valueEl.textContent()
// 数值应该包含数字
expect(text, `${i + 1}个卡片应该有数值`).toMatch(/\d/)
}
}
})
test('采集服务状态显示正常', async ({ page }) => {
// 检查采集服务状态区域
const statusArea = page.locator('text=采集服务')
if (await statusArea.isVisible()) {
// 状态文本不应为空
const statusText = await page.locator('.el-tag').first().textContent()
assertValidValue(statusText, '采集服务状态')
}
})
})
// ========================
// 产量报表
// ========================
test.describe('产量报表', () => {
test.beforeEach(async ({ page }) => {
await login(page)
await page.goto('http://127.0.0.1/admin/production')
// 等待页面数据加载
await page.waitForTimeout(2000)
})
test('默认加载今天数据', async ({ page }) => {
// 检查日期选择器显示今天的日期
const today = new Date()
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
const dateInput = page.locator('input[placeholder]').first()
const inputValue = await dateInput.inputValue()
expect(inputValue, `日期选择器应显示今天 ${dateStr}`).toContain(dateStr.slice(0, 7)) // 至少年月匹配
// 检查表格有数据
const rows = page.locator('.el-table__body-wrapper .el-table__row')
const rowCount = await rows.count()
expect(rowCount, '产量报表应默认有数据').toBeGreaterThan(0)
})
test('汇总卡片有数值', async ({ page }) => {
// 检查4个卡片总产量、运行机床、切削总时、平均产量
const cardLabels = ['总产量', '运行机床', '切削总时', '平均产量']
for (const label of cardLabels) {
// 找到标签文本所在的卡片
const labelEl = page.locator(`text=${label}`).first()
await expect(labelEl, `"${label}"标签应可见`).toBeVisible()
// 同一个卡片内的数值紧跟标签的大字div
const card = labelEl.locator('..').locator('..')
const valueDiv = card.locator('div').last()
const valueText = await valueDiv.textContent()
assertValidValue(valueText, `"${label}"的数值`)
}
})
test('表格数据列有值', async ({ page }) => {
const rows = page.locator('.el-table__body-wrapper .el-table__row')
const rowCount = await rows.count()
expect(rowCount, '表格应有数据行').toBeGreaterThan(0)
// 检查第一行的关键列
const firstRow = rows.first()
const cells = firstRow.locator('td .cell')
// 日期列第1列
const dateText = await cells.nth(0).textContent()
assertValidValue(dateText, '表格第1行日期列')
// 机床名列第2列
const machineText = await cells.nth(1).textContent()
assertValidValue(machineText, '表格第1行机床名列')
// 产量列第4列
const qtyText = await cells.nth(3).textContent()
assertValidValue(qtyText, '表格第1行产量列')
})
test('车间下拉框有选项且筛选生效', async ({ page }) => {
// 1. 记录当前表格行数
const rowsBefore = page.locator('.el-table__body-wrapper .el-table__row')
const countBefore = await rowsBefore.count()
// 2. 点击车间下拉框展开点击外层wrapper
const workshopSelect = page.locator('.el-form-item').filter({ hasText: '车间' }).locator('.el-select')
await workshopSelect.click()
await page.waitForTimeout(500)
// 3. 断言:下拉列表中有选项
const options = page.locator('.el-select-dropdown__item:visible')
await expect(options.first(), '车间下拉框应有选项').toBeVisible()
const optionCount = await options.count()
expect(optionCount, '车间下拉框选项数量应>=1').toBeGreaterThanOrEqual(1)
// 4. 选择第一个选项
await options.first().click()
await page.waitForTimeout(300)
// 5. 点击查询按钮
await page.getByRole('button', { name: '查询' }).click()
await page.waitForTimeout(2000)
// 6. 断言:表格有数据(筛选后仍应有数据)
const rowsAfter = page.locator('.el-table__body-wrapper .el-table__row')
const countAfter = await rowsAfter.count()
expect(countAfter, '筛选后表格应有数据').toBeGreaterThan(0)
})
test('机床下拉框有选项', async ({ page }) => {
const machineSelect = page.locator('.el-form-item').filter({ hasText: '机床' }).locator('.el-select')
await machineSelect.click()
await page.waitForTimeout(500)
const options = page.locator('.el-select-dropdown__item:visible')
await expect(options.first(), '机床下拉框应有选项').toBeVisible()
const count = await options.count()
expect(count, '机床下拉框选项数量应>=1').toBeGreaterThanOrEqual(1)
// 关闭下拉框
await page.keyboard.press('Escape')
})
test('工人下拉框有选项', async ({ page }) => {
const workerSelect = page.locator('.el-form-item').filter({ hasText: '工人' }).locator('.el-select')
await workerSelect.click()
await page.waitForTimeout(500)
const options = page.locator('.el-select-dropdown__item:visible')
await expect(options.first(), '工人下拉框应有选项').toBeVisible()
const count = await options.count()
expect(count, '工人下拉框选项数量应>=1').toBeGreaterThanOrEqual(1)
// 关闭下拉框
await page.keyboard.press('Escape')
})
test('重置按钮清空筛选', async ({ page }) => {
// 先选择一个车间
const workshopSelect = page.locator('.el-form-item').filter({ hasText: '车间' }).locator('.el-select')
await workshopSelect.click()
await page.waitForTimeout(500)
await page.locator('.el-select-dropdown__item:visible').first().click()
await page.waitForTimeout(300)
// 点重置
await page.getByRole('button', { name: '重置' }).click()
await page.waitForTimeout(1000)
// 断言:车间下拉框恢复占位符
const placeholder = workshopSelect.locator('.el-select__placeholder')
const placeholderText = await placeholder.textContent()
expect(placeholderText, '重置后车间下拉框应显示占位符').toContain('请选择')
})
test('修正弹窗能打开', async ({ page }) => {
// 点击第一行的"修正"按钮
const adjustBtn = page.locator('.el-table__body-wrapper .el-table__row').first().getByRole('button', { name: '修正' })
await adjustBtn.click()
await page.waitForTimeout(500)
// 断言:弹窗出现,标题包含"修正"
const dialog = page.locator('.el-dialog:visible')
await expect(dialog, '修正弹窗应可见').toBeVisible()
const title = await dialog.locator('.el-dialog__title').textContent()
expect(title, '弹窗标题应包含"修正"').toContain('修正')
// 断言:当前产量输入框有值
const input = dialog.locator('input').first()
const inputValue = await input.inputValue()
expect(inputValue, '当前产量输入框应有初始值').toBeTruthy()
// 关闭弹窗
await dialog.locator('.el-dialog__headerbtn').click()
})
test('修正历史弹窗能打开', async ({ page }) => {
const historyBtn = page.locator('.el-table__body-wrapper .el-table__row').first().getByRole('button', { name: '修正历史' })
await historyBtn.click()
await page.waitForTimeout(500)
// 断言:弹窗出现
const dialog = page.locator('.el-dialog:visible')
await expect(dialog, '修正历史弹窗应可见').toBeVisible()
const title = await dialog.locator('.el-dialog__title').textContent()
expect(title, '弹窗标题应包含"修正历史"').toContain('修正历史')
// 关闭弹窗
await dialog.locator('.el-dialog__headerbtn').click()
})
})
// ========================
// 设备管理
// ========================
test.describe('设备管理', () => {
test.beforeEach(async ({ page }) => {
await login(page)
await page.goto('http://127.0.0.1/admin/machine')
await page.waitForTimeout(2000)
})
test('机床列表有数据', async ({ page }) => {
const rows = page.locator('.el-table__body-wrapper .el-table__row')
const rowCount = await rows.count()
expect(rowCount, '设备管理表格应有数据行').toBeGreaterThan(0)
})
})

@ -1,5 +1,10 @@
import { defineConfig } from '@playwright/test' import { defineConfig } from '@playwright/test'
// 通过环境变量 DEV_MODE 切换模式:
// - 默认IIS模式直接连接已部署的 http://127.0.0.1/admin不启动本地dev server
// - DEV_MODE=1启动 npm run dev连接 http://localhost:5173
const isDev = !!process.env.DEV_MODE
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
timeout: 60000, timeout: 60000,
@ -7,7 +12,7 @@ export default defineConfig({
fullyParallel: false, fullyParallel: false,
retries: 0, retries: 0,
use: { use: {
baseURL: 'http://localhost:5173', baseURL: isDev ? 'http://localhost:5173' : 'http://127.0.0.1/admin',
headless: true, headless: true,
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
trace: 'retain-on-failure', trace: 'retain-on-failure',
@ -16,10 +21,13 @@ export default defineConfig({
projects: [ projects: [
{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'chromium', use: { browserName: 'chromium' } },
], ],
// IIS模式下不启动webServer直接连接已部署的站点
...(isDev ? {
webServer: { webServer: {
command: 'npm run dev', command: 'npm run dev',
port: 5173, port: 5173,
reuseExistingServer: true, reuseExistingServer: true,
timeout: 30000, timeout: 30000,
}, }
} : {}),
}) })

Loading…
Cancel
Save