From 1edcda9be52ec682d4cbef1bce4a16bbebc48175 Mon Sep 17 00:00:00 2001 From: haoliang <821644@qq.com> Date: Sun, 3 May 2026 01:13:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B5=8B=E8=AF=95=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E6=96=87=E6=A1=A3=E3=80=81=E5=86=92=E7=83=9F=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=84=9A=E6=9C=AC=E3=80=81AGENTS.md=E9=93=81=E5=BE=8B?= =?UTF-8?q?=E7=AC=AC5/6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 13 + docs/06-测试规范.md | 459 +++++++++++++++++++++++++++++++++ frontend/e2e/smoke-iis.spec.ts | 285 ++++++++++++++++++++ frontend/playwright.config.ts | 22 +- 4 files changed, 772 insertions(+), 7 deletions(-) create mode 100644 docs/06-测试规范.md create mode 100644 frontend/e2e/smoke-iis.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 10987af..c62d91c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,19 @@ - 改完即验证,不累积到"全部改完" - **自动化测试**:`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`(影响面分类 → 规范路由 → 输出修改计划摘要 → 等待确认) --- diff --git a/docs/06-测试规范.md b/docs/06-测试规范.md new file mode 100644 index 0000000..60346a0 --- /dev/null +++ b/docs/06-测试规范.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元素存在 + +#### 根因3:NULL占位符滥用 + +**为什么反复出现:** 开发时用 `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. 重复L2(API验证) +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`) | diff --git a/frontend/e2e/smoke-iis.spec.ts b/frontend/e2e/smoke-iis.spec.ts new file mode 100644 index 0000000..3c0e55e --- /dev/null +++ b/frontend/e2e/smoke-iis.spec.ts @@ -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) + }) +}) diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 8c9a944..facff29 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,5 +1,10 @@ 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({ testDir: './e2e', timeout: 60000, @@ -7,7 +12,7 @@ export default defineConfig({ fullyParallel: false, retries: 0, use: { - baseURL: 'http://localhost:5173', + baseURL: isDev ? 'http://localhost:5173' : 'http://127.0.0.1/admin', headless: true, screenshot: 'only-on-failure', trace: 'retain-on-failure', @@ -16,10 +21,13 @@ export default defineConfig({ projects: [ { name: 'chromium', use: { browserName: 'chromium' } }, ], - webServer: { - command: 'npm run dev', - port: 5173, - reuseExistingServer: true, - timeout: 30000, - }, + // IIS模式下不启动webServer,直接连接已部署的站点 + ...(isDev ? { + webServer: { + command: 'npm run dev', + port: 5173, + reuseExistingServer: true, + timeout: 30000, + } + } : {}), })