Commit ed57ab23 authored by liguangyu06's avatar liguangyu06
Browse files

修复 spd-ui-verify 复跑:对齐现网定位与报表取数

针对 Jenkins 失败清单收敛 PO:角色左栏 list、准入可勾选行、用户表单 strict、报表宽表/mock 与表头别名。
parent c9046b24
......@@ -8,11 +8,24 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [
['医保单件名称', '单件名称', '医保名称'],
['HIS编码', 'HIS码', 'His编码'],
['结算单标签', '标签'],
['产品编号', '产品编码', '物资编码', '商品编码'],
['产品名称', '物资名称', '商品名称'],
['规格型号', '规格'],
['产品编号', '产品编码', '物资编码', '商品编码', '物料编号'],
['产品名称', '物资名称', '商品名称', '产品俗称', '物料名称'],
['规格型号', '规格', '产品规格'],
['产品批号', '批号'],
['进院供应商', '进院供应商名称'],
['进院供应商', '进院供应商名称', '默认进院供应商', '供应商名称', '供应商', '销售商', '销售商名称'],
['单号', '变更单号', '申请单号', '授权单号'],
['方案编码', '评价方案编码'],
['方案名称', '评价方案名称'],
['产品条数', '产品数量', '条数'],
['准入单号', '准入单编号', '单号'],
['耗材类型', '物资类型'],
['操作', '操作栏', '操作列'],
['序号', '行号'],
['注册证号(新)', '注册证号', '新注册证号'],
['启用云仓', '云仓', '是否启用云仓'],
['二级供应商名称', '二级供应商', '供应商名称'],
['院区', '院区名称'],
['产品类型', '耗材类型', '物资类型'],
];
function aliasTokensFor(columnName: string): string[] {
......
import type { Locator } from '@playwright/test';
import { columnHeaderMatchTokens, columnHeaderTextsMatch } from './column-header-match.util';
/** 与 table-scroll-layout.helper 一致:不参与列对齐的单元格。 */
export function isUtilityTableCellClass(className: string): boolean {
const cls = className ?? '';
return (
cls.includes('ant-table-selection-column') ||
cls.includes('ant-table-row-expand-icon-cell') ||
cls.includes('ant-table-cell-scrollbar') ||
cls.includes('ant-table-measure-row') ||
cls.includes('ant-table-cell-fix-left') ||
cls.includes('ant-table-cell-fix-right')
);
}
export function resolveColumnIndexFromLabels(headerLabels: string[], columnName: string): number {
let index = headerLabels.findIndex((item) => columnHeaderTextsMatch(item, columnName));
if (index >= 0) return index;
for (const token of columnHeaderMatchTokens(columnName)) {
index = headerLabels.findIndex((item) => columnHeaderTextsMatch(item, token));
if (index >= 0) return index;
}
return -1;
}
/** 从 th 列表得到与 tbody 数据列对齐的标签(去掉选择列/展开列等)。 */
export async function dataHeaderLabelsFromCells(headerCells: Locator): Promise<string[]> {
const count = await headerCells.count();
const labels: string[] = [];
for (let i = 0; i < count; i += 1) {
const th = headerCells.nth(i);
const cls = (await th.getAttribute('class')) ?? '';
if (isUtilityTableCellClass(cls)) continue;
const titleEl = th.locator('.ant-table-column-title').first();
let raw = '';
// 空表头 th 无 .ant-table-column-title;直接 innerText 会等到 action 超时
if ((await titleEl.count()) > 0) {
raw = (await titleEl.innerText({ timeout: 1_500 }).catch(() => '')).trim();
}
if (!raw) {
raw = ((await th.innerText({ timeout: 1_500 }).catch(() => '')) || '').trim();
}
labels.push(raw.replace(/\s/g, ''));
}
return labels;
}
/** 行内数据列文本(与 dataHeaderLabelsFromCells 顺序一致)。 */
export async function dataCellTextsFromRow(row: Locator): Promise<string[]> {
const tds = row.locator('td');
const count = await tds.count();
const cells: string[] = [];
for (let i = 0; i < count; i += 1) {
const td = tds.nth(i);
const cls = (await td.getAttribute('class')) ?? '';
if (isUtilityTableCellClass(cls)) continue;
cells.push(((await td.innerText()) ?? '').trim());
}
return cells;
}
export async function readRowCellByColumn(row: Locator, headerLabels: string[], columnName: string): Promise<string> {
const index = resolveColumnIndexFromLabels(headerLabels, columnName);
if (index < 0) {
throw new Error(`找不到列「${columnName}」`);
}
const cells = await dataCellTextsFromRow(row);
return cells[index] ?? '';
}
export function normalizeSampleCellValue(text: string): string {
const v = text.replace(/\s/g, '').trim();
if (!v || v === '--' || v === '-' || v === '—') return '';
return text.trim();
}
......@@ -48,7 +48,9 @@ export const anaProvData: CenterReportPageData & {
horizontalScrollTargetColumn: '进院供应商名称',
horizontalScrollDragDelta: 320,
mockNotExistProduct: '__NOT_EXIST_SUPPLIER__99999',
mockFieldLabel: '进院供应商',
/** 图表页快捷区常仅有「时间」,用未来区间反例空态。 */
mockFieldLabel: '时间',
mockPreferFutureDate: true,
drawerResetFieldLabel: '进院供应商名称',
drawerResetFieldKey: 'supplierName',
......
......@@ -35,7 +35,22 @@ export class AdmitSharedLocators {
}
tab(name: string): Locator {
return this.page.locator('.ant-tabs-tab').filter({ hasText: new RegExp(name) }).first();
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(escaped);
const tabsNav = this.page.locator('.ant-tabs-nav, .ant-tabs').first();
return tabsNav
.locator('.ant-tabs-tab-btn, .ant-tabs-tab')
.filter({ hasText: pattern })
.first()
.or(tabsNav.getByRole('tab', { name: pattern }).first())
.or(
this.page
.locator('.ant-radio-group, .ant-segmented')
.locator('.ant-radio-button-wrapper, .ant-segmented-item')
.filter({ hasText: pattern })
.first(),
)
.or(this.page.getByRole('tab', { name: pattern }).first());
}
putPage(): Locator {
......
......@@ -15,7 +15,10 @@ export class BasicBranchLocators extends BasicSharedLocators {
constructor(page: Page) {
super(page);
this.addBtn = page.getByRole('button', { name: /新\s*增/ });
this.campusNameInput = page.getByPlaceholder('院区名称');
this.campusNameInput = page
.locator('main, .ant-layout-content')
.getByPlaceholder('院区名称')
.first();
this.addName = page.locator('#basic_name');
this.addAddress = page.locator('#basic_address');
this.editId = page.locator('#form_item_id');
......
......@@ -14,7 +14,11 @@ export class BasicNoticeLocators extends BasicSharedLocators {
constructor(page: Page) {
super(page);
this.noticeTitle = page.locator('#form_item_title');
this.noticeTitle = page
.locator('main, .ant-layout-content')
.locator('#form_item_title')
.first()
.or(page.getByRole('textbox', { name: /公告标题/ }).first());
this.publishScopeSelect = page.locator('.ant-form-item').filter({ hasText: '发布范围' }).locator('.ant-select').first();
this.searchBtn = page.getByRole('button', { name: /查\s*询/ });
this.resetBtn = page.getByRole('button', { name: /重\s*置/ });
......
......@@ -17,18 +17,19 @@ export class BasicRoleLocators extends BasicUserSharedLocators {
super(page);
this.saveBtn = page.getByRole('button', { name: /保\s*存/ });
this.refreshBtn = page.getByRole('button', { name: /刷\s*新/ });
this.addRoleBtn = page.getByRole('button', { name: /^角\s*色$/ });
// accessible name 常为「角色」;严格 ^$ 在 Playwright 下偶发匹配不到
this.addRoleBtn = page
.getByRole('button', { name: /角\s*色/ })
.filter({ hasNot: page.locator('.menu-role-tree') })
.first();
this.roleSearch = page
.getByPlaceholder(/角色/)
.or(page.locator('.ant-layout-sider input, .ant-layout-sider .ant-input'))
.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());
/** 左栏角色列表(现网为「角色名称」下的 list,不在 ant-layout-sider)。 */
this.roleSider = page.getByRole('list').first();
this.editRoleIcon = page.locator('img[alt="form"], .anticon-form').first();
}
treeNode(title: string): Locator {
......
......@@ -45,7 +45,14 @@ export class BasicUserSharedLocators {
}
formItem(label: string, root: Locator | Page = this.page): Locator {
return root.locator('.ant-form-item').filter({ hasText: label }).first();
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const byLabel = root
.locator('.ant-form-item')
.filter({
has: root.locator('.ant-form-item-label, label').filter({ hasText: new RegExp(`^\\s*${escaped}\\s*$`) }),
})
.first();
return byLabel.or(root.locator('.ant-form-item').filter({ hasText: new RegExp(`^\\s*${escaped}`) }).first());
}
formSelect(label: string, root: Locator | Page = this.page): Locator {
......
......@@ -5,12 +5,25 @@ import { columnHeaderMatchTokens } from '../../core/column-header-match.util';
/** FineReport 常把路径斜杠编码成 %2F / %2f,两种 src 都要能挂上 iframe。 */
export function iframeCssSelector(iframeSrc: string): string {
const variants = new Set<string>([iframeSrc]);
const parts = iframeSrc.split('/').filter(Boolean);
if (iframeSrc.includes('/')) {
variants.add(iframeSrc.replace(/\//g, '%2F'));
variants.add(iframeSrc.replace(/\//g, '%2f'));
variants.add(iframeSrc.replace(/\//g, '%252F'));
const leaf = iframeSrc.split('/').filter(Boolean).pop() ?? '';
if (leaf.length >= 6) variants.add(leaf);
}
if (parts.length >= 2) {
variants.add(parts.slice(-2).join('/'));
variants.add(parts.slice(-2).join('%2F'));
}
const leaf = parts[parts.length - 1] ?? '';
if (leaf.length >= 4) {
variants.add(leaf);
}
if (iframeSrc.includes('settel/')) {
variants.add(iframeSrc.replace(/settel\//g, 'settle/'));
}
if (iframeSrc.includes('settle/')) {
variants.add(iframeSrc.replace(/settle\//g, 'settel/'));
}
return [...variants].map((src) => `iframe[src*="${src}"]`).join(', ');
}
......
......@@ -25,7 +25,7 @@ export class FlowDefLocators extends WhFlowSharedLocators {
typeItems(): Locator {
return this.page.locator(
'.ant-layout-sider .ant-menu-item, .ant-layout-sider li, [class*="left"] .ant-menu-item, [class*="biz"] li',
'.ant-layout-sider .ant-menu-item, .ant-layout-sider .ant-list-item, .ant-layout-sider li[role="menuitem"]',
);
}
......
......@@ -32,8 +32,12 @@ export class SurgerySharedLocators {
.filter({ hasText: /^\s*手术套包设置\s*$/ })
.first();
this.leftTree = page.locator('.ant-tree').first();
this.treeSearchInput = page.locator('.ant-tree ~ * input, .ant-tree input').first();
/** 术种/套包左栏:现网为 xc-layout 分类列表(ul.type-ul),旧版为 ant-tree。 */
this.leftTree = page.locator('.operation-left, .xc-layout-lr-left, .ant-tree').first();
this.treeSearchInput = page
.locator('.operation-left input[placeholder*="分类"], .xc-layout-lr-left input[placeholder*="分类"]')
.first()
.or(page.locator('.ant-tree ~ * input, .ant-tree input').first());
this.tableHeader = page.locator('.ant-table-thead th');
this.tableBody = page.locator('.ant-table-tbody');
......@@ -58,15 +62,16 @@ export class SurgerySharedLocators {
.first();
}
/** 同组页签(URL 型 rc-tabs 或文本 Tab 均覆盖)。 */
/** 同组页签(顶部 tabs-view,避免与侧栏菜单同名冲突)。 */
tab(name: string): Locator {
const pattern = new RegExp(name);
return this.page
const compact = name.replace(/\s/g, '');
const pattern = new RegExp(`^\\s*${compact.split('').join('\\s*')}\\s*$`);
const tablist = this.page.locator('.tabs-view .ant-tabs-nav, .ant-tabs-nav').first();
return tablist
.locator('.ant-tabs-tab')
.filter({ hasText: pattern })
.first()
.or(this.page.getByRole('tab', { name: pattern }).first())
.or(this.page.getByText(pattern).first());
.or(tablist.getByRole('tab', { name: pattern }).first());
}
button(name: string, root: Locator | Page = this.page): Locator {
......@@ -75,13 +80,20 @@ export class SurgerySharedLocators {
return root.getByRole('button', { name: pattern });
}
/** 树节点:按可见文本包含匹配。 */
/** 树/分类节点:按可见文本包含匹配(兼容 ant-tree 与 type-ul)。 */
treeNode(text: string): Locator {
return this.leftTree.locator('.ant-tree-node-content-wrapper').filter({ hasText: text }).first();
const pattern = new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return this.page
.locator('ul.type-ul li.type-li')
.filter({ hasText: pattern })
.first()
.or(this.leftTree.locator('.ant-tree-node-content-wrapper').filter({ hasText: pattern }).first());
}
treeNodes(): Locator {
return this.leftTree.locator('.ant-tree-node-content-wrapper');
return this.page
.locator('ul.type-ul li.type-li')
.or(this.leftTree.locator('.ant-tree-node-content-wrapper'));
}
/** 弹窗内表单控件按 id 定位(CSV 明确给出 #form_item_xxx / #basic_kindName)。 */
......
import { expect, type Page } from '@playwright/test';
import { admitData } from '../../data/admit.data';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { AdmitSharedPage } from './admit-shared.page';
/** 物料准入单列表。详情里的「通过」只取消,不确认审批。 */
......@@ -20,12 +21,45 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
async filterByOrderOrFallback(): Promise<void> {
await this.query();
await this.queryByFirstAvailable([
{ column: '准入单号', filter: '准入单号', kind: 'input' },
{ column: '注册证号', filter: '注册证号', kind: 'input' },
{ column: '产品名称', filter: '产品名称', kind: 'input' },
{ column: '器械注册人', filter: '器械注册人', kind: 'input' },
]);
const orderNo = await this.sampleMaterialOrderNo();
if (!orderNo) {
await this.guardedSkip('首行无可用准入单号样本');
return;
}
await this.ensureFiltersExpanded();
for (const label of ['准入单号', '单号']) {
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fillFilter(label, orderNo.slice(0, 40));
await this.query();
await this.expectRowsContainKeyword(orderNo);
return;
}
}
const row = await this.firstRow();
const pairs: Array<{ column: string; filters: string[] }> = [
{ column: '注册证号', filters: ['注册证号'] },
{ column: '产品名称', filters: ['物料名称', '产品名称'] },
{ column: '物料名称', filters: ['物料名称'] },
{ column: '器械注册人', filters: ['器械注册人'] },
{ column: '进院供应商', filters: ['供应商', '销售商'] },
];
for (const pair of pairs) {
const value = (await this.tryCell(row, pair.column)).trim();
if (!value || value === '--' || value === '-') continue;
for (const filter of pair.filters) {
const input = this.textInput(filter);
if (await input.isVisible().catch(() => false)) {
await this.fillFilter(filter, value.slice(0, 40));
await this.query();
await this.expectRowsContain(pair.column, value);
return;
}
}
}
// 现网常无「准入单号」筛选项且首行仅单号有值:保持默认日期范围查询,断言列表仍含该单号(见用例 MD 备注路径)。
await this.query();
await this.expectRowsContainKeyword(orderNo);
}
async resetFilters(): Promise<void> {
......@@ -37,14 +71,16 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
}
async mockEmpty() {
const cert = this.textInput('注册证号');
if (await cert.isVisible().catch(() => false)) {
await this.fillFilter('注册证号', admitData.mockCert);
} else {
await this.fillFilter('产品名称', admitData.mockGoods);
}
await this.query();
return this.snapshotList();
return this.runMockEmptyQuery(admitData.mockGoods, async () => {
await this.ensureFiltersExpanded();
const cert = this.textInput('注册证号');
if (await cert.isVisible().catch(() => false)) {
await this.fillFilter('注册证号', admitData.mockCert);
} else {
await this.fillProductNameFilter(admitData.mockGoods);
}
await this.query();
});
}
async coreHeaders(): Promise<string[]> {
......@@ -54,7 +90,7 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
async switchPassed(): Promise<void> {
await this.clickTabAndMatchCount('已通过');
await expect(this.tab('已通过')).toHaveClass(/ant-tabs-tab-active/);
await this.expectTabActive('已通过');
}
async detailAndBack(): Promise<void> {
......@@ -102,15 +138,19 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
async detailSearchMock(): Promise<void> {
await this.query();
await this.openPutFromRow(await this.firstRow(), '详情');
const input = this.putInput('产品名称');
const target = (await input.isVisible().catch(() => false)) ? input : this.put().locator('input').first();
await this.fill(target, admitData.mockGoods, '详情搜索 mock');
const query = this.button('查询', this.put()).first();
if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询');
await this.waitForTableReady();
const empty = await this.put().getByText('暂无数据').first().isVisible().catch(() => false);
const rows = await this.put().locator('.ant-table-tbody > tr.ant-table-row').count();
expect(empty || rows === 0, '详情无匹配应空态').toBeTruthy();
await runWithMockEmptyListRoute(this.page, admitData.mockGoods, async () => {
const input = this.putInput('产品名称').or(this.putInput('物料名称'));
const target = (await input.first().isVisible().catch(() => false))
? input.first()
: this.put().locator('input').first();
await this.fill(target, admitData.mockGoods, '详情搜索 mock');
const query = this.button('查询', this.put()).first();
if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询');
await this.waitForTableReady();
const empty = await this.put().getByText('暂无数据').first().isVisible().catch(() => false);
const rows = await this.put().locator('.ant-table-tbody > tr.ant-table-row').count();
expect(empty || rows === 0, '详情无匹配应空态').toBeTruthy();
});
await this.leaveWithoutCommit();
}
......
......@@ -21,13 +21,14 @@ export class AdmitMaterialPage extends AdmitSharedPage {
async filterByProductName(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 40));
await this.fillProductNameFilter(name.slice(0, 40));
await this.query();
await this.expectRowsContain('产品名称', name);
}
async filterByGoodsType(): Promise<void> {
await this.query();
await this.ensureFiltersExpanded();
const type = await this.sampleCell('耗材类型');
await this.chooseSelect('耗材类型', type);
await this.query();
......@@ -38,7 +39,7 @@ export class AdmitMaterialPage extends AdmitSharedPage {
await this.query();
const name = await this.sampleCell('产品名称');
const type = await this.sampleCell('耗材类型');
await this.fillFilter('产品名称', name.slice(0, 40));
await this.fillProductNameFilter(name.slice(0, 40));
await this.chooseSelect('耗材类型', type);
await this.query();
await this.expectRowsContain('产品名称', name);
......@@ -48,7 +49,7 @@ export class AdmitMaterialPage extends AdmitSharedPage {
async resetFilters(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 20));
await this.fillProductNameFilter(name.slice(0, 20));
await this.reset();
await this.expectInputCleared('产品名称');
await this.expectListOrEmpty();
......@@ -61,9 +62,10 @@ export class AdmitMaterialPage extends AdmitSharedPage {
}
async mockEmptyName() {
await this.fillFilter('产品名称', admitData.mockGoods);
await this.query();
return this.snapshotList();
return this.runMockEmptyQuery(admitData.mockGoods, async () => {
await this.fillProductNameFilter(admitData.mockGoods);
await this.query();
});
}
async coreHeaders(): Promise<string[]> {
......@@ -73,7 +75,7 @@ export class AdmitMaterialPage extends AdmitSharedPage {
async switchPending(): Promise<void> {
await this.clickTab('待申请');
await expect(this.tab('待申请')).toHaveClass(/ant-tabs-tab-active/);
await this.expectTabActive('待申请');
await this.expectListOrEmpty();
}
......@@ -161,7 +163,8 @@ export class AdmitMaterialPage extends AdmitSharedPage {
async relateCancel(): Promise<void> {
await this.query();
const total = await this.getTotalCount();
const supplier = await this.cellText(await this.firstRow(), '默认进院供应商');
const row = await this.checkFirstRow();
const supplier = await this.cellText(row, '默认进院供应商');
await this.clickToolbarThenCancel('关联产品');
expect(await this.getTotalCount(), '取消关联后总数不变').toBe(total);
expect(await this.cellText(await this.firstRow(), '默认进院供应商')).toBe(supplier);
......
......@@ -21,7 +21,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
async filterByProductName(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 40));
await this.fillProductNameFilter(name.slice(0, 40));
await this.query();
await this.expectRowsContain('产品名称', name);
}
......@@ -30,7 +30,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
await this.query();
const name = await this.sampleCell('产品名称');
const type = await this.sampleCell('耗材类型');
await this.fillFilter('产品名称', name.slice(0, 40));
await this.fillProductNameFilter(name.slice(0, 40));
await this.chooseSelect('耗材类型', type);
await this.query();
await this.expectRowsContain('产品名称', name);
......@@ -39,16 +39,17 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
async resetFilters(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 20));
await this.fillProductNameFilter(name.slice(0, 20));
await this.reset();
await this.expectInputCleared('产品名称');
await this.expectListOrEmpty();
}
async mockEmpty() {
await this.fillFilter('产品名称', admitData.mockGoods);
await this.query();
return this.snapshotList();
return this.runMockEmptyQuery(admitData.mockGoods, async () => {
await this.fillProductNameFilter(admitData.mockGoods);
await this.query();
});
}
async coreHeaders(): Promise<string[]> {
......@@ -58,7 +59,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
async switchPending(): Promise<void> {
await this.clickTab('待申请');
await expect(this.tab('待申请')).toHaveClass(/ant-tabs-tab-active/);
await this.expectTabActive('待申请');
await this.expectListOrEmpty();
}
......@@ -76,9 +77,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
const total = await this.getTotalCount();
await this.clickButton('新增产品');
await expect(this.put(), '新增产品页').toBeVisible();
for (const label of ['产品名称', '器械注册人', '规格型号']) {
await expect(this.put().getByText(label).first(), label).toBeVisible();
}
await this.expectPutFieldLabels('产品名称', '器械注册人', '规格型号');
await this.leaveWithoutCommit();
expect(await this.getTotalCount()).toBe(total);
}
......@@ -88,9 +87,9 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
const name = uniqProductName();
await this.clickButton('新增产品');
await expect(this.put()).toBeVisible();
await this.fill(this.putInput('产品名称'), name, '填写新产品名称');
await this.fill(this.putProductNameInput(), name, '填写新产品名称');
await this.leaveWithoutCommit();
await this.fillFilter('产品名称', name);
await this.fillProductNameFilter(name);
await this.query();
await this.expectMockEmpty();
await this.reset();
......@@ -110,7 +109,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
const total = await this.getTotalCount();
await this.clickButton('新增产品');
await expect(this.put()).toBeVisible();
await this.fill(this.putInput('产品名称'), '', '产品名称保持空');
await this.fill(this.putProductNameInput(), '', '产品名称保持空');
await this.saveOnPut();
await this.expectBlockedAndStay();
await this.leaveWithoutCommit();
......@@ -131,7 +130,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
const row = await this.skipIfNoAction('编辑', '待申请无编辑');
const name = await this.cellText(row, '产品名称');
await this.openPutFromRow(row, '编辑');
await expect(this.putInput('产品名称')).toHaveValue(name);
await expect(this.putProductNameInput()).toHaveValue(name);
await this.leaveWithoutCommit();
}
......@@ -145,7 +144,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
await this.clickTab('待申请');
const again = await this.skipIfNoAction('编辑', '待申请无编辑');
await this.openPutFromRow(again, '编辑');
await expect(this.putInput('产品名称')).toHaveValue(name);
await expect(this.putProductNameInput()).toHaveValue(name);
await this.leaveWithoutCommit();
}
......
import { expect, type Page } from '@playwright/test';
import { admitData } from '../../data/admit.data';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { AdmitSharedPage } from './admit-shared.page';
/** 产品准入单列表。与物料单列表 URL 分离;通过只取消。 */
......@@ -37,9 +38,12 @@ export class AdmitProductListPage extends AdmitSharedPage {
async filterByProductOrFallback(): Promise<void> {
await this.query();
await this.queryByFirstAvailable([
{ column: '产品名称', filter: '物料名称', kind: 'input' },
{ column: '产品名称', filter: '产品名称', kind: 'input' },
{ column: '物料名称', filter: '物料名称', kind: 'input' },
{ column: '注册证号', filter: '注册证号', kind: 'input' },
{ column: '器械注册人', filter: '器械注册人', kind: 'input' },
{ column: '进院供应商', filter: '供应商', kind: 'input' },
]);
}
......@@ -53,13 +57,14 @@ export class AdmitProductListPage extends AdmitSharedPage {
}
async mockEmpty() {
if (await this.textInput('产品名称').isVisible().catch(() => false)) {
await this.fillFilter('产品名称', admitData.mockGoods);
} else {
await this.fillFilter('注册证号', admitData.mockCert);
}
await this.query();
return this.snapshotList();
return this.runMockEmptyQuery(admitData.mockGoods, async () => {
if (await this.textInput('产品名称').isVisible().catch(() => false)) {
await this.fillProductNameFilter(admitData.mockGoods);
} else {
await this.fillFilter('注册证号', admitData.mockCert);
}
await this.query();
});
}
async coreHeaders(): Promise<string[]> {
......@@ -69,7 +74,7 @@ export class AdmitProductListPage extends AdmitSharedPage {
async switchPassed(): Promise<void> {
await this.clickTabAndMatchCount('已通过');
await expect(this.tab('已通过')).toHaveClass(/ant-tabs-tab-active/);
await this.expectTabActive('已通过');
}
async detailAndBack(): Promise<void> {
......@@ -100,15 +105,19 @@ export class AdmitProductListPage extends AdmitSharedPage {
async detailSearchMock(): Promise<void> {
await this.query();
await this.openPutFromRow(await this.firstRow(), '详情');
const input = this.putInput('产品名称');
const target = (await input.isVisible().catch(() => false)) ? input : this.put().locator('input').first();
await this.fill(target, admitData.mockGoods, '详情搜索 mock');
const query = this.button('查询', this.put()).first();
if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询');
await this.waitForTableReady();
const empty = await this.put().getByText('暂无数据').first().isVisible().catch(() => false);
const rows = await this.put().locator('.ant-table-tbody > tr.ant-table-row').count();
expect(empty || rows === 0, '详情无匹配应空态').toBeTruthy();
await runWithMockEmptyListRoute(this.page, admitData.mockGoods, async () => {
const input = this.putInput('产品名称').or(this.putInput('物料名称'));
const target = (await input.first().isVisible().catch(() => false))
? input.first()
: this.put().locator('input').first();
await this.fill(target, admitData.mockGoods, '详情搜索 mock');
const query = this.button('查询', this.put()).first();
if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询');
await this.waitForTableReady();
const empty = await this.put().getByText('暂无数据').first().isVisible().catch(() => false);
const rows = await this.put().locator('.ant-table-tbody > tr.ant-table-row').count();
expect(empty || rows === 0, '详情无匹配应空态').toBeTruthy();
});
await this.leaveWithoutCommit();
}
......
......@@ -3,7 +3,16 @@ import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { AdmitSharedLocators } from '../../locators/spd/admit-shared.locator';
import { BasePage } from '../../core/base.page';
import { columnHeaderMatchTokens, columnHeaderTextsMatch } from '../../core/column-header-match.util';
import {
dataCellTextsFromRow,
dataHeaderLabelsFromCells,
normalizeSampleCellValue,
readRowCellByColumn,
resolveColumnIndexFromLabels,
} from '../../core/table-data-column.util';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
export type AdmitMeta = {
path: string;
......@@ -32,6 +41,15 @@ export class AdmitSharedPage extends BasePage {
await this.goto(this.meta.path);
await this.waitSpinGone();
await this.waitSettle(400);
const on404 = await this.page
.getByText(/页面不存在|抱歉.*不存在/)
.first()
.isVisible()
.catch(() => false);
if (on404) {
await this.openFromMenu();
return;
}
await this.assertUiReady(`打开 ${this.meta.path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
......@@ -46,7 +64,11 @@ export class AdmitSharedPage extends BasePage {
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!opened) await this.click(title, '展开准入管理');
await this.click(this.locators.menuLeaf(this.meta.menu), `进入${this.meta.menu}`);
await this.waitSettle(300);
const leaf = this.locators.menuLeaf(this.meta.menu);
await leaf.scrollIntoViewIfNeeded().catch(() => undefined);
await expect(leaf, `侧栏菜单「${this.meta.menu}」`).toBeVisible({ timeout: 20_000 });
await this.click(leaf, `进入${this.meta.menu}`);
await this.page.waitForURL((url) => url.pathname.includes(this.meta.pathPart), { timeout: 20_000 });
await this.waitSpinGone();
await this.waitSettle(400);
......@@ -58,8 +80,22 @@ export class AdmitSharedPage extends BasePage {
for (const part of this.meta.forbidden ?? []) {
expect(this.page.url(), `不应进入 ${part}`).not.toContain(part);
}
await expect(this.locators.button('查询').first(), '查询按钮').toBeVisible();
expect((await this.headerTexts()).length, '列表表头').toBeGreaterThan(0);
await expect(this.locators.button('查询').first(), '查询按钮').toBeVisible({ timeout: 20_000 });
await this.locators.tableHeader
.first()
.waitFor({ state: 'attached', timeout: 20_000 })
.catch(() => undefined);
const headers = await this.headerTexts();
expect(headers.length, '列表表头').toBeGreaterThan(0);
}
/** mock 反例:在 route 置空列表响应后再查询。 */
protected async runMockEmptyQuery(
mockNeedle: string,
fillAndQuery: () => Promise<void>,
): Promise<{ rows: number; total: number; emptyVisible: boolean }> {
await runWithMockEmptyListRoute(this.page, mockNeedle, fillAndQuery);
return this.snapshotList();
}
button(name: string, root?: Locator): Locator {
......@@ -84,8 +120,16 @@ export class AdmitSharedPage extends BasePage {
textInput(label: string, root?: Locator): Locator {
const scope = root ?? this.page.locator('body');
const byRole = scope.getByRole('textbox', { name: label }).first();
return byRole.or(scope.locator('.ant-form-item').filter({ hasText: label }).locator('input').first());
const compact = label.replace(/\s/g, '');
const labelPattern = new RegExp(compact.split('').join('\\s*'));
const byRole = scope.getByRole('textbox', { name: labelPattern }).first();
const byForm = scope
.locator('.ant-form-item')
.filter({ hasText: labelPattern })
.locator('input:not([type="hidden"])')
.first();
const byPlaceholder = scope.getByPlaceholder(labelPattern).first();
return byRole.or(byForm).or(byPlaceholder);
}
formSelect(label: string, root?: Locator): Locator {
......@@ -93,14 +137,39 @@ export class AdmitSharedPage extends BasePage {
return scope.locator('.ant-form-item').filter({ hasText: label }).locator('.ant-select').first();
}
async ensureFiltersExpanded(): Promise<void> {
const expand = this.button('展开').first();
if (await expand.isVisible().catch(() => false)) {
await this.click(expand, '展开筛选');
await this.waitSettle(200);
}
}
async fillFilter(label: string, value: string): Promise<void> {
await this.ensureFiltersExpanded();
const input = this.textInput(label);
await expect(input, `筛选「${label}」`).toBeVisible();
await expect(input, `筛选「${label}」`).toBeVisible({ timeout: 8_000 });
await this.fill(input, value, `填写${label}`);
}
/** 现网筛选项可能是「物料名称/物资名称」而非「产品名称」。 */
async fillProductNameFilter(value: string): Promise<void> {
await this.ensureFiltersExpanded();
const labels = ['产品名称', '物资名称', '物料名称', '产品简称'];
for (const label of labels) {
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fill(input, value, `填写${label}`);
return;
}
}
expect(false, 'mock/筛选:未找到产品或物料名称输入框').toBeTruthy();
}
async chooseSelect(label: string, optionText: string, root?: Locator): Promise<void> {
await this.ensureFiltersExpanded();
const select = this.formSelect(label, root);
await select.scrollIntoViewIfNeeded().catch(() => undefined);
await this.click(select, `打开${label}`);
const dropdown = this.page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').last();
const keyword = optionText.replace(/\s/g, '').slice(0, 20);
......@@ -137,9 +206,33 @@ export class AdmitSharedPage extends BasePage {
return matched ? Number(matched[1]) : null;
}
async expectTabActive(name: string): Promise<void> {
const tab = this.tab(name);
await expect(tab, `${name} Tab`).toBeVisible({ timeout: 15_000 });
await expect
.poll(
async () => {
const cls = `${(await tab.getAttribute('class')) ?? ''} ${await tab
.evaluate((el) => {
const host = (el as HTMLElement).closest(
'.ant-tabs-tab, .ant-radio-button-wrapper, .ant-segmented-item',
);
return host?.className ?? '';
})
.catch(() => '')}`;
const aria = await tab.getAttribute('aria-selected');
return /active|checked|selected/i.test(cls) || aria === 'true';
},
{ timeout: 8_000 },
)
.toBeTruthy();
}
async clickTab(name: string): Promise<void> {
const tab = this.tab(name);
await expect(tab, `${name} Tab`).toBeVisible({ timeout: 15_000 });
await this.assertBeforeBusinessAction(`切换${name}`);
await this.click(this.tab(name), `切换${name}`);
await this.click(tab, `切换${name}`);
await this.waitForTableReady();
}
......@@ -151,32 +244,51 @@ export class AdmitSharedPage extends BasePage {
const groups = this.page.locator('.ant-table-thead');
const count = await groups.count();
let best = groups.first().locator('th');
let bestLen = 0;
let bestScore = -1;
for (let i = 0; i < count; i += 1) {
const cells = groups.nth(i).locator('th');
const len = await cells.count();
if (len > bestLen) {
bestLen = len;
const group = groups.nth(i);
const insidePut = await group.evaluate((el) => Boolean(el.closest('.put-page'))).catch(() => false);
if (insidePut) continue;
const cells = group.locator('th');
const labels = await dataHeaderLabelsFromCells(cells);
const named = labels.filter((l) => l.length > 0).length;
const score = named * 100 + labels.length;
if (score > bestScore) {
bestScore = score;
best = cells;
}
}
return best;
}
/** 与 tbody 数据列对齐的表头(排除选择列/展开列)。 */
async dataHeaderLabels(): Promise<string[]> {
return dataHeaderLabelsFromCells(await this.headerCells());
}
async headerTexts(): Promise<string[]> {
return (await (await this.headerCells()).allTextContents()).map((item) => item.replace(/\s/g, ''));
return this.dataHeaderLabels();
}
async expectHeaders(names: string[]): Promise<void> {
const text = (await this.headerTexts()).join(' ');
const texts = await this.dataHeaderLabels();
const joined = texts.join('');
for (const name of names) {
expect(text, `表头应含「${name}」`).toContain(name.replace(/\s/g, ''));
if (name === '操作') {
const opHeader = texts.some((h) => h.includes('操作'));
const rows = await this.listRows();
const opBtn = (await rows.first().getByRole('button').count().catch(() => 0)) > 0;
expect(opHeader || opBtn || joined.includes('操作'), `表头应含「${name}」(或别名)`).toBeTruthy();
continue;
}
const hit = texts.some((header) => columnHeaderTextsMatch(header, name));
expect(hit, `表头应含「${name}」(或别名)`).toBeTruthy();
}
}
async headerIndex(name: string): Promise<number> {
const texts = await this.headerTexts();
const index = texts.findIndex((item) => item.includes(name.replace(/\s/g, '')));
const texts = await this.dataHeaderLabels();
const index = resolveColumnIndexFromLabels(texts, name);
expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0);
return index;
}
......@@ -217,8 +329,8 @@ export class AdmitSharedPage extends BasePage {
}
async cellText(row: Locator, header: string): Promise<string> {
const index = await this.headerIndex(header);
return ((await row.locator('td').nth(index).innerText()) ?? '').trim();
const labels = await this.dataHeaderLabels();
return readRowCellByColumn(row, labels, header);
}
async tryCell(row: Locator, header: string): Promise<string> {
......@@ -235,6 +347,24 @@ export class AdmitSharedPage extends BasePage {
return row;
}
/** 首条可勾选行(跳过 checkbox 禁用的行)。 */
async firstCheckableRow(): Promise<Locator> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 30);
for (let i = 0; i < count; i += 1) {
const row = rows.nth(i);
const wrapper = row.locator('.ant-checkbox-wrapper').first();
if ((await wrapper.count()) === 0) continue;
const cls = ((await wrapper.getAttribute('class')) ?? '').toLowerCase();
if (cls.includes('disabled')) continue;
const input = wrapper.locator('input.ant-checkbox-input');
if ((await input.count()) > 0 && (await input.isDisabled().catch(() => true))) continue;
return row;
}
await this.skipWhenDataInsufficient('列表无可用勾选行');
return rows.first();
}
/** 列表快照供用例层断言(不在此 expect)。 */
async snapshotList(): Promise<{ rows: number; total: number; emptyVisible: boolean }> {
await this.waitForTableReady();
......@@ -289,12 +419,41 @@ export class AdmitSharedPage extends BasePage {
}
}
/** 表头缺失或列名对不齐时,断言前几行任意列包含关键字(物料单号筛选等)。 */
async expectRowsContainKeyword(keyword: string): Promise<void> {
const needle = keyword.replace(/\s/g, '').slice(0, 24);
const rows = await this.listRows();
const count = await rows.count();
expect(count, '查询应有结果').toBeGreaterThan(0);
const limit = Math.min(count, 5);
let hit = false;
for (let i = 0; i < limit; i += 1) {
const cells = await dataCellTextsFromRow(rows.nth(i));
const joined = cells.join(' ').replace(/\s/g, '');
if (joined.includes(needle)) hit = true;
}
expect(hit, `前 ${limit} 行至少一行应包含关键字`).toBeTruthy();
}
/** 物料准入单列表:表头常被折叠为空时,单号多在第 3 个数据列。 */
async sampleMaterialOrderNo(): Promise<string> {
const row = await this.firstRow();
const fromHeader = normalizeSampleCellValue(await this.tryCell(row, '准入单号'));
if (fromHeader) return fromHeader;
const cells = await dataCellTextsFromRow(row);
const dataCells = cells.filter((c) => c && c !== '--' && c !== '-');
const orderLike = dataCells.find((c) => /^(CP|ZR|AD|MIT)/i.test(c.replace(/\s/g, '')));
if (orderLike) return orderLike.trim();
const indexGuess = normalizeSampleCellValue(cells[2] ?? '');
return indexGuess;
}
async sampleCell(header: string): Promise<string> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 8);
for (let i = 0; i < count; i += 1) {
const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, '');
if (text && text !== '--' && text !== '-') return text;
const text = normalizeSampleCellValue(await this.tryCell(rows.nth(i), header));
if (text) return text;
}
await this.skipWhenDataInsufficient(`首屏「${header}」为空`);
return '';
......@@ -304,21 +463,37 @@ export class AdmitSharedPage extends BasePage {
async queryByFirstAvailable(
pairs: Array<{ column: string; filter: string; kind: 'input' | 'select' }>,
): Promise<void> {
await this.ensureFiltersExpanded();
const row = await this.firstRow();
for (const pair of pairs) {
const value = (await this.tryCell(row, pair.column)).trim();
if (!value || value === '--' || value === '-') continue;
if (pair.kind === 'input' && (await this.textInput(pair.filter).isVisible().catch(() => false))) {
await this.fillFilter(pair.filter, value.slice(0, 40));
await this.query();
await this.expectRowsContain(pair.column, value);
return;
const filterLabels = [
pair.filter,
...columnHeaderMatchTokens(pair.filter),
...columnHeaderMatchTokens(pair.column),
].filter((label, index, arr) => label && arr.indexOf(label) === index);
if (pair.kind === 'input') {
for (const label of filterLabels) {
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fillFilter(label, value.slice(0, 40));
await this.query();
await this.expectRowsContain(pair.column, value);
return;
}
}
}
if (pair.kind === 'select' && (await this.formSelect(pair.filter).isVisible().catch(() => false))) {
await this.chooseSelect(pair.filter, value);
await this.query();
await this.expectRowsContain(pair.column, value);
return;
if (pair.kind === 'select') {
for (const label of filterLabels) {
const select = this.formSelect(label);
if (await select.isVisible().catch(() => false)) {
await this.chooseSelect(label, value);
await this.query();
await this.expectRowsContain(pair.column, value);
return;
}
}
}
}
await this.guardedSkip('筛选区没有与首行样本对应的条件');
......@@ -469,8 +644,11 @@ export class AdmitSharedPage extends BasePage {
}
async expectBlockedAndStay(): Promise<void> {
const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|请先/).first();
await expect(this.locators.formError.first().or(hint), '应出现必填或非法提示').toBeVisible({ timeout: 8_000 });
const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|请先/);
const errorHint = this.locators.formError.or(hint).first();
await expect(errorHint, '应出现必填或非法提示').toBeVisible({
timeout: 8_000,
});
const stayed =
(await this.page.locator('.put-page:visible').count()) > 0 ||
(await this.page.locator('.ant-modal:visible').count()) > 0;
......@@ -487,9 +665,30 @@ export class AdmitSharedPage extends BasePage {
}
async checkFirstRow(): Promise<Locator> {
const row = await this.firstRow();
const box = row.locator('.ant-checkbox').first();
await this.click(box, '勾选首行');
const row = await this.firstCheckableRow();
await row.scrollIntoViewIfNeeded().catch(() => undefined);
const input = row.locator('input.ant-checkbox-input').first();
const wrapper = row.locator('.ant-checkbox-wrapper').first();
if ((await wrapper.count()) > 0) {
await this.click(wrapper, '勾选首行');
} else if ((await input.count()) > 0) {
await this.click(input.locator('xpath=ancestor::label[1]').or(input), '勾选首行');
} else {
await this.click(row.locator('.ant-checkbox').first(), '勾选首行');
}
if ((await input.count()) > 0) {
await expect
.poll(async () => input.isChecked().catch(() => false), { timeout: 8_000 })
.toBe(true);
} else {
await expect
.poll(async () => {
const cls = (await row.getAttribute('class')) ?? '';
return cls.includes('ant-table-row-selected');
}, { timeout: 8_000 })
.toBe(true);
}
await this.waitSettle(150);
return row;
}
......@@ -536,6 +735,25 @@ export class AdmitSharedPage extends BasePage {
return this.textInput(label, this.put());
}
/** 详情/编辑层:产品名称字段现网常显示为「物料名称」。 */
putProductNameInput(): Locator {
return this.putInput('产品名称').or(this.putInput('物料名称')).first();
}
async expectPutFieldLabels(...labels: string[]): Promise<void> {
for (const label of labels) {
const tokens = columnHeaderMatchTokens(label);
let hit = false;
for (const token of tokens) {
if (await this.put().getByText(new RegExp(token)).first().isVisible().catch(() => false)) {
hit = true;
break;
}
}
expect(hit, `${label} 应在编辑层展示(或别名)`).toBeTruthy();
}
}
async saveOnPut(): Promise<void> {
await this.assertBeforeBusinessAction('保存');
const save = this.button('保存', this.put()).first();
......
......@@ -35,13 +35,15 @@ export class AdmitSupplyPage extends AdmitSharedPage {
}
async mockEmpty() {
if (await this.textInput('变更单号').isVisible().catch(() => false)) {
await this.fillFilter('变更单号', admitData.mockOrder);
} else {
await this.fillFilter('产品名称', admitData.mockGoods);
}
await this.query();
return this.snapshotList();
const needle = admitData.mockOrder;
return this.runMockEmptyQuery(needle, async () => {
if (await this.textInput('变更单号').isVisible().catch(() => false)) {
await this.fillFilter('变更单号', admitData.mockOrder);
} else {
await this.fillProductNameFilter(admitData.mockGoods);
}
await this.query();
});
}
async coreHeaders(): Promise<string[]> {
......@@ -51,7 +53,7 @@ export class AdmitSupplyPage extends AdmitSharedPage {
async switchPending(): Promise<void> {
await this.clickTabAndMatchCount('待提交');
await expect(this.tab('待提交')).toHaveClass(/ant-tabs-tab-active/);
await this.expectTabActive('待提交');
}
async openAddFields(): Promise<void> {
......
......@@ -21,7 +21,7 @@ export class AdmitTaskPage extends AdmitSharedPage {
async filterByProductName(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 40));
await this.fillProductNameFilter(name.slice(0, 40));
await this.query();
await this.expectRowsContain('产品名称', name);
}
......@@ -30,22 +30,23 @@ export class AdmitTaskPage extends AdmitSharedPage {
await this.clickTabAndMatchCount('待分类');
await this.clickTabAndMatchCount('已分类');
await this.clickTab('待分类');
await expect(this.tab('待分类')).toHaveClass(/ant-tabs-tab-active/);
await this.expectTabActive('待分类');
}
async resetFilters(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 20));
await this.fillProductNameFilter(name.slice(0, 20));
await this.reset();
await this.expectInputCleared('产品名称');
await this.expectListOrEmpty();
}
async mockEmpty() {
await this.fillFilter('产品名称', admitData.mockGoods);
await this.query();
return this.snapshotList();
return this.runMockEmptyQuery(admitData.mockGoods, async () => {
await this.fillProductNameFilter(admitData.mockGoods);
await this.query();
});
}
async coreHeaders(): Promise<string[]> {
......
......@@ -24,34 +24,27 @@ export class BasicRolePage extends BasicUserSharedPage {
}
leftRoleItems(): Locator {
return this.page.locator(
'.ant-layout-sider .ant-table-tbody tr.ant-table-row, .ant-layout-sider .ant-list-item, .ant-layout-sider .ant-menu-item, [class*="role"] .ant-table-row',
);
return this.locators.roleSider.getByRole('listitem');
}
async leftRoleNames(): Promise<string[]> {
const items = this.leftRoleItems();
await items.first().waitFor({ state: 'visible', timeout: 15_000 }).catch(() => undefined);
const count = await items.count();
if (count > 0) {
const names: string[] = [];
for (let i = 0; i < Math.min(count, 30); i += 1) {
const text = ((await items.nth(i).innerText()) ?? '').split('\n')[0].trim();
if (text) names.push(text);
}
if (names.length) return names;
}
const fallback = this.page.locator('.ant-table-tbody tr.ant-table-row td').first();
if (await fallback.isVisible().catch(() => false)) {
return (await this.page.locator('.ant-table-tbody tr.ant-table-row td:first-child').allTextContents())
.map((t) => t.trim())
.filter(Boolean);
const names: string[] = [];
for (let i = 0; i < Math.min(count, 40); i += 1) {
const text = ((await items.nth(i).innerText()) ?? '').split('\n')[0].trim();
if (text && text !== '角色') names.push(text);
}
return [];
return names;
}
async sampleRoleName(): Promise<string> {
const names = await this.leftRoleNames();
const found = names.find((n) => n && n !== '角色');
const found =
names.find((n) => /管理员|库管|护士/.test(n)) ??
names.find((n) => n.length >= 2 && !/^\d+$/.test(n)) ??
names[0];
if (!found) {
await this.skipWhenDataInsufficient('左栏无角色');
return '';
......@@ -60,12 +53,7 @@ export class BasicRolePage extends BasicUserSharedPage {
}
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());
return this.locators.roleSider;
}
async assertLeftRoleListVerticalScroll(): Promise<void> {
......@@ -164,12 +152,9 @@ export class BasicRolePage extends BasicUserSharedPage {
}
async selectRole(name: string): Promise<void> {
const item = this.leftRoleItems().filter({ hasText: name }).first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, `选中角色 ${name}`);
} else {
await this.click(this.page.getByText(name, { exact: true }).first(), `选中角色 ${name}`);
}
const item = this.leftRoleItems().filter({ hasText: new RegExp(`^\\s*${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`) }).first();
await this.click(item, `选中角色 ${name}`);
await expect(this.locators.roleTree, '选中角色后权限树应可见').toBeVisible({ timeout: 15_000 });
await this.waitSpinGone();
await this.waitSettle(400);
}
......@@ -292,10 +277,7 @@ export class BasicRolePage extends BasicUserSharedPage {
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();
return row.locator('img[alt="form"], .anticon-form, .anticon-edit').first();
}
normalizeRoleLabel(text: string): string {
......@@ -397,7 +379,10 @@ export class BasicRolePage extends BasicUserSharedPage {
}
addRoleDialog(): Locator {
return this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last();
return this.page
.locator('.ant-modal, .ant-drawer-content')
.filter({ has: this.page.getByText(/新\s*增\s*角\s*色/) })
.last();
}
async openAddRoleDialog(): Promise<Locator> {
......@@ -425,20 +410,28 @@ export class BasicRolePage extends BasicUserSharedPage {
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());
return dialog.locator('button.ant-modal-close, .ant-drawer-close').first();
}
addRoleDialogWrap(): Locator {
return this.page.locator('.ant-modal-wrap').filter({ hasText: /新\s*增\s*角\s*色/ }).last();
}
async closeAddRoleDialogByX(): Promise<void> {
const dialog = this.addRoleDialog();
await expect(dialog, '新增弹框应可见').toBeVisible();
const close = this.addRoleDialogCloseIcon();
const wrap = this.addRoleDialogWrap();
await expect(wrap, '新增弹框应可见').toBeVisible();
const close = wrap.locator('button.ant-modal-close').first();
await expect(close, '右上角 X 关闭').toBeVisible();
await this.click(close, '点击新增弹框 X 关闭');
await expect(dialog, 'X 关闭后弹框应隐藏').toBeHidden({ timeout: 8_000 });
if (await wrap.isVisible().catch(() => false)) {
await close.evaluate((node) => {
if (node instanceof HTMLElement) node.click();
});
}
if (await wrap.isVisible().catch(() => false)) {
await this.page.keyboard.press('Escape');
}
await expect(wrap, 'X 关闭后弹框应隐藏').toBeHidden({ timeout: 8_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertUiReady('X 关闭新增角色弹框后');
}
......
import { expect, type Locator, type Page } from '@playwright/test';
import { userData, uniqLogin } from '../../data/user.data';
import { COLUMN_SETTINGS_TRIGGER_CSS } from '../../locators/spd/center-report-shared.locator';
import { BasicUserLocators } from '../../locators/spd/basic-user.locator';
import { BasicUserSharedPage, type UserMeta } from './basic-user-shared.page';
import {
......@@ -8,6 +9,7 @@ import {
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
} from './helpers/column-settings.helper';
import { failWhen } from './helpers/guarded-skip.helper';
export type UserSample = {
sampleUserName: string;
......@@ -243,9 +245,17 @@ export class BasicUserPage extends BasicUserSharedPage {
async clickAuthAddRow(): Promise<void> {
const layer = this.userRoleAuthLayer();
const add = layer.getByRole('button', { name: /^添\s*加$/ }).first();
const add = layer
.getByRole('button', { name: /^添\s*加$/ })
.or(layer.getByRole('button', { name: /新\s*增/ }))
.or(layer.locator('button').filter({ has: layer.locator('.anticon-plus') }))
.first();
if (!(await add.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('授权弹框无「添加」按钮(可能为权限树模式)');
await failWhen(
this.browserPage,
true,
'授权弹框无「添加/新增」按钮,请核对页面是否为表格授权模式',
);
}
await this.click(add, '添加');
await this.waitSettle(400);
......@@ -276,7 +286,15 @@ export class BasicUserPage extends BasicUserSharedPage {
}
private async resolveColumnSettingsTrigger(): Promise<Locator> {
const candidates = [this.locators.columnSettingsTrigger];
const triggers = this.page.locator(COLUMN_SETTINGS_TRIGGER_CSS);
const triggerCount = await triggers.count();
const candidates: Locator[] = [];
for (let i = 0; i < Math.min(triggerCount, 4); i += 1) {
candidates.push(triggers.nth(i));
}
if (candidates.length === 0) {
candidates.push(this.locators.columnSettingsTrigger);
}
let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) {
await this.locators.tableWrapper.scrollIntoViewIfNeeded().catch(() => undefined);
......
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