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[])[] = [ ...@@ -8,11 +8,24 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [
['医保单件名称', '单件名称', '医保名称'], ['医保单件名称', '单件名称', '医保名称'],
['HIS编码', 'HIS码', 'His编码'], ['HIS编码', 'HIS码', 'His编码'],
['结算单标签', '标签'], ['结算单标签', '标签'],
['产品编号', '产品编码', '物资编码', '商品编码'], ['产品编号', '产品编码', '物资编码', '商品编码', '物料编号'],
['产品名称', '物资名称', '商品名称'], ['产品名称', '物资名称', '商品名称', '产品俗称', '物料名称'],
['规格型号', '规格'], ['规格型号', '规格', '产品规格'],
['产品批号', '批号'], ['产品批号', '批号'],
['进院供应商', '进院供应商名称'], ['进院供应商', '进院供应商名称', '默认进院供应商', '供应商名称', '供应商', '销售商', '销售商名称'],
['单号', '变更单号', '申请单号', '授权单号'],
['方案编码', '评价方案编码'],
['方案名称', '评价方案名称'],
['产品条数', '产品数量', '条数'],
['准入单号', '准入单编号', '单号'],
['耗材类型', '物资类型'],
['操作', '操作栏', '操作列'],
['序号', '行号'],
['注册证号(新)', '注册证号', '新注册证号'],
['启用云仓', '云仓', '是否启用云仓'],
['二级供应商名称', '二级供应商', '供应商名称'],
['院区', '院区名称'],
['产品类型', '耗材类型', '物资类型'],
]; ];
function aliasTokensFor(columnName: string): string[] { 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 & { ...@@ -48,7 +48,9 @@ export const anaProvData: CenterReportPageData & {
horizontalScrollTargetColumn: '进院供应商名称', horizontalScrollTargetColumn: '进院供应商名称',
horizontalScrollDragDelta: 320, horizontalScrollDragDelta: 320,
mockNotExistProduct: '__NOT_EXIST_SUPPLIER__99999', mockNotExistProduct: '__NOT_EXIST_SUPPLIER__99999',
mockFieldLabel: '进院供应商', /** 图表页快捷区常仅有「时间」,用未来区间反例空态。 */
mockFieldLabel: '时间',
mockPreferFutureDate: true,
drawerResetFieldLabel: '进院供应商名称', drawerResetFieldLabel: '进院供应商名称',
drawerResetFieldKey: 'supplierName', drawerResetFieldKey: 'supplierName',
......
...@@ -35,7 +35,22 @@ export class AdmitSharedLocators { ...@@ -35,7 +35,22 @@ export class AdmitSharedLocators {
} }
tab(name: string): Locator { 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 { putPage(): Locator {
......
...@@ -15,7 +15,10 @@ export class BasicBranchLocators extends BasicSharedLocators { ...@@ -15,7 +15,10 @@ export class BasicBranchLocators extends BasicSharedLocators {
constructor(page: Page) { constructor(page: Page) {
super(page); super(page);
this.addBtn = page.getByRole('button', { name: /新\s*增/ }); 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.addName = page.locator('#basic_name');
this.addAddress = page.locator('#basic_address'); this.addAddress = page.locator('#basic_address');
this.editId = page.locator('#form_item_id'); this.editId = page.locator('#form_item_id');
......
...@@ -14,7 +14,11 @@ export class BasicNoticeLocators extends BasicSharedLocators { ...@@ -14,7 +14,11 @@ export class BasicNoticeLocators extends BasicSharedLocators {
constructor(page: Page) { constructor(page: Page) {
super(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.publishScopeSelect = page.locator('.ant-form-item').filter({ hasText: '发布范围' }).locator('.ant-select').first();
this.searchBtn = page.getByRole('button', { name: /查\s*询/ }); this.searchBtn = page.getByRole('button', { name: /查\s*询/ });
this.resetBtn = page.getByRole('button', { name: /重\s*置/ }); this.resetBtn = page.getByRole('button', { name: /重\s*置/ });
......
...@@ -17,18 +17,19 @@ export class BasicRoleLocators extends BasicUserSharedLocators { ...@@ -17,18 +17,19 @@ export class BasicRoleLocators extends BasicUserSharedLocators {
super(page); super(page);
this.saveBtn = page.getByRole('button', { name: /保\s*存/ }); this.saveBtn = page.getByRole('button', { name: /保\s*存/ });
this.refreshBtn = 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 this.roleSearch = page
.getByPlaceholder(/角色/) .getByPlaceholder(/角色/)
.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(); /** 左栏角色列表(现网为「角色名称」下的 list,不在 ant-layout-sider)。 */
this.editRoleIcon = this.roleSider this.roleSider = page.getByRole('list').first();
.locator('button, a, [role="button"]') this.editRoleIcon = page.locator('img[alt="form"], .anticon-form').first();
.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 {
......
...@@ -45,7 +45,14 @@ export class BasicUserSharedLocators { ...@@ -45,7 +45,14 @@ export class BasicUserSharedLocators {
} }
formItem(label: string, root: Locator | Page = this.page): Locator { 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 { formSelect(label: string, root: Locator | Page = this.page): Locator {
......
...@@ -5,12 +5,25 @@ import { columnHeaderMatchTokens } from '../../core/column-header-match.util'; ...@@ -5,12 +5,25 @@ import { columnHeaderMatchTokens } from '../../core/column-header-match.util';
/** FineReport 常把路径斜杠编码成 %2F / %2f,两种 src 都要能挂上 iframe。 */ /** FineReport 常把路径斜杠编码成 %2F / %2f,两种 src 都要能挂上 iframe。 */
export function iframeCssSelector(iframeSrc: string): string { export function iframeCssSelector(iframeSrc: string): string {
const variants = new Set<string>([iframeSrc]); const variants = new Set<string>([iframeSrc]);
const parts = iframeSrc.split('/').filter(Boolean);
if (iframeSrc.includes('/')) { if (iframeSrc.includes('/')) {
variants.add(iframeSrc.replace(/\//g, '%2F')); variants.add(iframeSrc.replace(/\//g, '%2F'));
variants.add(iframeSrc.replace(/\//g, '%2f')); variants.add(iframeSrc.replace(/\//g, '%2f'));
variants.add(iframeSrc.replace(/\//g, '%252F')); 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(', '); return [...variants].map((src) => `iframe[src*="${src}"]`).join(', ');
} }
......
...@@ -25,7 +25,7 @@ export class FlowDefLocators extends WhFlowSharedLocators { ...@@ -25,7 +25,7 @@ export class FlowDefLocators extends WhFlowSharedLocators {
typeItems(): Locator { typeItems(): Locator {
return this.page.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 { ...@@ -32,8 +32,12 @@ export class SurgerySharedLocators {
.filter({ hasText: /^\s*手术套包设置\s*$/ }) .filter({ hasText: /^\s*手术套包设置\s*$/ })
.first(); .first();
this.leftTree = page.locator('.ant-tree').first(); /** 术种/套包左栏:现网为 xc-layout 分类列表(ul.type-ul),旧版为 ant-tree。 */
this.treeSearchInput = page.locator('.ant-tree ~ * input, .ant-tree input').first(); 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.tableHeader = page.locator('.ant-table-thead th');
this.tableBody = page.locator('.ant-table-tbody'); this.tableBody = page.locator('.ant-table-tbody');
...@@ -58,15 +62,16 @@ export class SurgerySharedLocators { ...@@ -58,15 +62,16 @@ export class SurgerySharedLocators {
.first(); .first();
} }
/** 同组页签(URL 型 rc-tabs 或文本 Tab 均覆盖)。 */ /** 同组页签(顶部 tabs-view,避免与侧栏菜单同名冲突)。 */
tab(name: string): Locator { tab(name: string): Locator {
const pattern = new RegExp(name); const compact = name.replace(/\s/g, '');
return this.page 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') .locator('.ant-tabs-tab')
.filter({ hasText: pattern }) .filter({ hasText: pattern })
.first() .first()
.or(this.page.getByRole('tab', { name: pattern }).first()) .or(tablist.getByRole('tab', { name: pattern }).first());
.or(this.page.getByText(pattern).first());
} }
button(name: string, root: Locator | Page = this.page): Locator { button(name: string, root: Locator | Page = this.page): Locator {
...@@ -75,13 +80,20 @@ export class SurgerySharedLocators { ...@@ -75,13 +80,20 @@ export class SurgerySharedLocators {
return root.getByRole('button', { name: pattern }); return root.getByRole('button', { name: pattern });
} }
/** 树节点:按可见文本包含匹配。 */ /** 树/分类节点:按可见文本包含匹配(兼容 ant-tree 与 type-ul)。 */
treeNode(text: string): Locator { 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 { 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)。 */ /** 弹窗内表单控件按 id 定位(CSV 明确给出 #form_item_xxx / #basic_kindName)。 */
......
import { expect, type Page } from '@playwright/test'; import { expect, type Page } from '@playwright/test';
import { admitData } from '../../data/admit.data'; import { admitData } from '../../data/admit.data';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { AdmitSharedPage } from './admit-shared.page'; import { AdmitSharedPage } from './admit-shared.page';
/** 物料准入单列表。详情里的「通过」只取消,不确认审批。 */ /** 物料准入单列表。详情里的「通过」只取消,不确认审批。 */
...@@ -20,12 +21,45 @@ export class AdmitMaterialListPage extends AdmitSharedPage { ...@@ -20,12 +21,45 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
async filterByOrderOrFallback(): Promise<void> { async filterByOrderOrFallback(): Promise<void> {
await this.query(); await this.query();
await this.queryByFirstAvailable([ const orderNo = await this.sampleMaterialOrderNo();
{ column: '准入单号', filter: '准入单号', kind: 'input' }, if (!orderNo) {
{ column: '注册证号', filter: '注册证号', kind: 'input' }, await this.guardedSkip('首行无可用准入单号样本');
{ column: '产品名称', filter: '产品名称', kind: 'input' }, return;
{ column: '器械注册人', filter: '器械注册人', kind: 'input' }, }
]); 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> { async resetFilters(): Promise<void> {
...@@ -37,14 +71,16 @@ export class AdmitMaterialListPage extends AdmitSharedPage { ...@@ -37,14 +71,16 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
} }
async mockEmpty() { async mockEmpty() {
const cert = this.textInput('注册证号'); return this.runMockEmptyQuery(admitData.mockGoods, async () => {
if (await cert.isVisible().catch(() => false)) { await this.ensureFiltersExpanded();
await this.fillFilter('注册证号', admitData.mockCert); const cert = this.textInput('注册证号');
} else { if (await cert.isVisible().catch(() => false)) {
await this.fillFilter('产品名称', admitData.mockGoods); await this.fillFilter('注册证号', admitData.mockCert);
} } else {
await this.query(); await this.fillProductNameFilter(admitData.mockGoods);
return this.snapshotList(); }
await this.query();
});
} }
async coreHeaders(): Promise<string[]> { async coreHeaders(): Promise<string[]> {
...@@ -54,7 +90,7 @@ export class AdmitMaterialListPage extends AdmitSharedPage { ...@@ -54,7 +90,7 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
async switchPassed(): Promise<void> { async switchPassed(): Promise<void> {
await this.clickTabAndMatchCount('已通过'); await this.clickTabAndMatchCount('已通过');
await expect(this.tab('已通过')).toHaveClass(/ant-tabs-tab-active/); await this.expectTabActive('已通过');
} }
async detailAndBack(): Promise<void> { async detailAndBack(): Promise<void> {
...@@ -102,15 +138,19 @@ export class AdmitMaterialListPage extends AdmitSharedPage { ...@@ -102,15 +138,19 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
async detailSearchMock(): Promise<void> { async detailSearchMock(): Promise<void> {
await this.query(); await this.query();
await this.openPutFromRow(await this.firstRow(), '详情'); await this.openPutFromRow(await this.firstRow(), '详情');
const input = this.putInput('产品名称'); await runWithMockEmptyListRoute(this.page, admitData.mockGoods, async () => {
const target = (await input.isVisible().catch(() => false)) ? input : this.put().locator('input').first(); const input = this.putInput('产品名称').or(this.putInput('物料名称'));
await this.fill(target, admitData.mockGoods, '详情搜索 mock'); const target = (await input.first().isVisible().catch(() => false))
const query = this.button('查询', this.put()).first(); ? input.first()
if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询'); : this.put().locator('input').first();
await this.waitForTableReady(); await this.fill(target, admitData.mockGoods, '详情搜索 mock');
const empty = await this.put().getByText('暂无数据').first().isVisible().catch(() => false); const query = this.button('查询', this.put()).first();
const rows = await this.put().locator('.ant-table-tbody > tr.ant-table-row').count(); if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询');
expect(empty || rows === 0, '详情无匹配应空态').toBeTruthy(); 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(); await this.leaveWithoutCommit();
} }
......
...@@ -21,13 +21,14 @@ export class AdmitMaterialPage extends AdmitSharedPage { ...@@ -21,13 +21,14 @@ export class AdmitMaterialPage extends AdmitSharedPage {
async filterByProductName(): Promise<void> { async filterByProductName(): Promise<void> {
await this.query(); await this.query();
const name = await this.sampleCell('产品名称'); const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 40)); await this.fillProductNameFilter(name.slice(0, 40));
await this.query(); await this.query();
await this.expectRowsContain('产品名称', name); await this.expectRowsContain('产品名称', name);
} }
async filterByGoodsType(): Promise<void> { async filterByGoodsType(): Promise<void> {
await this.query(); await this.query();
await this.ensureFiltersExpanded();
const type = await this.sampleCell('耗材类型'); const type = await this.sampleCell('耗材类型');
await this.chooseSelect('耗材类型', type); await this.chooseSelect('耗材类型', type);
await this.query(); await this.query();
...@@ -38,7 +39,7 @@ export class AdmitMaterialPage extends AdmitSharedPage { ...@@ -38,7 +39,7 @@ export class AdmitMaterialPage extends AdmitSharedPage {
await this.query(); await this.query();
const name = await this.sampleCell('产品名称'); const name = await this.sampleCell('产品名称');
const type = 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.chooseSelect('耗材类型', type);
await this.query(); await this.query();
await this.expectRowsContain('产品名称', name); await this.expectRowsContain('产品名称', name);
...@@ -48,7 +49,7 @@ export class AdmitMaterialPage extends AdmitSharedPage { ...@@ -48,7 +49,7 @@ export class AdmitMaterialPage extends AdmitSharedPage {
async resetFilters(): Promise<void> { async resetFilters(): Promise<void> {
await this.query(); await this.query();
const name = await this.sampleCell('产品名称'); const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 20)); await this.fillProductNameFilter(name.slice(0, 20));
await this.reset(); await this.reset();
await this.expectInputCleared('产品名称'); await this.expectInputCleared('产品名称');
await this.expectListOrEmpty(); await this.expectListOrEmpty();
...@@ -61,9 +62,10 @@ export class AdmitMaterialPage extends AdmitSharedPage { ...@@ -61,9 +62,10 @@ export class AdmitMaterialPage extends AdmitSharedPage {
} }
async mockEmptyName() { async mockEmptyName() {
await this.fillFilter('产品名称', admitData.mockGoods); return this.runMockEmptyQuery(admitData.mockGoods, async () => {
await this.query(); await this.fillProductNameFilter(admitData.mockGoods);
return this.snapshotList(); await this.query();
});
} }
async coreHeaders(): Promise<string[]> { async coreHeaders(): Promise<string[]> {
...@@ -73,7 +75,7 @@ export class AdmitMaterialPage extends AdmitSharedPage { ...@@ -73,7 +75,7 @@ export class AdmitMaterialPage extends AdmitSharedPage {
async switchPending(): Promise<void> { async switchPending(): Promise<void> {
await this.clickTab('待申请'); await this.clickTab('待申请');
await expect(this.tab('待申请')).toHaveClass(/ant-tabs-tab-active/); await this.expectTabActive('待申请');
await this.expectListOrEmpty(); await this.expectListOrEmpty();
} }
...@@ -161,7 +163,8 @@ export class AdmitMaterialPage extends AdmitSharedPage { ...@@ -161,7 +163,8 @@ export class AdmitMaterialPage extends AdmitSharedPage {
async relateCancel(): Promise<void> { async relateCancel(): Promise<void> {
await this.query(); await this.query();
const total = await this.getTotalCount(); 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('关联产品'); await this.clickToolbarThenCancel('关联产品');
expect(await this.getTotalCount(), '取消关联后总数不变').toBe(total); expect(await this.getTotalCount(), '取消关联后总数不变').toBe(total);
expect(await this.cellText(await this.firstRow(), '默认进院供应商')).toBe(supplier); expect(await this.cellText(await this.firstRow(), '默认进院供应商')).toBe(supplier);
......
...@@ -21,7 +21,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage { ...@@ -21,7 +21,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
async filterByProductName(): Promise<void> { async filterByProductName(): Promise<void> {
await this.query(); await this.query();
const name = await this.sampleCell('产品名称'); const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 40)); await this.fillProductNameFilter(name.slice(0, 40));
await this.query(); await this.query();
await this.expectRowsContain('产品名称', name); await this.expectRowsContain('产品名称', name);
} }
...@@ -30,7 +30,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage { ...@@ -30,7 +30,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
await this.query(); await this.query();
const name = await this.sampleCell('产品名称'); const name = await this.sampleCell('产品名称');
const type = 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.chooseSelect('耗材类型', type);
await this.query(); await this.query();
await this.expectRowsContain('产品名称', name); await this.expectRowsContain('产品名称', name);
...@@ -39,16 +39,17 @@ export class AdmitProductApplyPage extends AdmitSharedPage { ...@@ -39,16 +39,17 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
async resetFilters(): Promise<void> { async resetFilters(): Promise<void> {
await this.query(); await this.query();
const name = await this.sampleCell('产品名称'); const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 20)); await this.fillProductNameFilter(name.slice(0, 20));
await this.reset(); await this.reset();
await this.expectInputCleared('产品名称'); await this.expectInputCleared('产品名称');
await this.expectListOrEmpty(); await this.expectListOrEmpty();
} }
async mockEmpty() { async mockEmpty() {
await this.fillFilter('产品名称', admitData.mockGoods); return this.runMockEmptyQuery(admitData.mockGoods, async () => {
await this.query(); await this.fillProductNameFilter(admitData.mockGoods);
return this.snapshotList(); await this.query();
});
} }
async coreHeaders(): Promise<string[]> { async coreHeaders(): Promise<string[]> {
...@@ -58,7 +59,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage { ...@@ -58,7 +59,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
async switchPending(): Promise<void> { async switchPending(): Promise<void> {
await this.clickTab('待申请'); await this.clickTab('待申请');
await expect(this.tab('待申请')).toHaveClass(/ant-tabs-tab-active/); await this.expectTabActive('待申请');
await this.expectListOrEmpty(); await this.expectListOrEmpty();
} }
...@@ -76,9 +77,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage { ...@@ -76,9 +77,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
const total = await this.getTotalCount(); const total = await this.getTotalCount();
await this.clickButton('新增产品'); await this.clickButton('新增产品');
await expect(this.put(), '新增产品页').toBeVisible(); await expect(this.put(), '新增产品页').toBeVisible();
for (const label of ['产品名称', '器械注册人', '规格型号']) { await this.expectPutFieldLabels('产品名称', '器械注册人', '规格型号');
await expect(this.put().getByText(label).first(), label).toBeVisible();
}
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
expect(await this.getTotalCount()).toBe(total); expect(await this.getTotalCount()).toBe(total);
} }
...@@ -88,9 +87,9 @@ export class AdmitProductApplyPage extends AdmitSharedPage { ...@@ -88,9 +87,9 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
const name = uniqProductName(); const name = uniqProductName();
await this.clickButton('新增产品'); await this.clickButton('新增产品');
await expect(this.put()).toBeVisible(); await expect(this.put()).toBeVisible();
await this.fill(this.putInput('产品名称'), name, '填写新产品名称'); await this.fill(this.putProductNameInput(), name, '填写新产品名称');
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
await this.fillFilter('产品名称', name); await this.fillProductNameFilter(name);
await this.query(); await this.query();
await this.expectMockEmpty(); await this.expectMockEmpty();
await this.reset(); await this.reset();
...@@ -110,7 +109,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage { ...@@ -110,7 +109,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
const total = await this.getTotalCount(); const total = await this.getTotalCount();
await this.clickButton('新增产品'); await this.clickButton('新增产品');
await expect(this.put()).toBeVisible(); await expect(this.put()).toBeVisible();
await this.fill(this.putInput('产品名称'), '', '产品名称保持空'); await this.fill(this.putProductNameInput(), '', '产品名称保持空');
await this.saveOnPut(); await this.saveOnPut();
await this.expectBlockedAndStay(); await this.expectBlockedAndStay();
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
...@@ -131,7 +130,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage { ...@@ -131,7 +130,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
const row = await this.skipIfNoAction('编辑', '待申请无编辑'); const row = await this.skipIfNoAction('编辑', '待申请无编辑');
const name = await this.cellText(row, '产品名称'); const name = await this.cellText(row, '产品名称');
await this.openPutFromRow(row, '编辑'); await this.openPutFromRow(row, '编辑');
await expect(this.putInput('产品名称')).toHaveValue(name); await expect(this.putProductNameInput()).toHaveValue(name);
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
} }
...@@ -145,7 +144,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage { ...@@ -145,7 +144,7 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
await this.clickTab('待申请'); await this.clickTab('待申请');
const again = await this.skipIfNoAction('编辑', '待申请无编辑'); const again = await this.skipIfNoAction('编辑', '待申请无编辑');
await this.openPutFromRow(again, '编辑'); await this.openPutFromRow(again, '编辑');
await expect(this.putInput('产品名称')).toHaveValue(name); await expect(this.putProductNameInput()).toHaveValue(name);
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
} }
......
import { expect, type Page } from '@playwright/test'; import { expect, type Page } from '@playwright/test';
import { admitData } from '../../data/admit.data'; import { admitData } from '../../data/admit.data';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { AdmitSharedPage } from './admit-shared.page'; import { AdmitSharedPage } from './admit-shared.page';
/** 产品准入单列表。与物料单列表 URL 分离;通过只取消。 */ /** 产品准入单列表。与物料单列表 URL 分离;通过只取消。 */
...@@ -37,9 +38,12 @@ export class AdmitProductListPage extends AdmitSharedPage { ...@@ -37,9 +38,12 @@ export class AdmitProductListPage extends AdmitSharedPage {
async filterByProductOrFallback(): Promise<void> { async filterByProductOrFallback(): Promise<void> {
await this.query(); await this.query();
await this.queryByFirstAvailable([ 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' },
{ column: '器械注册人', filter: '器械注册人', kind: 'input' }, { column: '器械注册人', filter: '器械注册人', kind: 'input' },
{ column: '进院供应商', filter: '供应商', kind: 'input' },
]); ]);
} }
...@@ -53,13 +57,14 @@ export class AdmitProductListPage extends AdmitSharedPage { ...@@ -53,13 +57,14 @@ export class AdmitProductListPage extends AdmitSharedPage {
} }
async mockEmpty() { async mockEmpty() {
if (await this.textInput('产品名称').isVisible().catch(() => false)) { return this.runMockEmptyQuery(admitData.mockGoods, async () => {
await this.fillFilter('产品名称', admitData.mockGoods); if (await this.textInput('产品名称').isVisible().catch(() => false)) {
} else { await this.fillProductNameFilter(admitData.mockGoods);
await this.fillFilter('注册证号', admitData.mockCert); } else {
} await this.fillFilter('注册证号', admitData.mockCert);
await this.query(); }
return this.snapshotList(); await this.query();
});
} }
async coreHeaders(): Promise<string[]> { async coreHeaders(): Promise<string[]> {
...@@ -69,7 +74,7 @@ export class AdmitProductListPage extends AdmitSharedPage { ...@@ -69,7 +74,7 @@ export class AdmitProductListPage extends AdmitSharedPage {
async switchPassed(): Promise<void> { async switchPassed(): Promise<void> {
await this.clickTabAndMatchCount('已通过'); await this.clickTabAndMatchCount('已通过');
await expect(this.tab('已通过')).toHaveClass(/ant-tabs-tab-active/); await this.expectTabActive('已通过');
} }
async detailAndBack(): Promise<void> { async detailAndBack(): Promise<void> {
...@@ -100,15 +105,19 @@ export class AdmitProductListPage extends AdmitSharedPage { ...@@ -100,15 +105,19 @@ export class AdmitProductListPage extends AdmitSharedPage {
async detailSearchMock(): Promise<void> { async detailSearchMock(): Promise<void> {
await this.query(); await this.query();
await this.openPutFromRow(await this.firstRow(), '详情'); await this.openPutFromRow(await this.firstRow(), '详情');
const input = this.putInput('产品名称'); await runWithMockEmptyListRoute(this.page, admitData.mockGoods, async () => {
const target = (await input.isVisible().catch(() => false)) ? input : this.put().locator('input').first(); const input = this.putInput('产品名称').or(this.putInput('物料名称'));
await this.fill(target, admitData.mockGoods, '详情搜索 mock'); const target = (await input.first().isVisible().catch(() => false))
const query = this.button('查询', this.put()).first(); ? input.first()
if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询'); : this.put().locator('input').first();
await this.waitForTableReady(); await this.fill(target, admitData.mockGoods, '详情搜索 mock');
const empty = await this.put().getByText('暂无数据').first().isVisible().catch(() => false); const query = this.button('查询', this.put()).first();
const rows = await this.put().locator('.ant-table-tbody > tr.ant-table-row').count(); if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询');
expect(empty || rows === 0, '详情无匹配应空态').toBeTruthy(); 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(); await this.leaveWithoutCommit();
} }
......
...@@ -3,7 +3,16 @@ import { expect, type Locator, type Page } from '@playwright/test'; ...@@ -3,7 +3,16 @@ import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config'; import { env } from '../../config/env.config';
import { AdmitSharedLocators } from '../../locators/spd/admit-shared.locator'; import { AdmitSharedLocators } from '../../locators/spd/admit-shared.locator';
import { BasePage } from '../../core/base.page'; 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 { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
export type AdmitMeta = { export type AdmitMeta = {
path: string; path: string;
...@@ -32,6 +41,15 @@ export class AdmitSharedPage extends BasePage { ...@@ -32,6 +41,15 @@ export class AdmitSharedPage extends BasePage {
await this.goto(this.meta.path); await this.goto(this.meta.path);
await this.waitSpinGone(); await this.waitSpinGone();
await this.waitSettle(400); 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}`); await this.assertUiReady(`打开 ${this.meta.path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/); expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
} }
...@@ -46,7 +64,11 @@ export class AdmitSharedPage extends BasePage { ...@@ -46,7 +64,11 @@ export class AdmitSharedPage extends BasePage {
.evaluate((el) => el.classList.contains('ant-menu-submenu-open')) .evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false); .catch(() => false);
if (!opened) await this.click(title, '展开准入管理'); 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.page.waitForURL((url) => url.pathname.includes(this.meta.pathPart), { timeout: 20_000 });
await this.waitSpinGone(); await this.waitSpinGone();
await this.waitSettle(400); await this.waitSettle(400);
...@@ -58,8 +80,22 @@ export class AdmitSharedPage extends BasePage { ...@@ -58,8 +80,22 @@ export class AdmitSharedPage extends BasePage {
for (const part of this.meta.forbidden ?? []) { for (const part of this.meta.forbidden ?? []) {
expect(this.page.url(), `不应进入 ${part}`).not.toContain(part); expect(this.page.url(), `不应进入 ${part}`).not.toContain(part);
} }
await expect(this.locators.button('查询').first(), '查询按钮').toBeVisible(); await expect(this.locators.button('查询').first(), '查询按钮').toBeVisible({ timeout: 20_000 });
expect((await this.headerTexts()).length, '列表表头').toBeGreaterThan(0); 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 { button(name: string, root?: Locator): Locator {
...@@ -84,8 +120,16 @@ export class AdmitSharedPage extends BasePage { ...@@ -84,8 +120,16 @@ export class AdmitSharedPage extends BasePage {
textInput(label: string, root?: Locator): Locator { textInput(label: string, root?: Locator): Locator {
const scope = root ?? this.page.locator('body'); const scope = root ?? this.page.locator('body');
const byRole = scope.getByRole('textbox', { name: label }).first(); const compact = label.replace(/\s/g, '');
return byRole.or(scope.locator('.ant-form-item').filter({ hasText: label }).locator('input').first()); 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 { formSelect(label: string, root?: Locator): Locator {
...@@ -93,14 +137,39 @@ export class AdmitSharedPage extends BasePage { ...@@ -93,14 +137,39 @@ export class AdmitSharedPage extends BasePage {
return scope.locator('.ant-form-item').filter({ hasText: label }).locator('.ant-select').first(); 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> { async fillFilter(label: string, value: string): Promise<void> {
await this.ensureFiltersExpanded();
const input = this.textInput(label); const input = this.textInput(label);
await expect(input, `筛选「${label}」`).toBeVisible(); await expect(input, `筛选「${label}」`).toBeVisible({ timeout: 8_000 });
await this.fill(input, value, `填写${label}`); 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> { async chooseSelect(label: string, optionText: string, root?: Locator): Promise<void> {
await this.ensureFiltersExpanded();
const select = this.formSelect(label, root); const select = this.formSelect(label, root);
await select.scrollIntoViewIfNeeded().catch(() => undefined);
await this.click(select, `打开${label}`); await this.click(select, `打开${label}`);
const dropdown = this.page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').last(); const dropdown = this.page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').last();
const keyword = optionText.replace(/\s/g, '').slice(0, 20); const keyword = optionText.replace(/\s/g, '').slice(0, 20);
...@@ -137,9 +206,33 @@ export class AdmitSharedPage extends BasePage { ...@@ -137,9 +206,33 @@ export class AdmitSharedPage extends BasePage {
return matched ? Number(matched[1]) : null; 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> { 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.assertBeforeBusinessAction(`切换${name}`);
await this.click(this.tab(name), `切换${name}`); await this.click(tab, `切换${name}`);
await this.waitForTableReady(); await this.waitForTableReady();
} }
...@@ -151,32 +244,51 @@ export class AdmitSharedPage extends BasePage { ...@@ -151,32 +244,51 @@ export class AdmitSharedPage extends BasePage {
const groups = this.page.locator('.ant-table-thead'); const groups = this.page.locator('.ant-table-thead');
const count = await groups.count(); const count = await groups.count();
let best = groups.first().locator('th'); let best = groups.first().locator('th');
let bestLen = 0; let bestScore = -1;
for (let i = 0; i < count; i += 1) { for (let i = 0; i < count; i += 1) {
const cells = groups.nth(i).locator('th'); const group = groups.nth(i);
const len = await cells.count(); const insidePut = await group.evaluate((el) => Boolean(el.closest('.put-page'))).catch(() => false);
if (len > bestLen) { if (insidePut) continue;
bestLen = len; 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; best = cells;
} }
} }
return best; return best;
} }
/** 与 tbody 数据列对齐的表头(排除选择列/展开列)。 */
async dataHeaderLabels(): Promise<string[]> {
return dataHeaderLabelsFromCells(await this.headerCells());
}
async headerTexts(): Promise<string[]> { 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> { 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) { 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> { async headerIndex(name: string): Promise<number> {
const texts = await this.headerTexts(); const texts = await this.dataHeaderLabels();
const index = texts.findIndex((item) => item.includes(name.replace(/\s/g, ''))); const index = resolveColumnIndexFromLabels(texts, name);
expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0); expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0);
return index; return index;
} }
...@@ -217,8 +329,8 @@ export class AdmitSharedPage extends BasePage { ...@@ -217,8 +329,8 @@ export class AdmitSharedPage extends BasePage {
} }
async cellText(row: Locator, header: string): Promise<string> { async cellText(row: Locator, header: string): Promise<string> {
const index = await this.headerIndex(header); const labels = await this.dataHeaderLabels();
return ((await row.locator('td').nth(index).innerText()) ?? '').trim(); return readRowCellByColumn(row, labels, header);
} }
async tryCell(row: Locator, header: string): Promise<string> { async tryCell(row: Locator, header: string): Promise<string> {
...@@ -235,6 +347,24 @@ export class AdmitSharedPage extends BasePage { ...@@ -235,6 +347,24 @@ export class AdmitSharedPage extends BasePage {
return row; 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)。 */ /** 列表快照供用例层断言(不在此 expect)。 */
async snapshotList(): Promise<{ rows: number; total: number; emptyVisible: boolean }> { async snapshotList(): Promise<{ rows: number; total: number; emptyVisible: boolean }> {
await this.waitForTableReady(); await this.waitForTableReady();
...@@ -289,12 +419,41 @@ export class AdmitSharedPage extends BasePage { ...@@ -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> { async sampleCell(header: string): Promise<string> {
const rows = await this.listRows(); const rows = await this.listRows();
const count = Math.min(await rows.count(), 8); const count = Math.min(await rows.count(), 8);
for (let i = 0; i < count; i += 1) { for (let i = 0; i < count; i += 1) {
const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, ''); const text = normalizeSampleCellValue(await this.tryCell(rows.nth(i), header));
if (text && text !== '--' && text !== '-') return text; if (text) return text;
} }
await this.skipWhenDataInsufficient(`首屏「${header}」为空`); await this.skipWhenDataInsufficient(`首屏「${header}」为空`);
return ''; return '';
...@@ -304,21 +463,37 @@ export class AdmitSharedPage extends BasePage { ...@@ -304,21 +463,37 @@ export class AdmitSharedPage extends BasePage {
async queryByFirstAvailable( async queryByFirstAvailable(
pairs: Array<{ column: string; filter: string; kind: 'input' | 'select' }>, pairs: Array<{ column: string; filter: string; kind: 'input' | 'select' }>,
): Promise<void> { ): Promise<void> {
await this.ensureFiltersExpanded();
const row = await this.firstRow(); const row = await this.firstRow();
for (const pair of pairs) { for (const pair of pairs) {
const value = (await this.tryCell(row, pair.column)).trim(); const value = (await this.tryCell(row, pair.column)).trim();
if (!value || value === '--' || value === '-') continue; if (!value || value === '--' || value === '-') continue;
if (pair.kind === 'input' && (await this.textInput(pair.filter).isVisible().catch(() => false))) { const filterLabels = [
await this.fillFilter(pair.filter, value.slice(0, 40)); pair.filter,
await this.query(); ...columnHeaderMatchTokens(pair.filter),
await this.expectRowsContain(pair.column, value); ...columnHeaderMatchTokens(pair.column),
return; ].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))) { if (pair.kind === 'select') {
await this.chooseSelect(pair.filter, value); for (const label of filterLabels) {
await this.query(); const select = this.formSelect(label);
await this.expectRowsContain(pair.column, value); if (await select.isVisible().catch(() => false)) {
return; await this.chooseSelect(label, value);
await this.query();
await this.expectRowsContain(pair.column, value);
return;
}
}
} }
} }
await this.guardedSkip('筛选区没有与首行样本对应的条件'); await this.guardedSkip('筛选区没有与首行样本对应的条件');
...@@ -469,8 +644,11 @@ export class AdmitSharedPage extends BasePage { ...@@ -469,8 +644,11 @@ export class AdmitSharedPage extends BasePage {
} }
async expectBlockedAndStay(): Promise<void> { async expectBlockedAndStay(): Promise<void> {
const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|请先/).first(); const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|请先/);
await expect(this.locators.formError.first().or(hint), '应出现必填或非法提示').toBeVisible({ timeout: 8_000 }); const errorHint = this.locators.formError.or(hint).first();
await expect(errorHint, '应出现必填或非法提示').toBeVisible({
timeout: 8_000,
});
const stayed = const stayed =
(await this.page.locator('.put-page:visible').count()) > 0 || (await this.page.locator('.put-page:visible').count()) > 0 ||
(await this.page.locator('.ant-modal:visible').count()) > 0; (await this.page.locator('.ant-modal:visible').count()) > 0;
...@@ -487,9 +665,30 @@ export class AdmitSharedPage extends BasePage { ...@@ -487,9 +665,30 @@ export class AdmitSharedPage extends BasePage {
} }
async checkFirstRow(): Promise<Locator> { async checkFirstRow(): Promise<Locator> {
const row = await this.firstRow(); const row = await this.firstCheckableRow();
const box = row.locator('.ant-checkbox').first(); await row.scrollIntoViewIfNeeded().catch(() => undefined);
await this.click(box, '勾选首行'); 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; return row;
} }
...@@ -536,6 +735,25 @@ export class AdmitSharedPage extends BasePage { ...@@ -536,6 +735,25 @@ export class AdmitSharedPage extends BasePage {
return this.textInput(label, this.put()); 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> { async saveOnPut(): Promise<void> {
await this.assertBeforeBusinessAction('保存'); await this.assertBeforeBusinessAction('保存');
const save = this.button('保存', this.put()).first(); const save = this.button('保存', this.put()).first();
......
...@@ -35,13 +35,15 @@ export class AdmitSupplyPage extends AdmitSharedPage { ...@@ -35,13 +35,15 @@ export class AdmitSupplyPage extends AdmitSharedPage {
} }
async mockEmpty() { async mockEmpty() {
if (await this.textInput('变更单号').isVisible().catch(() => false)) { const needle = admitData.mockOrder;
await this.fillFilter('变更单号', admitData.mockOrder); return this.runMockEmptyQuery(needle, async () => {
} else { if (await this.textInput('变更单号').isVisible().catch(() => false)) {
await this.fillFilter('产品名称', admitData.mockGoods); await this.fillFilter('变更单号', admitData.mockOrder);
} } else {
await this.query(); await this.fillProductNameFilter(admitData.mockGoods);
return this.snapshotList(); }
await this.query();
});
} }
async coreHeaders(): Promise<string[]> { async coreHeaders(): Promise<string[]> {
...@@ -51,7 +53,7 @@ export class AdmitSupplyPage extends AdmitSharedPage { ...@@ -51,7 +53,7 @@ export class AdmitSupplyPage extends AdmitSharedPage {
async switchPending(): Promise<void> { async switchPending(): Promise<void> {
await this.clickTabAndMatchCount('待提交'); await this.clickTabAndMatchCount('待提交');
await expect(this.tab('待提交')).toHaveClass(/ant-tabs-tab-active/); await this.expectTabActive('待提交');
} }
async openAddFields(): Promise<void> { async openAddFields(): Promise<void> {
......
...@@ -21,7 +21,7 @@ export class AdmitTaskPage extends AdmitSharedPage { ...@@ -21,7 +21,7 @@ export class AdmitTaskPage extends AdmitSharedPage {
async filterByProductName(): Promise<void> { async filterByProductName(): Promise<void> {
await this.query(); await this.query();
const name = await this.sampleCell('产品名称'); const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 40)); await this.fillProductNameFilter(name.slice(0, 40));
await this.query(); await this.query();
await this.expectRowsContain('产品名称', name); await this.expectRowsContain('产品名称', name);
} }
...@@ -30,22 +30,23 @@ export class AdmitTaskPage extends AdmitSharedPage { ...@@ -30,22 +30,23 @@ export class AdmitTaskPage extends AdmitSharedPage {
await this.clickTabAndMatchCount('待分类'); await this.clickTabAndMatchCount('待分类');
await this.clickTabAndMatchCount('已分类'); await this.clickTabAndMatchCount('已分类');
await this.clickTab('待分类'); await this.clickTab('待分类');
await expect(this.tab('待分类')).toHaveClass(/ant-tabs-tab-active/); await this.expectTabActive('待分类');
} }
async resetFilters(): Promise<void> { async resetFilters(): Promise<void> {
await this.query(); await this.query();
const name = await this.sampleCell('产品名称'); const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 20)); await this.fillProductNameFilter(name.slice(0, 20));
await this.reset(); await this.reset();
await this.expectInputCleared('产品名称'); await this.expectInputCleared('产品名称');
await this.expectListOrEmpty(); await this.expectListOrEmpty();
} }
async mockEmpty() { async mockEmpty() {
await this.fillFilter('产品名称', admitData.mockGoods); return this.runMockEmptyQuery(admitData.mockGoods, async () => {
await this.query(); await this.fillProductNameFilter(admitData.mockGoods);
return this.snapshotList(); await this.query();
});
} }
async coreHeaders(): Promise<string[]> { async coreHeaders(): Promise<string[]> {
......
...@@ -24,34 +24,27 @@ export class BasicRolePage extends BasicUserSharedPage { ...@@ -24,34 +24,27 @@ export class BasicRolePage extends BasicUserSharedPage {
} }
leftRoleItems(): Locator { leftRoleItems(): Locator {
return this.page.locator( return this.locators.roleSider.getByRole('listitem');
'.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',
);
} }
async leftRoleNames(): Promise<string[]> { async leftRoleNames(): Promise<string[]> {
const items = this.leftRoleItems(); const items = this.leftRoleItems();
await items.first().waitFor({ state: 'visible', timeout: 15_000 }).catch(() => undefined);
const count = await items.count(); const count = await items.count();
if (count > 0) { const names: string[] = [];
const names: string[] = []; for (let i = 0; i < Math.min(count, 40); i += 1) {
for (let i = 0; i < Math.min(count, 30); i += 1) { const text = ((await items.nth(i).innerText()) ?? '').split('\n')[0].trim();
const text = ((await items.nth(i).innerText()) ?? '').split('\n')[0].trim(); if (text && text !== '角色') names.push(text);
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);
} }
return []; return names;
} }
async sampleRoleName(): Promise<string> { async sampleRoleName(): Promise<string> {
const names = await this.leftRoleNames(); 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) { if (!found) {
await this.skipWhenDataInsufficient('左栏无角色'); await this.skipWhenDataInsufficient('左栏无角色');
return ''; return '';
...@@ -60,12 +53,7 @@ export class BasicRolePage extends BasicUserSharedPage { ...@@ -60,12 +53,7 @@ export class BasicRolePage extends BasicUserSharedPage {
} }
leftRoleListScrollHost(): Locator { leftRoleListScrollHost(): Locator {
const sider = this.locators.roleSider; return 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> { async assertLeftRoleListVerticalScroll(): Promise<void> {
...@@ -164,12 +152,9 @@ export class BasicRolePage extends BasicUserSharedPage { ...@@ -164,12 +152,9 @@ export class BasicRolePage extends BasicUserSharedPage {
} }
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: new RegExp(`^\\s*${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`) }).first();
if (await item.isVisible().catch(() => false)) { await this.click(item, `选中角色 ${name}`);
await this.click(item, `选中角色 ${name}`); await expect(this.locators.roleTree, '选中角色后权限树应可见').toBeVisible({ timeout: 15_000 });
} else {
await this.click(this.page.getByText(name, { exact: true }).first(), `选中角色 ${name}`);
}
await this.waitSpinGone(); await this.waitSpinGone();
await this.waitSettle(400); await this.waitSettle(400);
} }
...@@ -292,10 +277,7 @@ export class BasicRolePage extends BasicUserSharedPage { ...@@ -292,10 +277,7 @@ export class BasicRolePage extends BasicUserSharedPage {
editEntry(roleName: string): Locator { editEntry(roleName: string): Locator {
const row = this.leftRoleItems().filter({ hasText: roleName }).first(); const row = this.leftRoleItems().filter({ hasText: roleName }).first();
return row return row.locator('img[alt="form"], .anticon-form, .anticon-edit').first();
.getByRole('button', { name: /编\s*辑/ })
.or(row.locator('.anticon-edit, .anticon-form, [aria-label*="edit"], [aria-label*="编辑"]'))
.first();
} }
normalizeRoleLabel(text: string): string { normalizeRoleLabel(text: string): string {
...@@ -397,7 +379,10 @@ export class BasicRolePage extends BasicUserSharedPage { ...@@ -397,7 +379,10 @@ export class BasicRolePage extends BasicUserSharedPage {
} }
addRoleDialog(): Locator { 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> { async openAddRoleDialog(): Promise<Locator> {
...@@ -425,20 +410,28 @@ export class BasicRolePage extends BasicUserSharedPage { ...@@ -425,20 +410,28 @@ export class BasicRolePage extends BasicUserSharedPage {
addRoleDialogCloseIcon(): Locator { addRoleDialogCloseIcon(): Locator {
const dialog = this.addRoleDialog(); const dialog = this.addRoleDialog();
return dialog return dialog.locator('button.ant-modal-close, .ant-drawer-close').first();
.locator('.ant-modal-close') }
.first()
.or(dialog.getByRole('button', { name: /close|关闭/i }).first()) addRoleDialogWrap(): Locator {
.or(dialog.getByRole('img', { name: 'close' }).first()); return this.page.locator('.ant-modal-wrap').filter({ hasText: /新\s*增\s*角\s*色/ }).last();
} }
async closeAddRoleDialogByX(): Promise<void> { async closeAddRoleDialogByX(): Promise<void> {
const dialog = this.addRoleDialog(); const wrap = this.addRoleDialogWrap();
await expect(dialog, '新增弹框应可见').toBeVisible(); await expect(wrap, '新增弹框应可见').toBeVisible();
const close = this.addRoleDialogCloseIcon(); const close = wrap.locator('button.ant-modal-close').first();
await expect(close, '右上角 X 关闭').toBeVisible(); await expect(close, '右上角 X 关闭').toBeVisible();
await this.click(close, '点击新增弹框 X 关闭'); 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); expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertUiReady('X 关闭新增角色弹框后'); await this.assertUiReady('X 关闭新增角色弹框后');
} }
......
import { expect, type Locator, type Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import { userData, uniqLogin } from '../../data/user.data'; 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 { BasicUserLocators } from '../../locators/spd/basic-user.locator';
import { BasicUserSharedPage, type UserMeta } from './basic-user-shared.page'; import { BasicUserSharedPage, type UserMeta } from './basic-user-shared.page';
import { import {
...@@ -8,6 +9,7 @@ import { ...@@ -8,6 +9,7 @@ import {
executeColumnSettingsToggleRestore, executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger, findUsableColumnSettingsTrigger,
} from './helpers/column-settings.helper'; } from './helpers/column-settings.helper';
import { failWhen } from './helpers/guarded-skip.helper';
export type UserSample = { export type UserSample = {
sampleUserName: string; sampleUserName: string;
...@@ -243,9 +245,17 @@ export class BasicUserPage extends BasicUserSharedPage { ...@@ -243,9 +245,17 @@ export class BasicUserPage extends BasicUserSharedPage {
async clickAuthAddRow(): Promise<void> { async clickAuthAddRow(): Promise<void> {
const layer = this.userRoleAuthLayer(); 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))) { if (!(await add.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('授权弹框无「添加」按钮(可能为权限树模式)'); await failWhen(
this.browserPage,
true,
'授权弹框无「添加/新增」按钮,请核对页面是否为表格授权模式',
);
} }
await this.click(add, '添加'); await this.click(add, '添加');
await this.waitSettle(400); await this.waitSettle(400);
...@@ -276,7 +286,15 @@ export class BasicUserPage extends BasicUserSharedPage { ...@@ -276,7 +286,15 @@ export class BasicUserPage extends BasicUserSharedPage {
} }
private async resolveColumnSettingsTrigger(): Promise<Locator> { 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); let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) { if (!trigger) {
await this.locators.tableWrapper.scrollIntoViewIfNeeded().catch(() => undefined); 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