Commit 45178f79 authored by liguangyu06's avatar liguangyu06
Browse files

完善用户管理/医共体自动化用例与文档,并同步角色、准入及 CI 套件配置。



用户管理补充 20 条/页、列设置、授权弹框(账号一致、添加/移除)等用例与 PO;医共体对齐组合查询、分页、列设置与授权场景;CSV/MD/测试点与 generate-user-md 一并更新。

Co-authored-by: default avatarCursor <cursoragent@cursor.com>
parent 2eef70bd
...@@ -17,6 +17,12 @@ on: ...@@ -17,6 +17,12 @@ on:
- full - full
- warn - warn
- center - center
- vendor
- admit
- basic
- surgery
- warehouse
- flow
jobs: jobs:
test: test:
......
...@@ -112,7 +112,7 @@ pipeline { ...@@ -112,7 +112,7 @@ pipeline {
parameters { parameters {
choice( choice(
name: 'SUITE', name: 'SUITE',
choices: ['full', 'nightly', 'smoke', 'login', 'center', 'dept', 'invoice', 'trace', 'consume', 'recon', 'cost', 'warn', 'pol', 'exc', 'ana', 'ops', 'reag', 'invoice-trace', 'recon-cost', 'extended', 'quarantine'], choices: ['full', 'nightly', 'smoke', 'login', 'center', 'dept', 'invoice', 'trace', 'consume', 'recon', 'cost', 'warn', 'pol', 'exc', 'ana', 'ops', 'reag', 'invoice-trace', 'recon-cost', 'vendor', 'admit', 'basic', 'surgery', 'warehouse', 'flow', 'extended', 'quarantine'],
description: '执行方式(默认 full 全量回归,含 @full 全列排序)' description: '执行方式(默认 full 全量回归,含 @full 全列排序)'
) )
string(name: 'WORKERS', defaultValue: '3', description: '单进程内并行文件数(同一 spec 不拆开)') string(name: 'WORKERS', defaultValue: '3', description: '单进程内并行文件数(同一 spec 不拆开)')
......
...@@ -35,10 +35,10 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p ...@@ -35,10 +35,10 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p
| 套件 | 命令 | 用途 | | 套件 | 命令 | 用途 |
|------|------|------| |------|------|------|
| `smoke` | `npm run test:ci:smoke` | 全部报表模块中已打 `@smoke` 的用例(每页重置 / 列表或查询 / 列设置 / 导出) | | `smoke` | `npm run test:ci:smoke` | 报表与基础设置中已打 `@smoke` 的用例(含厂商、准入、基础管理、手术、库房、流程) |
| `nightly` | `npm run test:ci:nightly` | 主 spec 回归,排除 `@full` 与调试 `*.stat-*.spec.ts` | | `nightly` | `npm run test:ci:nightly` | 主 spec 回归,排除 `@full` 与调试 `*.stat-*.spec.ts` |
| `full` | `npm run test:ci:full` | 含全列排序,建议周末 | | `full` | `npm run test:ci:full` | 含全列排序,建议周末 |
| 模块 | `SUITE=center npm run test:ci` | `center` / `dept` / `warn` / `recon` 等 | | 模块 | `SUITE=center npm run test:ci` | `center` / `dept` / `vendor` / `admit` / `basic` / `surgery` / `warehouse` / `flow` 等 |
| 夜间分片 | `npm run test:spd:shard` | 先登录一次,再 2 进程分片并合并报告 | | 夜间分片 | `npm run test:spd:shard` | 先登录一次,再 2 进程分片并合并报告 |
并行默认按 **文件**(`PW_WORKERS`)。不要开 `PW_FULLY_PARALLEL=1`:本仓库大量同页 serial,拆开后同一账号会互踩。总浏览器约 `SHARDS × WORKERS`,单机建议不超过 6。详见 [docs/jenkins.md](docs/jenkins.md)。 并行默认按 **文件**(`PW_WORKERS`)。不要开 `PW_FULLY_PARALLEL=1`:本仓库大量同页 serial,拆开后同一账号会互踩。总浏览器约 `SHARDS × WORKERS`,单机建议不超过 6。详见 [docs/jenkins.md](docs/jenkins.md)。
......
...@@ -94,6 +94,45 @@ export const PAGE_GROUPS = { ...@@ -94,6 +94,45 @@ export const PAGE_GROUPS = {
ops: ['tests/spd/ops-check.spec.ts'], ops: ['tests/spd/ops-check.spec.ts'],
reag: ['tests/spd/reag-entry.spec.ts'], reag: ['tests/spd/reag-entry.spec.ts'],
auth: ['tests/spd/login.spec.ts', 'tests/spd/home.spec.ts'], auth: ['tests/spd/login.spec.ts', 'tests/spd/home.spec.ts'],
vendor: [
'tests/spd/vendor-query.spec.ts',
'tests/spd/vendor-qual.spec.ts',
'tests/spd/vendor-audit.spec.ts',
'tests/spd/vendor-cert-diff.spec.ts',
'tests/spd/vendor-eval-scheme.spec.ts',
'tests/spd/vendor-eval-indicator.spec.ts',
'tests/spd/vendor-holiday.spec.ts',
],
admit: [
'tests/spd/admit-material.spec.ts',
'tests/spd/admit-material-list.spec.ts',
'tests/spd/admit-product-apply.spec.ts',
'tests/spd/admit-product-list.spec.ts',
'tests/spd/admit-supply.spec.ts',
'tests/spd/admit-task.spec.ts',
],
basic: [
'tests/spd/basic-user.spec.ts',
'tests/spd/basic-user-union.spec.ts',
'tests/spd/basic-role.spec.ts',
'tests/spd/basic-branch.spec.ts',
'tests/spd/basic-notice.spec.ts',
'tests/spd/basic-hospital-info.spec.ts',
'tests/spd/basic-finance-month.spec.ts',
],
surgery: [
'tests/spd/surgery-base-pkg.spec.ts',
'tests/spd/surgery-oper-type.spec.ts',
'tests/spd/surgery-std-pkg.spec.ts',
],
warehouse: [
'tests/spd/warehouse-zone.spec.ts',
'tests/spd/warehouse-probe.spec.ts',
],
flow: [
'tests/spd/flow-def.spec.ts',
'tests/spd/flow-inst.spec.ts',
],
}; };
/** @typedef {{ description: string, files: string[], grep?: string, runFull?: boolean, runQuarantine?: boolean, workers?: number, retries?: number }} SuiteDef */ /** @typedef {{ description: string, files: string[], grep?: string, runFull?: boolean, runQuarantine?: boolean, workers?: number, retries?: number }} SuiteDef */
...@@ -109,7 +148,7 @@ export const SUITES = { ...@@ -109,7 +148,7 @@ export const SUITES = {
retries: 0, retries: 0,
}, },
smoke: { smoke: {
description: '冒烟:全部报表模块中已打 @smoke 的用例(登录/首页、成本、开票、科室、中心库、消耗、追溯、对账、预警、政策、异常、统计分析、运维、试剂)', description: '冒烟:报表与基础设置中已打 @smoke 的用例(登录/首页、报表、厂商、准入、基础管理、手术、库房、流程)',
files: [ files: [
...PAGE_GROUPS.auth, ...PAGE_GROUPS.auth,
...PAGE_GROUPS.cost, ...PAGE_GROUPS.cost,
...@@ -125,6 +164,12 @@ export const SUITES = { ...@@ -125,6 +164,12 @@ export const SUITES = {
...PAGE_GROUPS.ana, ...PAGE_GROUPS.ana,
...PAGE_GROUPS.ops, ...PAGE_GROUPS.ops,
...PAGE_GROUPS.reag, ...PAGE_GROUPS.reag,
...PAGE_GROUPS.vendor,
...PAGE_GROUPS.admit,
...PAGE_GROUPS.basic,
...PAGE_GROUPS.surgery,
...PAGE_GROUPS.warehouse,
...PAGE_GROUPS.flow,
], ],
grep: '@smoke', grep: '@smoke',
workers: 3, workers: 3,
...@@ -215,6 +260,42 @@ export const SUITES = { ...@@ -215,6 +260,42 @@ export const SUITES = {
workers: 2, workers: 2,
retries: 0, retries: 0,
}, },
vendor: {
description: '厂商管理',
files: PAGE_GROUPS.vendor,
workers: 1,
retries: 0,
},
admit: {
description: '准入管理',
files: PAGE_GROUPS.admit,
workers: 1,
retries: 0,
},
basic: {
description: '基础管理(用户/角色/院区等)',
files: PAGE_GROUPS.basic,
workers: 1,
retries: 0,
},
surgery: {
description: '手术包管理',
files: PAGE_GROUPS.surgery,
workers: 1,
retries: 0,
},
warehouse: {
description: '库区与探针',
files: PAGE_GROUPS.warehouse,
workers: 1,
retries: 0,
},
flow: {
description: '流程定义与实例',
files: PAGE_GROUPS.flow,
workers: 1,
retries: 0,
},
extended: { extended: {
description: '扩展报表:预警/运维/异常/政策/财务分析/试剂', description: '扩展报表:预警/运维/异常/政策/财务分析/试剂',
files: [ files: [
......
...@@ -12,6 +12,8 @@ export const userData = { ...@@ -12,6 +12,8 @@ export const userData = {
positiveUser: 'test_1', positiveUser: 'test_1',
mockUser: '__NOT_EXIST_USER__99999', mockUser: '__NOT_EXIST_USER__99999',
mockRole: '__NOT_EXIST_ROLE__99999', mockRole: '__NOT_EXIST_ROLE__99999',
/** 列设置 toggle 用非核心列(须当前表头可见)。 */
columnSettingsToggleCandidates: ['联系方式', '医院员工编号', '科室任职数'],
} as const; } as const;
export function uniqLogin(): string { export function uniqLogin(): string {
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
| 任务 | 套件 | 建议 cron | 说明 | | 任务 | 套件 | 建议 cron | 说明 |
|------|------|-----------|------| |------|------|-----------|------|
| `spd-ui-smoke` | `smoke` | `H 8-18 * * 1-5` | 工作时段冒烟:全部报表模块已打 `@smoke` 的用例 | | `spd-ui-smoke` | `smoke` | `H 8-18 * * 1-5` | 工作时段冒烟:报表与基础设置中已打 `@smoke` 的用例(含厂商、准入、基础管理、手术、库房、流程) |
| `spd-ui-nightly` / `SPD` | `nightly` / `full` | `H 23 * * 1,3,5` | 周一、三、五 **23 点** 回归(Jenkins 任务 `SPD` 当前按此时段跑 full) | | `spd-ui-nightly` / `SPD` | `nightly` / `full` | `H 23 * * 1,3,5` | 周一、三、五 **23 点** 回归(Jenkins 任务 `SPD` 当前按此时段跑 full) |
| `spd-ui-full` | `full` | `H 3 * * 0` | 周末含全列排序,最慢 | | `spd-ui-full` | `full` | `H 3 * * 0` | 周末含全列排序,最慢 |
......
...@@ -417,7 +417,7 @@ python3 scripts/sync-docs-ui-specs.py --only SURG-001,SURG-002,SURG-003 ...@@ -417,7 +417,7 @@ python3 scripts/sync-docs-ui-specs.py --only SURG-001,SURG-002,SURG-003
|------|----------|----------|------| |------|----------|----------|------|
| 用户管理 | USER-001 | `/spd/basic/user/platform/person` | 已扫描 · `basic-user` · **219** 项 · 测试点 **81** 条 · `TP_USER_USR` | | 用户管理 | USER-001 | `/spd/basic/user/platform/person` | 已扫描 · `basic-user` · **219** 项 · 测试点 **81** 条 · `TP_USER_USR` |
| 医共体用户管理 | USER-002 | `/spd/basic/user/platform/personCom` | 已扫描 · `basic-user-union` · **221** 项 · 测试点 **58** 条 · `TP_USER_UNI` | | 医共体用户管理 | USER-002 | `/spd/basic/user/platform/personCom` | 已扫描 · `basic-user-union` · **221** 项 · 测试点 **58** 条 · `TP_USER_UNI` |
| 角色管理 | USER-003 | `/spd/basic/user/platform/role` | 已扫描 · `basic-role` · **1170** 项(含权限树) · 测试点 **45** 条 · `TP_USER_ROL` | | 角色管理 | USER-003 | `/spd/basic/user/platform/role` | 已扫描 · `basic-role` · **1170** 项 · 测试点 **45** 条 · UI 自动化 **12** 条 · `TP_USER_ROL` / `USER-ROL` |
菜单路径:**基础设置** → **用户管理** → {页面名}(一级菜单中另有同名「用户管理」节点,自动化建议直接 `openPath` 并按 URL 断言)。 菜单路径:**基础设置** → **用户管理** → {页面名}(一级菜单中另有同名「用户管理」节点,自动化建议直接 `openPath` 并按 URL 断言)。
......
...@@ -85,10 +85,11 @@ author: "" ...@@ -85,10 +85,11 @@ author: ""
## 测试关注点 ## 测试关注点
1. 姓名/科室/角色组合筛选后列表条数与「共 N 条」一致;重置后恢复默认。 1. 姓名/科室/角色组合筛选后列表条数与「共 N 条」一致;重置后恢复默认。
2. 「角色」多角色截断显示(如 `内网医院初始化模板角色 (…`),编辑页应能查看全部角色。 2. 切换每页条数(如 **20 条/页**、**50 条/页**)后当前页可见行数随所选值变化且不超过 min(N,所选值),「共 N 条」不变(自动化 `USER-USR-016`)。
3. 「科室任职数」与用户在各科室任职记录条数一致,无任职显示 `--`。 3. 「角色」多角色截断显示(如 `内网医院初始化模板角色 (…`),编辑页应能查看全部角色。
4. 新增/编辑:必填为空拦截;取消不保存;编辑回填与原值一致。 4. 「科室任职数」与用户在各科室任职记录条数一致,无任职显示 `--`。
5. 授权弹窗保存后重新打开应保留勾选;取消不生效。 5. 新增/编辑:必填为空拦截;取消不保存;编辑回填与原值一致。
6. 行内「授权」弹出用户角色授权弹框,弹框账号与所选行一致;弹框内「添加/移除」可增删角色行(不保存不落库);保存后重新打开应保留勾选;取消不生效(自动化 `USER-USR-019` / `USER-USR-020` / `USER-USR-015`)。
## 关联 ## 关联
......
...@@ -88,8 +88,10 @@ author: "" ...@@ -88,8 +88,10 @@ author: ""
1. 切换「所属医院」后列表只显示该院区用户,「共 N 条」同步变化。 1. 切换「所属医院」后列表只显示该院区用户,「共 N 条」同步变化。
2. 两页同一用户的关键字段(姓名、登录账号、角色、所属科室)一致,仅多出「所属医院」维度。 2. 两页同一用户的关键字段(姓名、登录账号、角色、所属科室)一致,仅多出「所属医院」维度。
3. 姓名 + 联系方式组合筛选精确命中;重置恢复默认。 3. 姓名 + 所属医院 + 科室 + 角色 + 状态组合筛选精确命中;高级筛选按状态过滤;重置恢复默认医院。
4. 写操作(编辑/授权/重置密码)须先定位当前行,医共体下避免误改其他院区用户。 4. 切换每页条数(如 **20 条/页**)后可见行数随所选值变化且「共 N 条」不变;列设置可打开并恢复列显示(自动化 `USER-UNI-018`~`020`)。
5. 行内「授权」弹出用户角色授权弹框,账号与所选行一致;弹框内「添加/移除」可增删角色行;取消不落库(自动化 `USER-UNI-021`~`023`)。
6. 写操作(编辑/授权/重置密码)须先定位当前行,医共体下避免误改其他院区用户。
## 关联 ## 关联
......
...@@ -52,7 +52,7 @@ author: "" ...@@ -52,7 +52,7 @@ author: ""
### 列表与工具栏 ### 列表与工具栏
工具栏:左侧角色行内 **编辑** / 删除类图标按钮(右上角图标按钮按实际文案定位)。 工具栏:左栏顶部 **「角 色」** 链接按钮(打开新增角色弹窗);选中角色后左栏底部 **编辑** / 删除类图标(行内或底部操作区,以现网为准)。
| 列 | 说明 | | 列 | 说明 |
|----|------| |----|------|
...@@ -78,13 +78,22 @@ author: "" ...@@ -78,13 +78,22 @@ author: ""
- 勾选父节点联动子节点,保存后即为该角色授权范围。 - 勾选父节点联动子节点,保存后即为该角色授权范围。
### 新增/编辑角色弹窗(自动化已覆盖打开路径)
| 字段/控件 | 说明 |
|-----------|------|
| 角色名称 | 必填;空保存提示「请输入角色名称!」 |
| 查询配置化 | 复选框,可勾选/取消 |
| 操作 | **保 存** / **取 消**;右上角 **X** 关闭 |
## 自动化建议 ## 自动化建议
1. 元素扫描已完成(1170 项):`data/scan/basic-role-elements.json`;定位器 `locators/spd/basic-role.locator.yaml`;下一步补充 Page Object 与用例。 1. 元素扫描(1170 项):`data/scan/basic-role-elements.json`;定位器 `locators/spd/basic-role.locator.yaml`;Page Object `pages/spd/basic-role.page.ts`。
2. 菜单导航:`page.getByRole('menuitem', { name: '角色管理', exact: true }).click()`;或 `openPath('/spd/basic/user/platform/role')` 断言 URL。 2. 用例 CSV/MD:`testcase/基础管理-用户管理-角色管理-UI自动化测试用例-01.csv`(**12** 条);脚本 `tests/spd/basic-role.spec.ts`;执行 `npm run test:basic-role:all`(smoke:`npm run test:basic-role`)。
3. 左右分栏:先断言角色列表与权限树均可见;选中角色后再断言右侧勾选状态已刷新(避免读到上一个角色的树)。 3. 菜单导航:`page.getByRole('menuitem', { name: '角色管理', exact: true }).click()`;或 `openPath('/spd/basic/user/platform/role')` 断言 URL。
4. 权限树按模块展开,断言时优先定位「模块名 + 菜单名」层级,避免全局文本匹配到多份同名菜单。 4. 左右分栏:先断言角色列表与权限树均可见;选中角色后再断言右侧勾选状态已刷新(避免读到上一个角色的树)。
5. 授权保存属写操作:测试账号权限变更会影响其他用例,执行前记录原勾选,结束后还原。 5. 权限树按模块展开,断言时优先定位「模块名 + 菜单名」层级,避免全局文本匹配到多份同名菜单。
6. 写操作用例:编辑保存(005)受 `SKIP_BASIC_WRITE` 控制;不覆盖权限树批量授权保存、真实新增落库、删除等。
## 测试关注点 ## 测试关注点
......
...@@ -9,6 +9,9 @@ export class BasicRoleLocators extends BasicUserSharedLocators { ...@@ -9,6 +9,9 @@ export class BasicRoleLocators extends BasicUserSharedLocators {
readonly addRoleBtn: Locator; readonly addRoleBtn: Locator;
readonly roleSearch: Locator; readonly roleSearch: Locator;
readonly roleTree: Locator; readonly roleTree: Locator;
readonly roleSider: Locator;
/** 左栏底部/右下角编辑图标(选中角色后可见) */
readonly editRoleIcon: Locator;
constructor(page: Page) { constructor(page: Page) {
super(page); super(page);
...@@ -20,6 +23,12 @@ export class BasicRoleLocators extends BasicUserSharedLocators { ...@@ -20,6 +23,12 @@ export class BasicRoleLocators extends BasicUserSharedLocators {
.or(page.locator('.ant-layout-sider input, .ant-layout-sider .ant-input')) .or(page.locator('.ant-layout-sider input, .ant-layout-sider .ant-input'))
.first(); .first();
this.roleTree = page.locator('.menu-role-tree').first(); this.roleTree = page.locator('.menu-role-tree').first();
this.roleSider = page.locator('.ant-layout-sider').first();
this.editRoleIcon = this.roleSider
.locator('button, a, [role="button"]')
.filter({ has: page.locator('.anticon-edit, .anticon-form') })
.last()
.or(this.roleSider.locator('.anticon-edit, .anticon-form').last());
} }
treeNode(title: string): Locator { treeNode(title: string): Locator {
......
import type { Locator, Page } from '@playwright/test'; import type { Locator, Page } from '@playwright/test';
import { COLUMN_SETTINGS_TRIGGER_CSS } from './center-report-shared.locator';
/** 基础设置 · 用户管理。叶子菜单是 .ant-menu-item;一级与叶子均可叫「用户管理」。 */ /** 基础设置 · 用户管理。叶子菜单是 .ant-menu-item;一级与叶子均可叫「用户管理」。 */
export class BasicUserSharedLocators { export class BasicUserSharedLocators {
readonly menuBasic: Locator; readonly menuBasic: Locator;
readonly userSubmenuTitle: Locator; readonly userSubmenuTitle: Locator;
readonly tableHeader: Locator; readonly tableHeader: Locator;
readonly tableWrapper: Locator;
readonly columnSettingsTrigger: Locator;
readonly empty: Locator; readonly empty: Locator;
readonly paginationTotal: Locator; readonly paginationTotal: Locator;
readonly pageSizeChanger: Locator;
readonly nextPage: Locator; readonly nextPage: Locator;
readonly formError: Locator; readonly formError: Locator;
readonly advancedFilter: Locator; readonly advancedFilter: Locator;
...@@ -17,8 +22,11 @@ export class BasicUserSharedLocators { ...@@ -17,8 +22,11 @@ export class BasicUserSharedLocators {
this.menuBasic = page.getByRole('menuitem', { name: '基础设置' }); this.menuBasic = page.getByRole('menuitem', { name: '基础设置' });
this.userSubmenuTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^用户管理$/ }); this.userSubmenuTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^用户管理$/ });
this.tableHeader = page.locator('.ant-table-thead th'); this.tableHeader = page.locator('.ant-table-thead th');
this.tableWrapper = page.locator('.ant-table-wrapper').first();
this.columnSettingsTrigger = page.locator(COLUMN_SETTINGS_TRIGGER_CSS).first();
this.empty = page.locator('.ant-empty, .ant-empty-description').first(); this.empty = page.locator('.ant-empty, .ant-empty-description').first();
this.paginationTotal = page.locator('.ant-pagination-total-text').first(); this.paginationTotal = page.locator('.ant-pagination-total-text').first();
this.pageSizeChanger = page.locator('.ant-pagination-options-size-changer').first();
this.nextPage = page.locator('.ant-pagination-next').first(); this.nextPage = page.locator('.ant-pagination-next').first();
this.formError = page.locator('.ant-form-item-explain-error, .ant-message-notice, .ant-notification-notice'); this.formError = page.locator('.ant-form-item-explain-error, .ant-message-notice, .ant-notification-notice');
this.advancedFilter = page.locator('.advance-filter').first(); this.advancedFilter = page.locator('.advance-filter').first();
...@@ -43,4 +51,22 @@ export class BasicUserSharedLocators { ...@@ -43,4 +51,22 @@ export class BasicUserSharedLocators {
formSelect(label: string, root: Locator | Page = this.page): Locator { formSelect(label: string, root: Locator | Page = this.page): Locator {
return this.formItem(label, root).locator('.ant-select').first(); return this.formItem(label, root).locator('.ant-select').first();
} }
columnSettingsPanel(): Locator {
return this.page
.locator('.ant-popover:visible, .ant-dropdown:visible')
.filter({ hasText: '字段设置' })
.first();
}
columnSettingsCheckboxRow(label: string): Locator {
return this.columnSettingsPanel()
.locator('.ant-checkbox-wrapper, label')
.filter({ hasText: label })
.first();
}
columnHeaderCell(columnName: string): Locator {
return this.page.locator('.ant-table-thead th').filter({ hasText: columnName }).first();
}
} }
...@@ -14,10 +14,8 @@ export class AdmitMaterialListPage extends AdmitSharedPage { ...@@ -14,10 +14,8 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
}); });
} }
async expectEntered(): Promise<void> { async ready(): Promise<void> {
await this.expectOnPage(); await this.expectOnPage();
expect(this.page.url()).toContain('zrlist');
await expect(this.tab('已通过')).toBeVisible();
} }
async filterByOrderOrFallback(): Promise<void> { async filterByOrderOrFallback(): Promise<void> {
...@@ -38,7 +36,7 @@ export class AdmitMaterialListPage extends AdmitSharedPage { ...@@ -38,7 +36,7 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
await this.expectListOrEmpty(); await this.expectListOrEmpty();
} }
async mockEmpty(): Promise<void> { async mockEmpty() {
const cert = this.textInput('注册证号'); const cert = this.textInput('注册证号');
if (await cert.isVisible().catch(() => false)) { if (await cert.isVisible().catch(() => false)) {
await this.fillFilter('注册证号', admitData.mockCert); await this.fillFilter('注册证号', admitData.mockCert);
...@@ -46,13 +44,12 @@ export class AdmitMaterialListPage extends AdmitSharedPage { ...@@ -46,13 +44,12 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
await this.fillFilter('产品名称', admitData.mockGoods); await this.fillFilter('产品名称', admitData.mockGoods);
} }
await this.query(); await this.query();
await this.expectMockEmpty(); return this.snapshotList();
} }
async expectCoreColumns(): Promise<void> { async coreHeaders(): Promise<string[]> {
await this.query(); await this.query();
await this.expectHeaders(['准入类型', '准入单号', '进院供应商', '产品名称', '注册证号', '耗材类型', '产品条数', '状态', '操作']); return this.headerTexts();
await expect(this.rowAction(await this.firstRow(), '详情')).toBeVisible();
} }
async switchPassed(): Promise<void> { async switchPassed(): Promise<void> {
......
...@@ -14,13 +14,8 @@ export class AdmitMaterialPage extends AdmitSharedPage { ...@@ -14,13 +14,8 @@ export class AdmitMaterialPage extends AdmitSharedPage {
}); });
} }
async expectEntered(): Promise<void> { async ready(): Promise<void> {
await this.expectOnPage(); await this.expectOnPage();
expect(this.page.url()).toContain('ZRlist');
for (const name of ['关联产品', '生成申请单', '一键生成', '作废']) {
await expect(this.button(name).first(), name).toBeVisible();
}
await expect(this.tab('待申请')).toBeVisible();
} }
async filterByProductName(): Promise<void> { async filterByProductName(): Promise<void> {
...@@ -65,29 +60,15 @@ export class AdmitMaterialPage extends AdmitSharedPage { ...@@ -65,29 +60,15 @@ export class AdmitMaterialPage extends AdmitSharedPage {
await this.collapseKeeps('产品名称', name.slice(0, 20)); await this.collapseKeeps('产品名称', name.slice(0, 20));
} }
async mockEmptyName(): Promise<void> { async mockEmptyName() {
await this.fillFilter('产品名称', admitData.mockGoods); await this.fillFilter('产品名称', admitData.mockGoods);
await this.query(); await this.query();
await this.expectMockEmpty(); return this.snapshotList();
} }
async expectCoreColumns(): Promise<void> { async coreHeaders(): Promise<string[]> {
await this.query(); await this.query();
await this.expectHeaders([ return this.headerTexts();
'准入类型',
'产品编号',
'产品名称',
'规格型号',
'规格',
'型号',
'器械注册人',
'单位',
'耗材类型',
'默认进院供应商',
'操作',
]);
const row = await this.firstRow();
await expect(this.rowAction(row, '详情')).toBeVisible();
} }
async switchPending(): Promise<void> { async switchPending(): Promise<void> {
......
...@@ -14,12 +14,8 @@ export class AdmitProductApplyPage extends AdmitSharedPage { ...@@ -14,12 +14,8 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
}); });
} }
async expectEntered(): Promise<void> { async ready(): Promise<void> {
await this.expectOnPage(); await this.expectOnPage();
expect(this.page.url()).not.toContain('ZRlist');
for (const name of ['新增产品', 'Excel导入', '生成申请单']) {
await expect(this.button(name).first(), name).toBeVisible();
}
} }
async filterByProductName(): Promise<void> { async filterByProductName(): Promise<void> {
...@@ -49,15 +45,15 @@ export class AdmitProductApplyPage extends AdmitSharedPage { ...@@ -49,15 +45,15 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
await this.expectListOrEmpty(); await this.expectListOrEmpty();
} }
async mockEmpty(): Promise<void> { async mockEmpty() {
await this.fillFilter('产品名称', admitData.mockGoods); await this.fillFilter('产品名称', admitData.mockGoods);
await this.query(); await this.query();
await this.expectMockEmpty(); return this.snapshotList();
} }
async expectCoreColumns(): Promise<void> { async coreHeaders(): Promise<string[]> {
await this.query(); await this.query();
await this.expectHeaders(['准入单号', '产品编号', '产品名称', '规格型号', '器械注册人', '单位', '操作']); return this.headerTexts();
} }
async switchPending(): Promise<void> { async switchPending(): Promise<void> {
......
...@@ -14,15 +14,8 @@ export class AdmitProductListPage extends AdmitSharedPage { ...@@ -14,15 +14,8 @@ export class AdmitProductListPage extends AdmitSharedPage {
}); });
} }
async expectEntered(): Promise<void> { async ready(): Promise<void> {
await this.expectOnPage(); await this.expectOnPage();
expect(this.page.url()).not.toContain('zrlist');
await expect(this.tab('已通过')).toBeVisible();
}
async expectNotMaterialUrl(): Promise<void> {
expect(this.page.url()).toContain('productOrder/list');
expect(this.page.url()).not.toContain('zrlist');
} }
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
...@@ -59,19 +52,19 @@ export class AdmitProductListPage extends AdmitSharedPage { ...@@ -59,19 +52,19 @@ export class AdmitProductListPage extends AdmitSharedPage {
await this.expectListOrEmpty(); await this.expectListOrEmpty();
} }
async mockEmpty(): Promise<void> { async mockEmpty() {
if (await this.textInput('产品名称').isVisible().catch(() => false)) { if (await this.textInput('产品名称').isVisible().catch(() => false)) {
await this.fillFilter('产品名称', admitData.mockGoods); await this.fillFilter('产品名称', admitData.mockGoods);
} else { } else {
await this.fillFilter('注册证号', admitData.mockCert); await this.fillFilter('注册证号', admitData.mockCert);
} }
await this.query(); await this.query();
await this.expectMockEmpty(); return this.snapshotList();
} }
async expectCoreColumns(): Promise<void> { async coreHeaders(): Promise<string[]> {
await this.query(); await this.query();
await this.expectHeaders(['准入单号', '进院供应商', '产品名称', '注册证号', '耗材类型', '产品条数', '状态', '备注', '附件', '操作']); return this.headerTexts();
} }
async switchPassed(): Promise<void> { async switchPassed(): Promise<void> {
......
...@@ -232,25 +232,46 @@ export class AdmitSharedPage extends BasePage { ...@@ -232,25 +232,46 @@ export class AdmitSharedPage extends BasePage {
return row; return row;
} }
async expectListOrEmpty(): Promise<void> { /** 列表快照供用例层断言(不在此 expect)。 */
async snapshotList(): Promise<{ rows: number; total: number; emptyVisible: boolean }> {
await this.waitForTableReady(); await this.waitForTableReady();
const rows = await this.getDataRowCount(); const rows = await this.getDataRowCount();
if (rows > 0) { const total = await this.getTotalCount();
expect(await this.getTotalCount(), '有数据时分页总数应 ≥ 1').toBeGreaterThanOrEqual(1); const emptyVisible =
(await this.locators.empty.isVisible().catch(() => false)) ||
(await this.page.getByText('暂无数据').first().isVisible().catch(() => false));
return { rows, total, emptyVisible };
}
/** @deprecated 优先在 spec 用 snapshotList + assertAdmitListOrEmpty */
async expectListOrEmpty(): Promise<void> {
const state = await this.snapshotList();
if (state.rows > 0) {
expect(state.total, '有数据时分页总数应 ≥ 1').toBeGreaterThanOrEqual(1);
return; return;
} }
const emptyVisible = await this.locators.empty.isVisible().catch(() => false); expect(state.emptyVisible || state.total === 0, '无数据时空态应明确').toBeTruthy();
const emptyText = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
expect(emptyVisible || emptyText || (await this.getTotalCount()) === 0, '无数据时空态应明确').toBeTruthy();
} }
/** @deprecated 优先在 spec 用 snapshotList + assertAdmitMockEmpty */
async expectMockEmpty(): Promise<void> { async expectMockEmpty(): Promise<void> {
await this.waitForTableReady(); const state = await this.snapshotList();
const rows = await this.getDataRowCount(); expect(state.rows === 0 || state.emptyVisible, '无匹配应空态').toBeTruthy();
const total = await this.getTotalCount(); expect(state.total === 0 || state.rows === 0, '分页总数应为 0').toBeTruthy();
const empty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false); }
expect(rows === 0 || empty, '无匹配应空态').toBeTruthy();
expect(total === 0 || rows === 0, '分页总数应为 0').toBeTruthy(); /** 只读:抽样行某列是否含关键字(用例层断言)。 */
async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise<boolean> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
const rows = await this.listRows();
const count = await rows.count();
if (count === 0) return false;
const limit = Math.min(count, sample);
for (let i = 0; i < limit; i += 1) {
const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, '');
if (!text.includes(needle)) return false;
}
return true;
} }
async expectRowsContain(header: string, keyword: string): Promise<void> { async expectRowsContain(header: string, keyword: string): Promise<void> {
......
...@@ -14,10 +14,8 @@ export class AdmitSupplyPage extends AdmitSharedPage { ...@@ -14,10 +14,8 @@ export class AdmitSupplyPage extends AdmitSharedPage {
}); });
} }
async expectEntered(): Promise<void> { async ready(): Promise<void> {
await this.expectOnPage(); await this.expectOnPage();
await expect(this.button('新增').first()).toBeVisible();
await expect(this.button('批量提交').first()).toBeVisible();
} }
async filterBySupplier(): Promise<void> { async filterBySupplier(): Promise<void> {
...@@ -36,19 +34,19 @@ export class AdmitSupplyPage extends AdmitSharedPage { ...@@ -36,19 +34,19 @@ export class AdmitSupplyPage extends AdmitSharedPage {
await this.expectListOrEmpty(); await this.expectListOrEmpty();
} }
async mockEmpty(): Promise<void> { async mockEmpty() {
if (await this.textInput('变更单号').isVisible().catch(() => false)) { if (await this.textInput('变更单号').isVisible().catch(() => false)) {
await this.fillFilter('变更单号', admitData.mockOrder); await this.fillFilter('变更单号', admitData.mockOrder);
} else { } else {
await this.fillFilter('产品名称', admitData.mockGoods); await this.fillFilter('产品名称', admitData.mockGoods);
} }
await this.query(); await this.query();
await this.expectMockEmpty(); return this.snapshotList();
} }
async expectCoreColumns(): Promise<void> { async coreHeaders(): Promise<string[]> {
await this.query(); await this.query();
await this.expectHeaders(['申请单号', '申请人', '申请时间', '进院供应商', '二级供应商', '业务模式', '是否默认', '备注', '操作']); return this.headerTexts();
} }
async switchPending(): Promise<void> { async switchPending(): Promise<void> {
......
...@@ -14,11 +14,8 @@ export class AdmitTaskPage extends AdmitSharedPage { ...@@ -14,11 +14,8 @@ export class AdmitTaskPage extends AdmitSharedPage {
}); });
} }
async expectEntered(): Promise<void> { async ready(): Promise<void> {
await this.expectOnPage(); await this.expectOnPage();
await expect(this.tab('待分类')).toBeVisible();
await expect(this.tab('已分类')).toBeVisible();
await expect(this.button('批量分配').first()).toBeVisible();
} }
async filterByProductName(): Promise<void> { async filterByProductName(): Promise<void> {
...@@ -45,18 +42,15 @@ export class AdmitTaskPage extends AdmitSharedPage { ...@@ -45,18 +42,15 @@ export class AdmitTaskPage extends AdmitSharedPage {
await this.expectListOrEmpty(); await this.expectListOrEmpty();
} }
async mockEmpty(): Promise<void> { async mockEmpty() {
await this.fillFilter('产品名称', admitData.mockGoods); await this.fillFilter('产品名称', admitData.mockGoods);
await this.query(); await this.query();
await this.expectMockEmpty(); return this.snapshotList();
} }
async expectCoreColumns(): Promise<void> { async coreHeaders(): Promise<string[]> {
await this.clickTab('待分类'); await this.clickTab('待分类');
await this.expectHeaders(['准入类型', '产品编号', '产品名称', '规格型号', '规格', '型号', '器械注册人', '操作']); return this.headerTexts();
if ((await this.getDataRowCount()) > 0) {
await expect(this.rowAction(await this.firstRow(), '分配类型')).toBeVisible();
}
} }
async goPage2(): Promise<void> { async goPage2(): Promise<void> {
......
...@@ -59,6 +59,109 @@ export class BasicRolePage extends BasicUserSharedPage { ...@@ -59,6 +59,109 @@ export class BasicRolePage extends BasicUserSharedPage {
return found; return found;
} }
leftRoleListScrollHost(): Locator {
const sider = this.locators.roleSider;
return sider
.locator('.ant-table-body')
.first()
.or(sider.locator('.rc-virtual-list-holder').first())
.or(sider.locator('.ant-list').first());
}
async assertLeftRoleListVerticalScroll(): Promise<void> {
const first = this.leftRoleItems().first();
await expect(first, '左栏应有角色行').toBeVisible();
await this.assertVerticalScrollRegion(
this.leftRoleListScrollHost(),
first,
'数据不足:左栏列表高度未溢出,无法验证滚动',
'左栏列表滚动后',
);
}
roleTreeScrollHost(): Locator {
return this.locators.roleTree
.locator('.ant-tree-list-holder')
.first()
.or(this.locators.roleTree.locator('.ant-tree-list').first())
.or(this.locators.roleTree);
}
async assertVerticalScrollRegion(
host: Locator,
walkAnchor: Locator,
skipDetail: string,
healthContext: string,
): Promise<void> {
const hostScrollable = await host
.evaluate((el) => el.scrollHeight > el.clientHeight + 4)
.catch(() => false);
if (hostScrollable) {
const top0 = await host.evaluate((el) => el.scrollTop);
await host.evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
await this.waitSettle(250);
const topDown = await host.evaluate((el) => el.scrollTop);
expect(topDown, '向下滚动后 scrollTop 应增加').toBeGreaterThan(top0);
const box = await host.boundingBox();
if (box) {
await this.page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 120));
await this.page.mouse.wheel(0, -600);
await this.waitSettle(250);
} else {
await host.evaluate((el) => {
el.scrollTop = 0;
});
}
const topUp = await host.evaluate((el) => el.scrollTop);
expect(topUp, '向上滑动后应离开底部').toBeLessThan(topDown);
await host.evaluate((el) => {
el.scrollTop = 0;
});
await this.assertPageHealthy(healthContext);
return;
}
const walked = await walkAnchor.evaluate((row) => {
let el: HTMLElement | null = row as HTMLElement;
while (el) {
if (el.scrollHeight > el.clientHeight + 4) {
const top0 = el.scrollTop;
el.scrollTop = el.scrollHeight;
const topDown = el.scrollTop;
el.scrollTop = 0;
const topUp = el.scrollTop;
return { ok: true as const, top0, topDown, topUp };
}
el = el.parentElement;
}
return { ok: false as const };
});
if (!walked.ok) {
await this.skipWhenDataInsufficient(skipDetail);
}
expect(walked.topDown, '向下滚动后 scrollTop 应增加').toBeGreaterThan(walked.top0);
expect(walked.topUp, '向上滚回后应回到顶部').toBeLessThanOrEqual(4);
await this.assertPageHealthy(healthContext);
}
async assertRoleTreeVerticalScroll(): Promise<void> {
const name = await this.sampleRoleName();
await this.selectRole(name);
await expect(this.locators.roleTree, '权限树').toBeVisible();
await this.expandTreeNode('基础设置');
const firstNode = this.locators.roleTree.locator('.ant-tree-treenode').first();
await expect(firstNode, '权限树应有节点').toBeVisible();
await this.assertVerticalScrollRegion(
this.roleTreeScrollHost(),
firstNode,
'数据不足:右侧权限树高度未溢出,无法验证滚动',
'右侧权限树滚动后',
);
}
async selectRole(name: string): Promise<void> { async selectRole(name: string): Promise<void> {
const item = this.leftRoleItems().filter({ hasText: name }).first(); const item = this.leftRoleItems().filter({ hasText: name }).first();
if (await item.isVisible().catch(() => false)) { if (await item.isVisible().catch(() => false)) {
...@@ -111,11 +214,21 @@ export class BasicRolePage extends BasicUserSharedPage { ...@@ -111,11 +214,21 @@ export class BasicRolePage extends BasicUserSharedPage {
return parts.join('|'); return parts.join('|');
} }
async expandTreeNode(title: string): Promise<void> { treeNodeRow(title: string): Locator {
const node = this.locators.roleTree return this.locators.roleTree
.locator('.ant-tree-treenode') .locator('.ant-tree-treenode')
.filter({ has: this.page.locator('.ant-tree-title').filter({ hasText: new RegExp(`^\\s*${title}\\s*$`) }) }) .filter({ has: this.page.locator('.ant-tree-title').filter({ hasText: new RegExp(`^\\s*${title}\\s*$`) }) })
.first(); .first();
}
async isTreeNodeExpanded(title: string): Promise<boolean> {
const switcher = this.treeNodeRow(title).locator('.ant-tree-switcher').first();
if (!(await switcher.isVisible().catch(() => false))) return false;
return switcher.evaluate((el) => el.classList.contains('ant-tree-switcher_open')).catch(() => false);
}
async expandTreeNode(title: string): Promise<void> {
const node = this.treeNodeRow(title);
if (!(await node.isVisible().catch(() => false))) return; if (!(await node.isVisible().catch(() => false))) return;
const switcher = node.locator('.ant-tree-switcher').first(); const switcher = node.locator('.ant-tree-switcher').first();
const closed = await switcher const closed = await switcher
...@@ -125,6 +238,17 @@ export class BasicRolePage extends BasicUserSharedPage { ...@@ -125,6 +238,17 @@ export class BasicRolePage extends BasicUserSharedPage {
await this.waitSettle(200); await this.waitSettle(200);
} }
async collapseTreeNode(title: string): Promise<void> {
const node = this.treeNodeRow(title);
if (!(await node.isVisible().catch(() => false))) return;
const switcher = node.locator('.ant-tree-switcher').first();
const open = await switcher
.evaluate((el) => el.classList.contains('ant-tree-switcher_open'))
.catch(() => false);
if (open) await this.click(switcher, `收起 ${title}`);
await this.waitSettle(200);
}
async isTreeChecked(title: string): Promise<boolean> { async isTreeChecked(title: string): Promise<boolean> {
const box = this.locators.treeCheckbox(title); const box = this.locators.treeCheckbox(title);
const cls = (await box.getAttribute('class').catch(() => '')) ?? ''; const cls = (await box.getAttribute('class').catch(() => '')) ?? '';
...@@ -165,6 +289,84 @@ export class BasicRolePage extends BasicUserSharedPage { ...@@ -165,6 +289,84 @@ export class BasicRolePage extends BasicUserSharedPage {
.first(); .first();
} }
editEntry(roleName: string): Locator {
const row = this.leftRoleItems().filter({ hasText: roleName }).first();
return row
.getByRole('button', { name: /编\s*辑/ })
.or(row.locator('.anticon-edit, .anticon-form, [aria-label*="edit"], [aria-label*="编辑"]'))
.first();
}
normalizeRoleLabel(text: string): string {
return text.replace(/\s/g, '').replace(/[((].*$/, '').trim();
}
roleNamesMatch(selected: string, inForm: string): boolean {
const a = this.normalizeRoleLabel(selected);
const b = this.normalizeRoleLabel(inForm);
if (!a || !b) return false;
return a === b || a.includes(b) || b.includes(a);
}
async readEditRoleNameFromDialog(): Promise<string> {
const dialog = this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last();
const labeled = dialog
.locator('.ant-form-item')
.filter({ hasText: /角色名称|角\s*色名/ })
.locator('input')
.first();
if (await labeled.isVisible().catch(() => false)) {
return ((await labeled.inputValue()) ?? '').trim();
}
const placeholder = dialog.getByPlaceholder(/角色/).first();
if (await placeholder.isVisible().catch(() => false)) {
return ((await placeholder.inputValue()) ?? '').trim();
}
return ((await dialog.locator('input:not([type="hidden"])').first().inputValue().catch(() => '')) ?? '').trim();
}
async openEditRoleDialog(roleName?: string): Promise<{ selectedRoleName: string; dialog: Locator }> {
const selectedRoleName = roleName ?? (await this.sampleRoleName());
await this.selectRole(selectedRoleName);
const cornerEdit = this.locators.editRoleIcon;
const rowEdit = this.editEntry(selectedRoleName);
if (await cornerEdit.isVisible().catch(() => false)) {
await this.click(cornerEdit, '左栏右下角编辑');
} else if (await rowEdit.isVisible().catch(() => false)) {
await this.click(rowEdit, '行内编辑角色');
} else {
await this.skipWhenDataInsufficient('编辑入口不可见');
return { selectedRoleName, dialog: this.dialog() };
}
await this.waitSettle(400);
const dialog = this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last();
await expect(dialog, '编辑角色弹窗').toBeVisible({ timeout: 12_000 });
return { selectedRoleName, dialog };
}
editRoleDialog(): Locator {
return this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last();
}
async saveEditRoleDialog(): Promise<void> {
const dialog = this.editRoleDialog();
const save = dialog.getByRole('button', { name: /保\s*存/ }).first();
await expect(save, '弹框保存按钮').toBeVisible();
await this.click(save, '保存编辑角色', { operation: 'write' });
await this.waitSpinGone();
await this.waitSettle(400);
}
async expectToastSuccess(text: string | RegExp): Promise<void> {
const toast = this.page
.locator('.ant-message-notice, .ant-message-success, .ant-notification-notice, .ant-message')
.filter({ hasText: text })
.first();
await expect(toast, `应提示成功:${String(text)}`).toBeVisible({ timeout: 12_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertPageHealthy('保存后页面健康');
}
async openDeleteConfirm(): Promise<{ roleName: string; confirm: Locator } | null> { async openDeleteConfirm(): Promise<{ roleName: string; confirm: Locator } | null> {
const names = await this.leftRoleNames(); const names = await this.leftRoleNames();
const target = names.find((n) => n && !/管理员/.test(n)) ?? names[0]; const target = names.find((n) => n && !/管理员/.test(n)) ?? names[0];
...@@ -192,21 +394,68 @@ export class BasicRolePage extends BasicUserSharedPage { ...@@ -192,21 +394,68 @@ export class BasicRolePage extends BasicUserSharedPage {
return { roleName: target, confirm }; return { roleName: target, confirm };
} }
async openAddRoleForm(): Promise<{ before: number; opened: boolean }> { addRoleDialog(): Locator {
const add = this.locators.addRoleBtn.or(this.button('新增').first()); return this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last();
}
async openAddRoleDialog(): Promise<Locator> {
const add = this.locators.addRoleBtn;
if (!(await add.isVisible().catch(() => false))) { if (!(await add.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('新增角色入口不可见'); await this.skipWhenDataInsufficient('「角色」按钮不可见');
return { before: 0, opened: false }; return this.addRoleDialog();
} }
const before = (await this.leftRoleNames()).length; await this.click(add, '角 色');
await this.click(add, '新增角色');
await this.waitSettle(400); await this.waitSettle(400);
const layer = this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')); const dialog = this.addRoleDialog();
const opened = await layer.first().isVisible().catch(() => false); await expect(dialog, '新增角色弹窗').toBeVisible({ timeout: 12_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertPageHealthy('打开新增角色弹窗');
return dialog;
}
async openAddRoleForm(): Promise<{ before: number; opened: boolean }> {
const before = (await this.leftRoleNames()).length;
const dialog = await this.openAddRoleDialog();
const opened = await dialog.isVisible().catch(() => false);
return { before, opened }; return { before, opened };
} }
addRoleDialogCloseIcon(): Locator {
const dialog = this.addRoleDialog();
return dialog
.locator('.ant-modal-close')
.first()
.or(dialog.getByRole('button', { name: /close|关闭/i }).first())
.or(dialog.getByRole('img', { name: 'close' }).first());
}
async closeAddRoleDialogByX(): Promise<void> {
const dialog = this.addRoleDialog();
await expect(dialog, '新增弹框应可见').toBeVisible();
const close = this.addRoleDialogCloseIcon();
await expect(close, '右上角 X 关闭').toBeVisible();
await this.click(close, '点击新增弹框 X 关闭');
await expect(dialog, 'X 关闭后弹框应隐藏').toBeHidden({ timeout: 8_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertPageHealthy('X 关闭新增角色弹框后');
}
async cancelAddRoleDialog(): Promise<void> {
const dialog = this.addRoleDialog();
await expect(dialog, '新增弹框应可见').toBeVisible();
const cancel = dialog.getByRole('button', { name: /取\s*消|关\s*闭/ }).first();
await expect(cancel, '取消按钮').toBeVisible();
await this.click(cancel, '取消新增角色');
await expect(dialog, '取消后弹框应关闭').toBeHidden({ timeout: 8_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertPageHealthy('取消新增角色后');
}
async cancelAddRole(): Promise<void> { async cancelAddRole(): Promise<void> {
if (await this.addRoleDialog().isVisible().catch(() => false)) {
await this.cancelAddRoleDialog();
return;
}
const layer = this.dialog().or(this.page.locator('.ant-modal:visible')); const layer = this.dialog().or(this.page.locator('.ant-modal:visible'));
if (await layer.first().isVisible().catch(() => false)) { if (await layer.first().isVisible().catch(() => false)) {
await this.closeDialog(); await this.closeDialog();
...@@ -214,4 +463,65 @@ export class BasicRolePage extends BasicUserSharedPage { ...@@ -214,4 +463,65 @@ export class BasicRolePage extends BasicUserSharedPage {
} }
await this.page.keyboard.press('Escape'); await this.page.keyboard.press('Escape');
} }
addRoleNameFormItem(): Locator {
const dialog = this.addRoleDialog();
return this.locators
.formItem('角色名称', dialog)
.or(dialog.locator('.ant-form-item').filter({ hasText: /角色名称|角\s*色名/ }).first());
}
queryConfigCheckbox(label = '查询配置化'): Locator {
const dialog = this.addRoleDialog();
return dialog.locator('.ant-checkbox-wrapper').filter({ hasText: label }).first();
}
async ensureQueryConfigCheckbox(label = '查询配置化'): Promise<Locator> {
const wrapper = this.queryConfigCheckbox(label);
if (!(await wrapper.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient(`新增弹框无「${label}」复选框`);
}
await expect(wrapper, `「${label}」复选框`).toBeVisible();
return wrapper;
}
async isQueryConfigChecked(label = '查询配置化'): Promise<boolean> {
const wrapper = await this.ensureQueryConfigCheckbox(label);
return wrapper.locator('input[type="checkbox"]').isChecked();
}
async setQueryConfigChecked(checked: boolean, label = '查询配置化'): Promise<void> {
const wrapper = await this.ensureQueryConfigCheckbox(label);
const input = wrapper.locator('input[type="checkbox"]');
const current = await input.isChecked();
if (current !== checked) {
await this.click(wrapper, checked ? `勾选${label}` : `取消勾选${label}`);
await this.waitSettle(200);
}
expect(await input.isChecked(), `${label} 勾选态应为 ${checked}`).toBe(checked);
}
async saveAddRoleDialog(): Promise<void> {
const dialog = this.addRoleDialog();
const save = dialog.getByRole('button', { name: /保\s*存/ }).first();
await expect(save, '弹框保存按钮').toBeVisible();
await this.click(save, '保存新增角色');
await this.waitSettle(400);
}
async expectAddRoleNameRequired(message = '请输入角色名称!'): Promise<void> {
const dialog = this.addRoleDialog();
await expect(dialog, '新增弹框仍应打开').toBeVisible();
const item = this.addRoleNameFormItem();
await expect(item, '角色名称表单项应校验失败').toHaveClass(/ant-form-item-has-error/);
const explain = item.locator('.ant-form-item-explain-error').filter({ hasText: message });
await expect(explain.first(), `应提示:${message}`).toBeVisible({ timeout: 8_000 });
const inputWrap = item.locator('input, .ant-input-affix-wrapper').first();
const wrapClass = (await inputWrap.getAttribute('class').catch(() => '')) ?? '';
const fieldLooksInvalid =
/ant-input-status-error|ant-input-affix-wrapper-status-error/.test(wrapClass) ||
(await item.getAttribute('class').catch(() => ''))?.includes('ant-form-item-has-error');
expect(fieldLooksInvalid, '角色名称输入框应变红(校验失败态)').toBeTruthy();
expect(await this.page.locator('.ant-message-error').count(), '不应出现系统级错误 toast').toBe(0);
}
} }
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment