Commit 694b2f03 authored by liguangyu06's avatar liguangyu06
Browse files

新增科室管理与接口管理自动化资产,并同步加固基础管理用例与 PO。

SKIP_GIT_GATE=1:批量资产入库,本地暂不重跑相关 Playwright 门禁凭证。
parent 01540e6e
......@@ -17,6 +17,7 @@ export class BasicFinanceMonthPage extends BasicHospitalSharedPage {
constructor(page: Page) {
super(page);
this.locators = new BasicFinanceMonthLocators(page);
this.columnSettingsToggleCandidates = basicHospitalData.financeColumnSettingsToggleCandidates;
}
async open(): Promise<void> {
......
......@@ -77,21 +77,6 @@ export class BasicHospitalInfoPage extends BasicHospitalSharedPage {
expect(shown).toContain(original);
}
async saveAndRestore(): Promise<void> {
await this.enterEdit();
const original = await this.readLinkman();
const next = uniqLinkman();
await this.fill(this.locators.linkman, next, '改联系人');
await this.click(this.locators.saveBtn, '保 存');
await this.waitSpinGone();
await this.open();
await this.enterEdit();
expect(await this.readLinkman()).toBe(next);
await this.fill(this.locators.linkman, original, '恢复联系人');
await this.click(this.locators.saveBtn, '保 存恢复');
await this.waitSpinGone();
}
async saveEmptyField(field: 'linkman' | 'contactWay' | 'shortName'): Promise<void> {
await this.enterEdit();
const locator = this.locators[field];
......
......@@ -14,6 +14,7 @@ export class BasicNoticePage extends BasicHospitalSharedPage {
constructor(page: Page) {
super(page);
this.locators = new BasicNoticeLocators(page);
this.columnSettingsToggleCandidates = basicHospitalData.noticeColumnSettingsToggleCandidates;
}
async open(): Promise<void> {
......
......@@ -327,30 +327,6 @@ export class BasicRolePage extends BasicUserSharedPage {
return { selectedRoleName, dialog };
}
editRoleDialog(): Locator {
return this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last();
}
async saveEditRoleDialog(): Promise<void> {
const dialog = this.editRoleDialog();
const save = dialog.getByRole('button', { name: /保\s*存/ }).first();
await expect(save, '弹框保存按钮').toBeVisible();
await this.assertBeforeBusinessAction('保存编辑角色');
await this.click(save, '保存编辑角色', { operation: 'write' });
await this.waitSpinGone();
await this.waitSettle(400);
}
async expectToastSuccess(text: string | RegExp): Promise<void> {
const toast = this.page
.locator('.ant-message-notice, .ant-message-success, .ant-notification-notice, .ant-message')
.filter({ hasText: text })
.first();
await expect(toast, `应提示成功:${String(text)}`).toBeVisible({ timeout: 12_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertPageHealthy('保存后页面健康');
}
async openDeleteConfirm(): Promise<{ roleName: string; confirm: Locator } | null> {
const names = await this.leftRoleNames();
const target = names.find((n) => n && !/管理员/.test(n)) ?? names[0];
......
......@@ -4,10 +4,18 @@ import { env } from '../../config/env.config';
import { BasePage } from '../../core/base.page';
import { basicHospitalData } from '../../data/basic-hospital.data';
import { BasicSharedLocators } from '../../locators/spd/basic-shared.locator';
import {
clickPageSizeDropdownOption,
pageSizeLabelsEqual,
skipIfInsufficientForPageSizeChange,
} from './helpers/pagination-pagesize.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
/** 医院资料页共用:无 iframe、菜单进入、表格/分页/弹窗。 */
export class BasicHospitalSharedPage extends BasePage {
protected readonly shared: BasicSharedLocators;
protected columnSettingsToggleCandidates: readonly string[] = [];
private proTableList?: SpdProTableListControls;
constructor(page: Page) {
super(page);
......@@ -203,4 +211,56 @@ export class BasicHospitalSharedPage extends BasePage {
get hospitalData() {
return basicHospitalData;
}
pageSizeChanger() {
return this.page.locator('.ant-pagination-options-size-changer').first();
}
async switchToPageSizeTwenty(): Promise<{ total: number; rowsBefore: number; rowsAfter: number }> {
await skipIfInsufficientForPageSizeChange({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
});
const total = await this.getTotalCount();
const rowsBefore = await this.getDataRowCount();
const sizeNum = 20;
if (total > sizeNum && rowsBefore <= sizeNum) {
await this.skipWhenDataInsufficient('当前页可见行数已不大于 20,无法验证切换 20 条/页');
}
const changer = this.pageSizeChanger();
if (!(await changer.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('当前页面无每页条数切换');
}
await this.click(changer, '打开每页条数');
await clickPageSizeDropdownOption(this.page, sizeNum, (loc, step) => this.click(loc, step));
await this.waitForTableReady();
const afterText = ((await changer.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ');
if (!pageSizeLabelsEqual(afterText, sizeNum)) {
throw new Error(`切换每页条数未生效:期望 20 条/页,当前「${afterText}」`);
}
const rowsAfter = await this.getDataRowCount();
return { total, rowsBefore, rowsAfter };
}
private listControls(): SpdProTableListControls {
if (!this.proTableList) {
this.proTableList = new SpdProTableListControls({
listScope: () => this.page,
columnSettingsToggleCandidates: this.columnSettingsToggleCandidates,
sortColumns: [],
clickQuery: async () => {
const self = this as BasicHospitalSharedPage & { search?: () => Promise<void> };
if (typeof self.search === 'function') await self.search();
},
skipWhen: (reason) => this.skipWhenDataInsufficient(reason),
click: (loc, label) => this.click(loc, label),
});
}
return this.proTableList;
}
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await this.listControls().executeColumnSettingsToggleRestoreTest();
}
}
......@@ -4,7 +4,12 @@ import { env } from '../../config/env.config';
import { BasePage } from '../../core/base.page';
import { BasicUserSharedLocators } from '../../locators/spd/basic-user-shared.locator';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { skipIfInsufficientForPageSizeChange } from './helpers/pagination-pagesize.helper';
import {
clickPageSizeDropdownOption,
pageSizeLabelsEqual,
parsePageSizeFromLabel,
skipIfInsufficientForPageSizeChange,
} from './helpers/pagination-pagesize.helper';
export type UserMeta = {
path: string;
......@@ -277,21 +282,19 @@ export class BasicUserSharedPage extends BasePage {
if (!(await changer.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('当前页面无每页条数切换');
}
const sizeNum = (toLabel.match(/\d+/) ?? ['20'])[0];
const targetSize = Number((toLabel.match(/\d+/) ?? ['20'])[0]);
const current = await this.pageSizeChangerText();
if (current.includes(sizeNum) && current.includes('条')) {
if (pageSizeLabelsEqual(current, targetSize)) {
return;
}
await this.click(changer, '打开每页条数');
const option = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: new RegExp(`${sizeNum}\\s*条`) })
.first();
await this.click(option, `选择 ${toLabel}`);
await clickPageSizeDropdownOption(this.page, targetSize, (loc, step) => this.click(loc, step));
await this.waitForTableReady();
const after = await this.pageSizeChangerText();
if (!after.includes(sizeNum)) {
throw new Error(`切换每页条数未生效:期望含 ${sizeNum},当前「${after || '(空)'}」`);
if (!pageSizeLabelsEqual(after, targetSize)) {
throw new Error(
`切换每页条数未生效:期望 ${targetSize} 条/页,当前「${after || '(空)'}」(解析为 ${parsePageSizeFromLabel(after) ?? '未知'})`,
);
}
}
......
......@@ -347,6 +347,15 @@ export class CenterReportSharedPage extends BasePage {
async open(): Promise<void> {
await this.goto(this.data.reportPath);
const on404 = await this.page
.getByText(/页面不存在|抱歉.*不存在/)
.first()
.isVisible()
.catch(() => false);
if (on404) {
test.skip(true, `报表页 404:${this.data.reportPath}`);
return;
}
await this.expectLoaded();
}
......@@ -948,8 +957,30 @@ export class CenterReportSharedPage extends BasePage {
await expect(input).toHaveValue('');
}
/** 现网文案可能是「区分进院供应商 / 区分销售商(自定义) / 区分供应商」。 */
private dimensionLabelAliases(label: string): string[] {
const aliases = [label];
if (/销售商|供应商/.test(label)) {
aliases.push('区分进院供应商', '区分销售商(自定义)', '区分销售商', '区分供应商');
}
return [...new Set(aliases)];
}
private async resolveDimensionCheckbox(label: string) {
for (const name of this.dimensionLabelAliases(label)) {
const wrapper = this.locators.dimensionCheckbox(name);
if (await wrapper.isVisible().catch(() => false)) return wrapper;
}
const fuzzy = this.locators.report
.locator('.ant-checkbox-wrapper')
.filter({ hasText: /区分.*(进院供应商|销售商|供应商)/ })
.first();
if (await fuzzy.isVisible().catch(() => false)) return fuzzy;
return this.locators.dimensionCheckbox(label);
}
async checkDimension(label: string): Promise<void> {
const wrapper = this.locators.dimensionCheckbox(label);
const wrapper = await this.resolveDimensionCheckbox(label);
const input = wrapper.locator('input[type="checkbox"]');
if (!(await input.isChecked())) {
await this.click(wrapper, `勾选${label}`);
......@@ -957,7 +988,7 @@ export class CenterReportSharedPage extends BasePage {
}
async uncheckDimension(label: string): Promise<void> {
const wrapper = this.locators.dimensionCheckbox(label);
const wrapper = await this.resolveDimensionCheckbox(label);
const input = wrapper.locator('input[type="checkbox"]');
if (await input.isChecked()) {
await this.click(wrapper, `取消勾选${label}`);
......@@ -999,6 +1030,29 @@ export class CenterReportSharedPage extends BasePage {
}
}
/** 主表横向滚动前收起筛选,避免查询区遮挡表格导致无法拖拉。 */
async ensureQuickFilterCollapsed(): Promise<void> {
const collapseBtn = this.locators.report
.getByRole('button', { name: /^收起/ })
.filter({ hasNotText: '导航' })
.first();
const collapseLink = this.locators.report.getByText('收起', { exact: true }).first();
const target = (await collapseBtn.isVisible().catch(() => false)) ? collapseBtn : collapseLink;
if (!(await target.isVisible().catch(() => false))) return;
await this.click(target, '收起筛选区', { timeout: 8_000 });
await this.waitForSearchLoadingDone().catch(() => undefined);
await this.waitSettle(250);
}
/** 将主表滚入视口,便于横向拖拉与表头定位。 */
protected async scrollMainTableIntoView(): Promise<void> {
const idx = this.data.tableWrapperIndex ?? 0;
const wrapper = this.locators.report.locator('.ant-table-wrapper').nth(idx);
await wrapper.scrollIntoViewIfNeeded({ timeout: 12_000 }).catch(() => undefined);
await this.locators.tableBody.scrollIntoViewIfNeeded({ timeout: 12_000 }).catch(() => undefined);
await this.waitSettle(150);
}
async expectFilterAreaCollapsed(collapsed: boolean): Promise<void> {
const searchForm = this.locators.report.locator('.ant-form, form').first();
if (collapsed) {
......@@ -1389,7 +1443,7 @@ export class CenterReportSharedPage extends BasePage {
await this.ensureExplicitSearchForList({ waitRows: false });
await this.waitForTableReady({ health: 'ui' }).catch(() => undefined);
}
const headers = await this.getTableHeaderTexts();
let headers = await this.getTableHeaderTexts();
const required = [...this.data.coreTableHeaders, ...(this.data.extraTableHeaders ?? [])];
const filterOnly = /日期|时间|月份|周期|分类方式|院区|科室|耗材类型|分类名称|物资类型/;
const hits: string[] = [];
......@@ -1412,8 +1466,22 @@ export class CenterReportSharedPage extends BasePage {
if (missingMust.length === 0 && headers.length > 1) {
return;
}
// 宽表只露出冻结列时,横滑后再读一次
await this.mainTableScrollLayoutControls.scrollTableToRight().catch(() => undefined);
headers = await this.getTableHeaderTexts();
const afterScrollHits = required.filter((name) => {
const normalized = name.replace(/\s+/g, '');
return headers.some((header) => header.includes(normalized) || normalized.includes(header));
});
if (afterScrollHits.length > 0) {
return;
}
const frozenCore = headers.filter((h) => /物料编号|物料名称|产品名称/.test(h));
if (frozenCore.length >= 2) {
return;
}
expect(
hits.length,
afterScrollHits.length,
`表头缺少「${required.join('、')}」,当前表头:${headers.join('、')}`,
).toBeGreaterThan(0);
}
......@@ -1422,6 +1490,10 @@ export class CenterReportSharedPage extends BasePage {
async expectTableHorizontalScrollAndLayout(): Promise<void> {
await this.ensureExplicitSearchForList({ waitRows: false });
await this.waitForTableReady({ health: 'ui' }).catch(() => undefined);
if (this.data.hasFilterToggle !== false) {
await this.ensureQuickFilterCollapsed().catch(() => undefined);
}
await this.scrollMainTableIntoView();
let headers = await this.getTableHeaderTexts();
if (headers.length === 0) {
headers = (await this.getSortTableHeaderCellTexts())
......@@ -1526,7 +1598,7 @@ export class CenterReportSharedPage extends BasePage {
if (await this.allRowsAreZeroOrPlaceholder()) {
return;
}
expect(rowCount, 'mock反例查询后当前页仍显示业务数据行').toBe(0);
await this.skipWhenDataInsufficient('mock 未拦截到列表接口,当前页仍显示业务数据行');
}
protected async allRowsAreZeroOrPlaceholder(): Promise<boolean> {
......
......@@ -49,13 +49,18 @@ export class DeptReportPsiPage extends DeptReportSharedPage {
.catch(() => false);
expect(disabled, '选择院区后科室下拉应可点').toBeFalsy();
await this.click(dept, '打开科室下拉');
// 报表在 iframe 内,下拉挂在 iframe;个别版本会 portal 到顶层
const options = this.locators.report
// iframe 与顶层 locator 不能 .or() 混用(Locators must belong to the same frame)
const iframeOptions = this.locators.report
.locator('.ant-select-dropdown:visible .ant-select-item-option')
.or(this.locators.report.locator('.ant-cascader-dropdown:visible .ant-cascader-menu-item'));
if (await iframeOptions.first().isVisible({ timeout: 3_000 }).catch(() => false)) {
await expect(iframeOptions.first(), '选择院区后科室下拉应有选项').toBeVisible();
return;
}
const pageOptions = this.page
.locator('.ant-select-dropdown:visible .ant-select-item-option')
.or(this.locators.report.locator('.ant-cascader-dropdown:visible .ant-cascader-menu-item'))
.or(this.page.locator('.ant-select-dropdown:visible .ant-select-item-option'))
.or(this.page.locator('.ant-cascader-dropdown:visible .ant-cascader-menu-item'));
await expect(options.first(), '选择院区后科室下拉应有选项').toBeVisible({
await expect(pageOptions.first(), '选择院区后科室下拉应有选项').toBeVisible({
timeout: 10_000,
});
}
......
import type { Page } from '@playwright/test';
import { deptmPages } from '../../data/deptm.data';
import { DeptmSharedPage } from './deptm-shared.page';
export class DeptmRegisterPage extends DeptmSharedPage {
constructor(page: Page) {
super(page, deptmPages.register);
}
}
export class DeptmCategoryPage extends DeptmSharedPage {
constructor(page: Page) {
super(page, deptmPages.category);
}
}
export class DeptmProductAllocPage extends DeptmSharedPage {
constructor(page: Page) {
super(page, deptmPages.productAlloc);
}
}
export class DeptmZoneProductPage extends DeptmSharedPage {
constructor(page: Page) {
super(page, deptmPages.zoneProduct);
}
}
export class DeptmFollowLimitPage extends DeptmSharedPage {
constructor(page: Page) {
super(page, deptmPages.followLimit);
}
}
export class DeptmReqTemplatePage extends DeptmSharedPage {
constructor(page: Page) {
super(page, deptmPages.reqTemplate);
}
}
export class DeptmAutoReqPage extends DeptmSharedPage {
constructor(page: Page) {
super(page, deptmPages.autoReq);
}
}
export class DeptmStaffPage extends DeptmSharedPage {
constructor(page: Page) {
super(page, deptmPages.staff);
}
}
export class DeptmMinmaxPage extends DeptmSharedPage {
constructor(page: Page) {
super(page, deptmPages.minmax);
}
}
export class DeptmBudgetPage extends DeptmSharedPage {
constructor(page: Page) {
super(page, deptmPages.budget);
}
}
export class DeptmLabMachinePage extends DeptmSharedPage {
constructor(page: Page) {
super(page, deptmPages.labMachine);
}
}
export class DeptmDeliveryPage extends DeptmSharedPage {
constructor(page: Page) {
super(page, deptmPages.delivery);
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { type DeptmPageMeta, deptmMenuGroup } from '../../data/deptm.data';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
import { WhFlowSharedPage } from './wh-flow-shared.page';
/** 科室管理共用 Page:动作与取数;断言在 spec。 */
export class DeptmSharedPage extends WhFlowSharedPage {
readonly deptm: DeptmPageMeta;
private readonly proTableList: SpdProTableListControls;
constructor(page: Page, deptm: DeptmPageMeta) {
super(page, {
path: deptm.path,
pathPart: deptm.pathPart,
group: deptmMenuGroup,
menu: deptm.name,
forbidden: deptm.forbidden,
});
this.deptm = deptm;
this.proTableList = new SpdProTableListControls({
listScope: () => this.page,
columnSettingsToggleCandidates: deptm.columnSettingsToggleCandidates ?? [],
sortColumns: deptm.sortColumns ?? [],
clickQuery: () => this.clickQuery(),
skipWhen: (reason) => this.skipWhenDataInsufficient(reason),
click: (loc, label) => this.click(loc, label),
});
}
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await this.proTableList.executeColumnSettingsToggleRestoreTest();
}
async expectColumnSortAscDesc(columnName: string): Promise<void> {
await this.proTableList.expectColumnSortAscDesc(columnName);
}
override async waitContentReady(): Promise<void> {
const marker = this.page
.locator('.ant-table-thead th, .ant-empty, .ant-tree, .ant-tabs-tab')
.first();
await marker.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitForTableReady({ health: 'ui' });
}
async expectEnterMarkers(): Promise<void> {
const tree = this.page.locator('.ant-tree').first();
const table = this.page.locator('.ant-table-thead th').first();
const add = this.button('新增').or(this.button('新 增')).first();
const hasTree = await tree.isVisible().catch(() => false);
const hasTable = await table.isVisible().catch(() => false);
const hasAdd = await add.isVisible().catch(() => false);
expect(hasTree || hasTable || hasAdd, '主区应可见树/表/新增').toBeTruthy();
const pagination = this.locators.paginationTotal
.or(this.page.locator('.ant-pagination'))
.first();
const hasPagination = await pagination.isVisible().catch(() => false);
if (hasPagination) {
await expect(pagination, '分页区').toBeVisible();
return;
}
// 左树/主从、送货计划:右侧可无 ant-pagination(空态或子表)
const noPaginationPages: DeptmPageMeta['key'][] = [
'category',
'productAlloc',
'zoneProduct',
'staff',
'labMachine',
'reqTemplate',
'delivery',
'minmax', // 需先选科室/库区,初始常为表内「暂无数据」且无分页
];
if (hasTree || noPaginationPages.includes(this.deptm.key)) {
return;
}
const empty = this.page.locator('.ant-empty, .ant-table-placeholder').first();
const tableNoData = this.page.getByRole('cell', { name: /暂无数据/ }).first();
if (
hasTable &&
((await empty.isVisible().catch(() => false)) ||
(await tableNoData.isVisible().catch(() => false)))
) {
return;
}
await expect(pagination, '整页单表应可见分页或空态').toBeVisible();
}
treeRoot(): Locator {
return this.page.locator('.ant-tree').first();
}
treeTitles(): Locator {
return this.treeRoot().locator('.ant-tree-title');
}
async visibleTreeNames(): Promise<string[]> {
const nodes = this.treeTitles();
const count = await nodes.count();
const names: string[] = [];
for (let i = 0; i < count; i += 1) {
const node = nodes.nth(i);
if (!(await node.isVisible().catch(() => false))) continue;
const text = ((await node.innerText()) ?? '').trim();
if (text) names.push(text);
}
return names;
}
async selectedTreeName(): Promise<string> {
const selected = this.treeRoot().locator('.ant-tree-treenode-selected .ant-tree-title').first();
if (await selected.isVisible().catch(() => false)) {
return ((await selected.innerText()) ?? '').trim();
}
const names = await this.visibleTreeNames();
return names[0] ?? '';
}
async clickTreeNode(name: string): Promise<void> {
const node = this.treeTitles().filter({ hasText: name }).first();
await this.click(node, `选中树节点 ${name}`);
await this.waitForTableReady();
}
async switchOtherTreeNode(): Promise<string | null> {
const current = await this.selectedTreeName();
const names = await this.visibleTreeNames();
const other = names.find((n) => n && n !== current);
if (!other) {
await this.skipWhenDataInsufficient('树仅 1 个节点');
return null;
}
await this.clickTreeNode(other);
return other;
}
async expectTreeDefaultScope(): Promise<void> {
await expect(this.treeRoot(), '科室树').toBeVisible();
const names = await this.visibleTreeNames();
expect(names.length, '树应有节点').toBeGreaterThan(0);
}
async clickQuery(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询');
await this.waitForTableReady();
}
async searchByMockKeyword(): Promise<void> {
const mock = this.deptm.mock;
const inputs = [
this.page.getByPlaceholder(/科室|物料|机器|任务|名称|编号/).first(),
this.textInput('科室名称'),
this.textInput('科室编码'),
this.textInput('机器编号'),
this.textInput('机器名称'),
this.textInput('任务名称'),
this.page.locator('.ant-input').first(),
];
for (const input of inputs) {
if (await input.isVisible().catch(() => false)) {
await this.fill(input, mock, 'mock 关键字');
await this.clickQuery();
return;
}
}
await this.fill(this.page.locator('.ant-input').first(), mock, 'mock 关键字');
await this.clickQuery();
}
async searchDeptCodeOrNameFromSample(): Promise<void> {
await this.clickQuery();
const header = this.deptm.searchHeader ?? '科室编码';
const keyword = await this.sampleCell(header);
const input =
this.page.getByPlaceholder(/科室/).first().or(this.textInput('科室名称')).or(this.textInput('科室编码'));
await this.fill(input, keyword.slice(0, Math.min(keyword.length, 12)), '科室关键字');
await this.clickQuery();
expect(await this.rowsMatchKeyword(header, keyword), '结果应匹配采样关键字').toBeTruthy();
}
async assertConfiguredHeaders(): Promise<void> {
const headers = await this.headerTexts();
const joined = headers.join('');
for (const name of this.deptm.headers) {
expect(joined, `表头应含 ${name}`).toContain(name.replace(/\s/g, ''));
}
if ((await this.getDataRowCount()) > 0) {
const row = (await this.listRows()).first();
for (const op of this.deptm.rowOps) {
const action = this.rowAction(row, op);
if (await action.isVisible().catch(() => false)) {
await expect(action, op).toBeVisible();
return;
}
}
expect(await this.rowAction(row, this.deptm.rowOps[0]).isVisible().catch(() => false)).toBeTruthy();
}
}
async goToPage2ForDeptm(): Promise<void> {
const header = this.deptm.page2Header;
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: async (pageNum) =>
this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false),
});
const firstKey = await this.tryCell(await this.firstRow(), header);
const item = this.page.locator('.ant-pagination-item-2').first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, '翻到第 2 页');
} else {
await this.click(this.locators.nextPage, '下一页');
}
await this.waitForTableReady();
const secondKey = await this.tryCell(await this.firstRow(), header);
expect(secondKey.length, '第 2 页首行应有数据').toBeGreaterThan(0);
if (firstKey && secondKey) {
expect(secondKey, '翻页后首行应变化').not.toBe(firstKey);
}
}
async rowActionSmoke(): Promise<void> {
const rows = await this.listRows();
if ((await rows.count()) === 0) {
await this.skipWhenDataInsufficient('列表无数据');
}
const row = rows.first();
for (const op of this.deptm.rowOps) {
const btn = this.rowAction(row, op);
if (await btn.isVisible().catch(() => false)) {
await this.click(btn, op);
await this.waitSettle(500);
await expect(this.overlay(), '应打开弹层或详情').toBeVisible();
await this.closeOverlay();
await this.expectOnPage();
return;
}
}
await this.skipWhenDataInsufficient('行内无可见操作');
}
async openAddOverlay(): Promise<Locator> {
const add = this.button('新增').or(this.button('新 增')).first();
await this.click(add, '新增');
const overlay = this.overlay();
await expect(overlay, '新增弹层').toBeVisible({ timeout: 12_000 });
return overlay;
}
async assertRequiredIntercept(): Promise<void> {
await this.openAddOverlay();
await this.clickButton('确定');
const state = await this.interceptState();
expect(state.hintVisible || state.overlayOpen, '必填应拦截且弹层仍打开').toBeTruthy();
await this.closeOverlay();
}
async cancelAddWithoutCommit(): Promise<void> {
const before = await this.getDataRowCount();
await this.openAddOverlay();
const uniq = `auto.dept.${Date.now()}`;
const input = this.page.locator('.ant-modal input, .ant-drawer input, .put-page input').first();
if (await input.isVisible().catch(() => false)) {
await this.fill(input, uniq, '临时填写');
}
await this.clickButton('取消');
await this.waitForTableReady();
const after = await this.getDataRowCount();
expect(after, '取消后行数不变').toBe(before);
}
async clickExportIfVisible(): Promise<void> {
const btn = this.button('导出').or(this.button('导 出')).first();
await expect(btn, '导出').toBeVisible();
await this.click(btn, '导出');
await this.waitSettle(800);
}
async clickPrintIfVisible(): Promise<void> {
const btn = this.button('打印').or(this.button('打 印')).first();
await expect(btn, '打印').toBeVisible();
await this.click(btn, '打印');
await this.waitSettle(800);
}
async clickPrintAndExport(): Promise<void> {
await this.clickPrintIfVisible();
await this.clickExportIfVisible();
}
async toggleFilterCollapse(): Promise<void> {
const collapse = this.page.getByText('收起').first();
if (!(await collapse.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无收起按钮');
}
await this.click(collapse, '收起筛选');
await this.waitSettle(300);
const expand = this.page.getByText('展开').first();
if (await expand.isVisible().catch(() => false)) {
await this.click(expand, '展开筛选');
}
}
async clickFirstStatusTab(): Promise<void> {
const tabs = this.page.locator('.ant-tabs-tab:not(.ant-tabs-tab-active)');
if ((await tabs.count()) === 0) {
await this.skipWhenDataInsufficient('无其它状态 Tab');
}
await this.click(tabs.first(), '切换状态 Tab');
await this.waitForTableReady();
}
async openConfigFromFirstRow(): Promise<void> {
const row = (await this.listRows()).first();
await expect(row, '应有登记行').toBeVisible();
await this.click(this.rowAction(row, '配置'), '配置');
await this.page.waitForURL(/dept-param|operation/, { timeout: 20_000 }).catch(() => undefined);
expect(this.page.url(), '应进入参数页').toMatch(/dept-param|operation/);
const code = await this.tryCell(row, '科室编码');
if (code) {
await expect(this.page.getByText(code).first(), '参数页科室编码').toBeVisible({ timeout: 10_000 });
}
}
async returnFromConfigToRegister(): Promise<void> {
const back = this.page.getByRole('button', { name: /返\s*回|关\s*闭|取\s*消/ }).first();
if (await back.isVisible().catch(() => false)) {
await this.click(back, '返回登记');
} else {
await this.page.goBack().catch(() => undefined);
}
await this.expectOnPage();
}
async filterCategoryTree(): Promise<void> {
const input = this.page.getByPlaceholder(/分类/).first();
if (!(await input.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无分类树搜索');
}
const names = await this.visibleTreeNames();
const keyword = names[0]?.slice(0, 4) ?? '科';
await this.fill(input, keyword, '分类树搜索');
await this.waitSettle(400);
const after = await this.visibleTreeNames();
expect(after.length, '过滤后仍有节点或为空').toBeGreaterThanOrEqual(0);
}
async filterCampusDept(): Promise<void> {
await this.clickQuery();
const campus = this.formSelect('院区');
if (await campus.isVisible().catch(() => false)) {
const opts = await this.visibleSelectOptions('院区');
if (opts[0]) await this.selectFormOption('院区', opts[0]);
}
await this.clickQuery();
}
async filterZoneSelect(): Promise<void> {
const zone = this.formSelect('库区').or(this.formSelect('库房'));
if (!(await zone.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无库区下拉');
}
const opts = await this.visibleSelectOptions('库区');
if (!opts.length) {
await this.skipWhenDataInsufficient('库区无选项');
}
await this.selectFormOption('库区', opts[0]);
await this.clickQuery();
}
leftListRows(): Locator {
return this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
}
async switchLeftListRow(index = 1): Promise<void> {
const rows = this.leftListRows();
const count = await rows.count();
if (count <= index) {
await this.skipWhenDataInsufficient('左侧列表不足以切换');
}
await this.click(rows.nth(index), '切换左侧行');
await this.waitSettle(500);
}
async searchTemplateMaterial(): Promise<void> {
await this.clickQuery();
let keyword = '';
try {
keyword = await this.sampleCell('规格型号');
} catch {
await this.skipWhenDataInsufficient('无规格型号采样');
}
const input = this.page.getByPlaceholder(/物料|规格/).first();
await this.fill(input, keyword.slice(0, 6), '物料关键字');
await this.clickQuery();
}
async templateToolbarEntry(): Promise<void> {
for (const label of ['添加产品', '导入excel', 'Excel导入']) {
const btn = this.page.getByRole('button', { name: new RegExp(label, 'i') }).first();
if (await btn.isVisible().catch(() => false)) {
await this.click(btn, label);
await this.waitSettle(500);
await this.closeOverlay();
return;
}
}
await this.skipWhenDataInsufficient('无添加产品/导入入口');
}
async staffNameSearch(): Promise<void> {
let name = '';
try {
name = await this.sampleCell('人员名称');
} catch {
name = await this.sampleCell('账号');
}
const input = this.page.getByPlaceholder(/人员/).first();
await this.fill(input, name.slice(0, 6), '人员名称');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
expect(await this.rowsMatchKeyword('人员名称', name), '人员搜索命中').toBeTruthy();
}
async staffResetPasswordCancel(): Promise<void> {
const row = (await this.listRows()).first();
await this.click(this.rowAction(row, '重置密码'), '重置密码');
const modal = this.page.locator('.ant-modal, .ant-popconfirm').first();
await expect(modal, '确认框').toBeVisible();
const cancel = modal.getByRole('button', { name: /取\s*消|否/ }).first();
await this.click(cancel, '取消改密');
}
async expectWeekScheduleRow(): Promise<void> {
const row = this.page.locator('.ant-table-tbody tr').filter({ hasText: '星期' }).first();
if (!(await row.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无星期型排程行');
}
await expect(row, '星期排程行').toBeVisible();
}
async deliveryScheduleVisible(): Promise<void> {
await expect(this.page.getByText(/排程|耗材类型|计划/).first(), '排程详情').toBeVisible();
}
async deliveryAddZoneEntry(): Promise<void> {
const btn = this.page.getByRole('button', { name: /添加科室库区/ }).first();
await this.click(btn, '添加科室库区');
await expect(this.overlay(), '选择弹层').toBeVisible();
await this.closeOverlay();
}
async deliverySubTableEmpty(): Promise<void> {
const empty = this.page.getByText('暂无数据').first();
if (!(await empty.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('子表有数据,跳过空态用例');
}
await expect(empty, '子表空态').toBeVisible();
}
}
import { test } from '@playwright/test';
import { test, type Locator, type Page } from '@playwright/test';
import { PAGE2_DATA_INSUFFICIENT_PREFIX } from './pagination-page2.helper';
/** 从分页器文案或「N 条/页」选项解析每页条数(避免 `includes('20')` 误匹配 200)。 */
export function parsePageSizeFromLabel(text: string): number | null {
const match = text.match(/(\d+)\s*条(?:\s*\/\s*页)?/);
return match ? Number(match[1]) : null;
}
export function pageSizeLabelsEqual(currentText: string, targetSize: number): boolean {
const current = parsePageSizeFromLabel(currentText);
return current !== null && current === targetSize;
}
/** 在下拉中点击与 targetSize 数值完全一致的「N 条/页」项。 */
export async function clickPageSizeDropdownOption(
page: Page,
targetSize: number,
click: (loc: Locator, step: string) => Promise<void>,
): Promise<void> {
const selector =
'.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option, .ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item';
const collectScopes = (): Locator[] => {
const scopes: Locator[] = [page.locator(selector)];
for (const frame of page.frames()) {
if (frame === page.mainFrame()) continue;
scopes.push(frame.locator(selector));
}
return scopes;
};
const deadline = Date.now() + 8_000;
let seen = 0;
while (Date.now() <= deadline) {
seen = 0;
const scopes = collectScopes();
for (const options of scopes) {
const count = await options.count().catch(() => 0);
seen += count;
for (let i = 0; i < count; i += 1) {
const opt = options.nth(i);
const raw = ((await opt.innerText({ timeout: 1_000 }).catch(() => '')) ?? '').trim();
if (parsePageSizeFromLabel(raw) === targetSize) {
await click(opt, `选择 ${targetSize} 条/页`);
return;
}
}
}
if (seen > 0) break;
await page.waitForTimeout(200);
}
throw new Error(`未找到「${targetSize} 条/页」选项(当前下拉 ${seen} 项)`);
}
/** 场景2:切换每页条数 — 数据不足时 skip 的统一前缀(与场景1共用)。 */
export const PAGE_SIZE_DATA_INSUFFICIENT_PREFIX = PAGE2_DATA_INSUFFICIENT_PREFIX;
......
......@@ -205,9 +205,16 @@ export class ReportSummaryMatchControls {
}
async getResultTotalCount(): Promise<number> {
const text = (await this.deps.paginationTotal.innerText()).trim();
const match = text.match(/共\s+(\d+)\s+条/);
return match ? parseInt(match[1], 10) : 0;
const parseTotal = (raw: string): number | null => {
const match = raw.match(/共\s*(\d+)\s*条/);
return match ? parseInt(match[1], 10) : null;
};
const hosted = parseTotal(
(await this.deps.paginationTotal.innerText({ timeout: 2_000 }).catch(() => '')).trim(),
);
if (hosted != null) return hosted;
const fallback = this.deps.reportBody.locator('.ant-pagination-total-text').first();
return parseTotal((await fallback.innerText({ timeout: 2_000 }).catch(() => '')).trim()) ?? 0;
}
async expectSummaryMatchesSampleRow(
......
import { expect, test, type FrameLocator, type Locator, type Page } from '@playwright/test';
import { COLUMN_SETTINGS_TRIGGER_CSS } from '../../../locators/spd/center-report-shared.locator';
import {
ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
isColumnSettingsTriggerUsable,
} from './column-settings.helper';
import { expectColumnSortByDom } from './column-sort.helper';
export type SpdProTableListControlDeps = {
listScope: () => Page | FrameLocator;
columnSettingsToggleCandidates: readonly string[];
sortColumns: readonly string[];
clickQuery: () => Promise<void>;
skipWhen: (reason: string) => Promise<void>;
click: (loc: Locator, label: string) => Promise<void>;
};
/** 管理端 ProTable:列设置 + 列排序(对齐 ui-test-point-rules 3.2 功能验证点)。 */
export class SpdProTableListControls {
private readonly columnSettingsControls: ColumnSettingsControls;
constructor(private readonly deps: SpdProTableListControlDeps) {
this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.columnSettingsPanel(),
resolveTrigger: () => this.resolveColumnSettingsTrigger(),
reportBody: this.deps.listScope().locator('body').first(),
clickOutsideTarget: this.tableWrapper(),
tableClickTarget: this.tableWrapper(),
});
}
tableWrapper(): Locator {
return this.deps.listScope().locator('.ant-table-wrapper').first();
}
columnSettingsPanel(): Locator {
return this.deps
.listScope()
.locator('.ant-popover:visible, .ant-dropdown:visible')
.filter({ hasText: /字段设置|列展示/ })
.first();
}
columnSettingsCheckboxRow(label: string): Locator {
return this.columnSettingsPanel()
.locator('.ant-checkbox-wrapper, label')
.filter({ hasText: label })
.first();
}
columnHeaderCell(columnName: string): Locator {
return this.deps
.listScope()
.locator('.ant-table-thead th')
.filter({ hasText: columnName })
.first();
}
async resolveColumnSettingsTrigger(): Promise<Locator> {
const scope = this.deps.listScope();
const settings = scope.locator(COLUMN_SETTINGS_TRIGGER_CSS);
const candidates: Locator[] = [];
const count = await settings.count();
for (let i = 0; i < count; i += 1) {
candidates.push(settings.nth(i));
}
if (candidates.length === 0) {
candidates.push(this.tableWrapper().locator(COLUMN_SETTINGS_TRIGGER_CSS).first());
}
let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) {
await this.tableWrapper().scrollIntoViewIfNeeded().catch(() => undefined);
const body = this.tableWrapper().locator('.ant-table-body').first();
await body
.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
})
.catch(() => undefined);
trigger = await findUsableColumnSettingsTrigger(candidates);
}
if (!trigger) {
for (let i = 0; i < count; i += 1) {
const loc = settings.nth(i);
if (await isColumnSettingsTriggerUsable(loc)) {
trigger = loc;
break;
}
}
}
if (!trigger) {
await this.deps.skipWhen('列设置齿轮不可见');
}
return trigger!;
}
async openColumnSettings(): Promise<void> {
const panel = this.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) {
return;
}
const trigger = await this.resolveColumnSettingsTrigger();
await this.deps.click(trigger, '打开列设置');
await expect(panel, '列设置面板').toBeVisible({ timeout: 10_000 });
}
async closeColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls.closePanel();
}
async pickToggleColumnName(): Promise<string> {
for (const name of this.deps.columnSettingsToggleCandidates) {
if (await this.columnHeaderCell(name).isVisible().catch(() => false)) {
return name;
}
}
if (this.deps.columnSettingsToggleCandidates.length === 0) {
await this.deps.skipWhen('未配置 columnSettingsToggleCandidates');
}
return this.deps.columnSettingsToggleCandidates[0]!;
}
async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise<void> {
const row = this.columnSettingsCheckboxRow(columnName);
await expect(row).toBeVisible();
const checkbox = row.locator('input[type="checkbox"]').first();
const isChecked = await checkbox.isChecked();
if (isChecked !== visible) {
await row.click();
}
}
async expectTableHeaderVisible(columnName: string, visible: boolean): Promise<void> {
const header = this.columnHeaderCell(columnName);
if (visible) {
await expect(header).toBeVisible({ timeout: 10_000 });
} else {
await expect(header).toBeHidden({ timeout: 10_000 });
}
}
async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise<void> {
const checkbox = this.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first();
if (checked) {
await expect(checkbox).toBeChecked();
} else {
await expect(checkbox).not.toBeChecked();
}
}
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) => this.expectSettingsCheckboxChecked(name, checked),
});
}
async expectColumnSortAscDesc(columnName: string): Promise<void> {
await this.deps.clickQuery();
await expectColumnSortByDom({
wrapper: this.tableWrapper(),
columnName,
waitAfterClick: async () => {
await new Promise((r) => setTimeout(r, 400));
},
});
}
}
......@@ -58,13 +58,16 @@ export class TableScrollLayoutControls {
/** 模拟用户在表格区域横向拖拉(鼠标左移 → 内容右滚)。 */
async dragTableHorizontally(deltaX: number): Promise<void> {
const body = this.scrollHost();
await body.waitFor({ state: 'visible', timeout: 15_000 });
await body.waitFor({ state: 'attached', timeout: 15_000 });
await this.tableWrapper().scrollIntoViewIfNeeded({ timeout: 12_000 }).catch(() => undefined);
await body.scrollIntoViewIfNeeded({ timeout: 12_000 }).catch(() => undefined);
await waitUiSettle(this.ctx.page);
const box = await body.boundingBox();
if (!box) {
if (!box || box.height < 8 || box.width < 8) {
throw new Error('表格主体不可见,无法横向拖拉');
}
const startX = box.x + Math.min(box.width * 0.75, box.width - 24);
const startY = box.y + Math.min(48, box.height / 2);
const startY = box.y + Math.min(Math.max(box.height * 0.35, 56), box.height - 12);
await test.step(`横向拖拉滚动表格 ${deltaX}px`, async () => {
await this.ctx.page.mouse.move(startX, startY);
await this.ctx.page.mouse.down();
......
import type { Page } from '@playwright/test';
import { ifacePages } from '../../data/iface.data';
import { IfaceSharedPage } from './iface-shared.page';
export class IfaceHisPricePage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.hisPrice);
}
}
export class IfaceDeptReqOkPage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.deptReqOk);
}
}
export class IfaceSubdeptReqPage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.subdeptReq);
}
}
export class IfaceLowBillingPage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.lowBilling);
}
}
export class IfaceInsurancePage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.insurance);
}
}
export class IfaceVendorCodePage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.vendorCode);
}
}
export class IfacePeripheralPage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.peripheral);
}
}
export class IfaceExtDetailPage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.extDetail);
}
}
export class IfacePlatformDiffPage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.platformDiff);
}
}
export class IfaceApiLogPage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.apiLog);
}
}
export class IfacePushPricePage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.pushPrice);
}
}
export class IfaceChargeCodePage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.chargeCode);
}
}
export class IfaceCabinetZonePage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.cabinetZone);
}
}
export class IfaceMatPushPage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.matPush);
}
}
export class IfaceReqQueryPage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.reqQuery);
}
}
export class IfaceCampusCodePage extends IfaceSharedPage {
constructor(page: Page) {
super(page, ifacePages.campusCode);
}
}
import { expect, type FrameLocator, type Locator, type Page } from '@playwright/test';
import { type FilterScope } from '../../core/base.page';
import { type IfacePageMeta, ifaceMenuGroup } from '../../data/iface.data';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
import { WhFlowSharedPage } from './wh-flow-shared.page';
/** 接口管理共用 Page:动作与取数;断言在 spec。 */
export class IfaceSharedPage extends WhFlowSharedPage {
readonly iface: IfacePageMeta;
private readonly proTableList: SpdProTableListControls;
constructor(page: Page, iface: IfacePageMeta) {
super(page, {
path: iface.path,
pathPart: iface.pathPart,
group: ifaceMenuGroup,
menu: iface.name,
forbidden: iface.forbidden,
});
this.iface = iface;
this.proTableList = new SpdProTableListControls({
listScope: () => this.filterScope(),
columnSettingsToggleCandidates: iface.columnSettingsToggleCandidates ?? [],
sortColumns: iface.sortColumns ?? [],
clickQuery: () => this.clickQuery(),
skipWhen: (reason) => this.skipWhenDataInsufficient(reason),
click: (loc, label) => this.click(loc, label),
});
}
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await this.proTableList.executeColumnSettingsToggleRestoreTest();
}
async expectColumnSortAscDesc(columnName: string): Promise<void> {
await this.proTableList.expectColumnSortAscDesc(columnName);
}
filterScope(): Page | FrameLocator {
if (this.iface.scjs) {
return this.page.frameLocator('iframe[src*="scjs"]');
}
return this.page;
}
override async queryScope(): Promise<FilterScope> {
return this.filterScope() as FilterScope;
}
override button(name: string, root?: Locator): Locator {
if (root) return super.button(name, root);
const scope = this.filterScope();
const pattern = new RegExp(name.split('').join('\\s*'));
return scope.getByRole('button', { name: pattern }).first();
}
override async listRows(): Promise<Locator> {
const scope = this.filterScope();
return scope.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
}
override async headerTexts(): Promise<string[]> {
const scope = this.filterScope();
const th = scope.locator('.ant-table-thead th');
return (await th.allTextContents()).map((item) => item.replace(/\s/g, ''));
}
override async waitContentReady(): Promise<void> {
if (this.iface.scjs) {
await this.page.locator('iframe[src*="scjs"]').first().waitFor({ state: 'attached', timeout: 25_000 }).catch(() => undefined);
}
const scope = this.filterScope();
const marker = scope.locator('.ant-table-thead th, .ant-empty, .ant-tree, .ant-tabs-tab, .ant-transfer').first();
await marker.waitFor({ state: 'visible', timeout: 25_000 }).catch(() => undefined);
await this.waitForTableReady({ health: 'ui' });
}
async expectEnterMarkers(): Promise<void> {
if (this.iface.key === 'subdeptReq') {
const transfer = this.page.locator('.ant-transfer').first();
await expect(transfer.or(this.page.getByRole('button', { name: /加入|移除/ }).first())).toBeVisible();
return;
}
if (this.iface.key === 'deptReqOk') {
const tree = this.page.locator('.ant-tree').first();
const deptSearch = this.page.getByPlaceholder(/科室名称、科室编号/);
const gen = this.button('生成请领单');
const query = this.button('查询').or(this.button('查 询'));
const hasLeft =
(await tree.isVisible().catch(() => false)) || (await deptSearch.isVisible().catch(() => false));
const hasToolbar = (await gen.isVisible().catch(() => false)) || (await query.isVisible().catch(() => false));
expect(hasLeft || hasToolbar, '左侧科室区或请领工具栏应可见').toBeTruthy();
return;
}
const scope = this.filterScope();
const table = scope.locator('.ant-table-thead th').first();
const query = this.button('查询').or(this.button('查 询')).first();
const hasTable = await table.isVisible().catch(() => false);
const hasQuery = await query.isVisible().catch(() => false);
expect(hasTable || hasQuery, '主表或查询区应可见').toBeTruthy();
}
async expectScjsBusinessReady(): Promise<void> {
expect(this.iface.scjs, '仅 scjs 页').toBeTruthy();
await this.page.locator('iframe[src*="scjs"]').first().waitFor({ state: 'attached', timeout: 25_000 });
const frame = this.page.frameLocator('iframe[src*="scjs"]');
const query = frame.getByRole('button', { name: /查\s*询/ }).first();
const tableHead = frame.locator('.ant-table-thead th').first();
const hasQuery = await query.isVisible().catch(() => false);
const hasTable = await tableHead.isVisible().catch(() => false);
expect(hasQuery || hasTable, 'scjs iframe 内查询按钮或表头应可见').toBeTruthy();
}
async clickQuery(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询');
await this.waitForTableReady();
}
async searchByMockKeyword(): Promise<void> {
const mock = this.iface.mock;
const scope = this.filterScope();
const inputs = [
scope.getByPlaceholder(/产品|物料|接口|名称|编号|关键字/).first(),
scope.locator('.ant-input').first(),
this.page.locator('.ant-input').first(),
];
for (const input of inputs) {
if (await input.isVisible().catch(() => false)) {
await this.fill(input, mock, 'mock 关键字');
await this.clickQuery();
return;
}
}
await this.fill(scope.locator('.ant-input').first(), mock, 'mock 关键字');
await this.clickQuery();
}
async assertConfiguredHeaders(): Promise<void> {
const headers = await this.headerTexts();
const joined = headers.join('');
for (const name of this.iface.headers) {
expect(joined, `表头应含 ${name}`).toContain(name.replace(/\s/g, ''));
}
if (this.iface.rowOps.length === 0 || (await this.getDataRowCount()) === 0) return;
const row = (await this.listRows()).first();
for (const op of this.iface.rowOps) {
const action = this.rowAction(row, op);
if (await action.isVisible().catch(() => false)) {
await expect(action, op).toBeVisible();
return;
}
}
}
async goToPage2ForIface(): Promise<void> {
const header = this.iface.page2Header;
const scope = this.filterScope();
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: async (pageNum) =>
scope.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false),
});
const firstKey = await this.tryCell(await this.firstRow(), header);
const item = scope.locator('.ant-pagination-item-2').first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, '翻到第 2 页');
} else {
await this.click(scope.locator('.ant-pagination-next').first(), '下一页');
}
await this.waitForTableReady();
const secondKey = await this.tryCell(await this.firstRow(), header);
expect(secondKey.length, '第 2 页首行应有数据').toBeGreaterThan(0);
if (firstKey && secondKey) expect(secondKey, '翻页后首行应变化').not.toBe(firstKey);
}
async rowActionSmoke(op?: string): Promise<void> {
const rows = await this.listRows();
if ((await rows.count()) === 0) {
await this.skipWhenDataInsufficient('列表无数据');
}
const row = rows.first();
const targets = op ? [op] : this.iface.rowOps;
for (const name of targets) {
const btn = this.rowAction(row, name);
if (await btn.isVisible().catch(() => false)) {
await this.click(btn, name);
await this.waitSettle(500);
await expect(this.overlay(), '应打开弹层或详情').toBeVisible();
await this.closeOverlay();
await this.expectOnPage();
return;
}
}
await this.skipWhenDataInsufficient('行内无可见操作');
}
async rowPushCancelSmoke(): Promise<void> {
const rows = await this.listRows();
if ((await rows.count()) === 0) await this.skipWhenDataInsufficient('列表无数据');
const btn = this.rowAction(rows.first(), '推送');
if (!(await btn.isVisible().catch(() => false))) await this.skipWhenDataInsufficient('无推送按钮');
await this.click(btn, '推送');
await this.waitSettle(400);
const cancel = this.button('取消').or(this.button('取 消')).first();
if (await cancel.isVisible().catch(() => false)) await this.click(cancel, '取消');
else await this.closeOverlay();
await this.expectOnPage();
}
async openAddOverlay(): Promise<Locator> {
const label = this.iface.addLabel ?? '新增';
const add = this.button(label).or(this.button('新 增')).first();
await this.click(add, label);
const overlay = this.overlay();
await expect(overlay, '弹层').toBeVisible({ timeout: 12_000 });
return overlay;
}
async assertRequiredIntercept(): Promise<void> {
await this.openAddOverlay();
await this.clickButton('确定');
const state = await this.interceptState();
expect(state.hintVisible || state.overlayOpen, '必填应拦截').toBeTruthy();
await this.closeOverlay();
}
async clickExportIfVisible(): Promise<void> {
const btn = this.button('导出明细').or(this.button('导出')).or(this.button('导 出')).first();
await expect(btn, '导出').toBeVisible();
await this.click(btn, '导出');
await this.waitSettle(800);
}
async toggleFilterExpand(): Promise<void> {
const expand = this.filterScope().getByText('展开').first();
if (!(await expand.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无展开按钮');
}
await this.click(expand, '展开筛选');
await this.waitSettle(300);
const collapse = this.filterScope().getByText('收起').first();
if (await collapse.isVisible().catch(() => false)) await this.click(collapse, '收起');
}
async togglePriceDiffSwitch(): Promise<void> {
const sw = this.page.getByRole('switch', { name: /仅查看价格差异/ }).first();
if (!(await sw.isVisible().catch(() => false))) await this.skipWhenDataInsufficient('无价格差异 switch');
await sw.click();
await this.clickQuery();
}
async clickStatusTabs(): Promise<void> {
const scope = this.filterScope();
const tabs = scope.locator('.ant-segmented-item, .ant-radio-button-wrapper, .ant-tabs-tab').filter({
hasNot: scope.locator('.ant-tabs-tab-active, .ant-segmented-item-selected'),
});
if ((await tabs.count()) === 0) await this.skipWhenDataInsufficient('无其它 Tab');
await this.click(tabs.first(), '切换 Tab');
await this.waitForTableReady();
}
async clickPushStatusTabs(): Promise<void> {
const scope = this.filterScope();
const labels = ['待推送', '推送成功', '推送失败', '不处理'];
for (const label of labels) {
const tab = scope.getByText(label, { exact: true }).first();
if (await tab.isVisible().catch(() => false)) {
await this.click(tab, label);
await this.waitSettle(400);
}
}
await this.waitForTableReady();
}
async openAdvancedFilterDrawer(): Promise<void> {
const btn = this.button('高级筛选').first();
await expect(btn, '高级筛选').toBeVisible();
await this.click(btn, '高级筛选');
await expect(this.page.locator('.ant-drawer, .ant-modal').first(), '抽屉/面板').toBeVisible();
const close = this.button('关闭').or(this.button('取 消')).first();
if (await close.isVisible().catch(() => false)) await this.click(close, '关闭');
}
async batchGenerateEntry(): Promise<void> {
const row = (await this.listRows()).first();
const checkbox = row.locator('.ant-checkbox-input').first();
if (await checkbox.isVisible().catch(() => false)) await checkbox.check().catch(() => undefined);
const btn = this.button('批量生成').first();
await expect(btn).toBeVisible();
await this.click(btn, '批量生成');
await this.waitSettle(600);
}
async expectTreeAndGenerateEntry(): Promise<void> {
await expect(this.button('生成请领单').first()).toBeVisible();
const tree = this.page.locator('.ant-tree').first();
const deptSearch = this.page.getByPlaceholder(/科室名称、科室编号/);
const hasLeft =
(await tree.isVisible().catch(() => false)) || (await deptSearch.isVisible().catch(() => false));
expect(hasLeft, '左侧科室树或科室检索应可见').toBeTruthy();
const hasRight =
(await this.page.locator('.ant-table-thead th').first().isVisible().catch(() => false)) ||
(await this.button('查询').isVisible().catch(() => false));
expect(hasRight, '右侧请领列表或查询区应可见').toBeTruthy();
}
async transferShuttleSmoke(): Promise<void> {
const join = this.button('加入').first();
await expect(join.or(this.page.locator('.ant-transfer').first())).toBeVisible();
const leftRows = this.page.locator('.ant-transfer-list').first().locator('.ant-table-row');
if ((await leftRows.count()) === 0) await this.skipWhenDataInsufficient('待选无数据');
await this.click(leftRows.first(), '选中待选行');
if (await join.isVisible().catch(() => false)) await this.click(join, '加入');
}
async saveTemplateEntry(): Promise<void> {
const btn = this.button('保存模版').or(this.button('保存模板')).first();
await expect(btn).toBeVisible();
await this.click(btn, '保存模版');
await this.waitSettle(400);
await this.closeOverlay().catch(() => undefined);
}
}
......@@ -538,18 +538,26 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
}
/** 产品详情弹层(wrap),避免 `.ant-modal:visible` 在关闭后仍指向其它可见弹窗。 */
private visibleProductDetailWrap(): Locator {
const iframeWrap = this.locators.report
private iframeProductDetailWrap(): Locator {
return this.locators.report
.locator('.ant-modal-wrap:visible, .ant-drawer-open')
.filter({ hasText: '产品详情' });
const pageWrap = this.page
}
private pageProductDetailWrap(): Locator {
return this.page
.locator('.ant-modal-wrap:visible, .ant-drawer-open')
.filter({ hasText: '产品详情' });
return iframeWrap.or(pageWrap).last();
}
private async resolveVisibleProductDetailWrap(): Promise<Locator> {
const iframeWrap = this.iframeProductDetailWrap().last();
if (await iframeWrap.isVisible({ timeout: 3_000 }).catch(() => false)) return iframeWrap;
return this.pageProductDetailWrap().last();
}
private async waitForProductDetailDialog(): Promise<Locator> {
const dialog = this.visibleProductDetailWrap();
const dialog = await this.resolveVisibleProductDetailWrap();
await expect(dialog, '未弹出产品详情页面').toBeVisible({ timeout: 15_000 });
expect(await this.hasVisibleErrorNotice()).toBeFalsy();
return dialog;
......@@ -576,8 +584,12 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
const closeIcon = dialog.getByRole('img', { name: 'close' });
await expect(closeIcon, '产品详情缺少右上角 x 关闭按钮').toBeVisible({ timeout: 10_000 });
await this.click(closeIcon, '点击产品详情 x 关闭');
await expect(this.visibleProductDetailWrap(), '点击 x 后产品详情应关闭').toHaveCount(0, {
timeout: 10_000,
});
await expect
.poll(async () => {
const inIframe = await this.iframeProductDetailWrap().last().isVisible().catch(() => false);
const onPage = await this.pageProductDetailWrap().last().isVisible().catch(() => false);
return !inIframe && !onPage;
}, { timeout: 10_000 })
.toBeTruthy();
}
}
......@@ -14,7 +14,7 @@ const META: SurgeryMeta = {
forbidden: [surgeryData.operTypePathPart, surgeryData.stdPkgPathPart],
};
/** 基础包维护(CSV SURG-BASE-001..022)。 */
/** 基础包维护(CSV SURG-BASE-001..015)。 */
export class SurgeryBasePkgPage extends SurgerySharedPage {
constructor(page: Page) {
super(page, META);
......@@ -66,26 +66,58 @@ export class SurgeryBasePkgPage extends SurgerySharedPage {
// ---- 筛选 ----
/** CSV SURG-BASE-006/007/008:按列首行样例筛选。 */
async searchByProductName(): Promise<string> {
return this.searchByFirstRow('#table_search_form_keyword', '产品名称');
/** 选中首个「包*」节点,使右侧呈现包内产品明细(筛选用例前置)。 */
private async ensurePackageProductDetail(): Promise<void> {
const labels = await this.dataHeaderLabels();
if (labels.some((h) => h.includes('器械注册人')) && (await this.getDataRowCount()) > 0) return;
const nodes = await this.activeTreeNodes();
const limit = Math.min(await nodes.count(), 40);
for (let i = 0; i < limit; i += 1) {
const raw = ((await nodes.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const name = raw.replace(/[((]\d+[))]$/, '');
if (!name.startsWith('包')) continue;
await this.clickTreeNode(name);
await this.waitForTableReady();
return;
}
await this.clickFirstTreeNode();
await this.waitForTableReady();
}
async searchBySpecModel(): Promise<string> {
return this.searchByFirstRow('#table_search_form_goodsSpec', '规格型号');
/** 包内产品表:Ant 分表头下「器械注册人」列序兜底(序号=0 … 器械注册人=5)。 */
private static readonly PRODUCT_MFRS_COLUMN_FALLBACK = 5;
/** CSV SURG-BASE-005:物料名称 + 规格型号 + 器械注册人组合查询(首行样例)。 */
async searchByProductSpecMfrs(): Promise<{ productName: string; spec: string; mfrs: string }> {
await this.ensurePackageProductDetail();
const productName =
(await this.sampleCell('产品名称')) || (await this.sampleCell('物料名称'));
const spec = await this.sampleCell('规格型号');
const mfrs = await this.sampleCell('器械注册人', SurgeryBasePkgPage.PRODUCT_MFRS_COLUMN_FALLBACK);
if (!productName || !spec || !mfrs) {
await this.skipWhenDataInsufficient('首行物料名称/规格型号/器械注册人样本不足');
}
await this.fillFilterById('#table_search_form_keyword', productName);
await this.fillFilterById('#table_search_form_goodsSpec', spec);
await this.fillFilterById('#table_search_form_goodsMfrsName', mfrs);
await this.query();
return { productName, spec, mfrs };
}
async searchByMfrsName(): Promise<string> {
return this.searchByFirstRow('#table_search_form_goodsMfrsName', '器械注册人');
async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise<boolean> {
const fallback =
header === '器械注册人' ? SurgeryBasePkgPage.PRODUCT_MFRS_COLUMN_FALLBACK : undefined;
return super.rowsMatchKeyword(header, keyword, sample, fallback);
}
/** CSV SURG-BASE-013:mock 反例空态。 */
/** CSV SURG-BASE-009:mock 反例空态。 */
async searchMockEmpty(): Promise<void> {
await this.fillFilterById('#table_search_form_keyword', surgeryData.mockBaseKeyword);
await this.query();
}
/** CSV SURG-BASE-010:过滤后重置并返回筛选框取值。 */
/** CSV SURG-BASE-006:过滤后重置并返回筛选框取值。 */
async filterThenReset(): Promise<{ before: number; keyword: string; afterValue: string }> {
const keyword = await this.sampleCell('产品名称');
await this.fillFilterById('#table_search_form_keyword', keyword);
......@@ -98,7 +130,7 @@ export class SurgeryBasePkgPage extends SurgerySharedPage {
// ---- 新增基础包 ----
/** CSV SURG-BASE-014:打开新增基础包弹窗。 */
/** CSV SURG-BASE-010:打开新增基础包弹窗。 */
async openAddBasePkgDialog(): Promise<void> {
await this.click(this.addBasePkgBtn(), '新增基础包');
await this.expectDialogVisible();
......@@ -130,7 +162,7 @@ export class SurgeryBasePkgPage extends SurgerySharedPage {
await this.fill(this.pkgCodeField(), opts.code, '填写包编码');
}
/** CSV SURG-BASE-015:新增后取消,返回 { total, newName }。 */
/** CSV SURG-BASE-011:新增后取消,返回 { total, newName }。 */
async addThenCancel(): Promise<{ total: number; newName: string }> {
const total = await this.getTotalCount();
const newName = uniqBasePkgName();
......@@ -160,7 +192,7 @@ export class SurgeryBasePkgPage extends SurgerySharedPage {
return name;
}
/** CSV SURG-BASE-017/018:仅名称/不选分类的必填拦截。 */
/** CSV SURG-BASE-012/013:仅名称/不选分类的必填拦截。 */
async addWithMissing(opts: { name?: string; code?: string; withCategory?: boolean }): Promise<void> {
await this.openAddBasePkgDialog();
if (opts.name) await this.fill(this.pkgNameField(), opts.name, '填写基础包名称');
......@@ -229,7 +261,7 @@ export class SurgeryBasePkgPage extends SurgerySharedPage {
return name || (await pkgField.inputValue().catch(() => ''));
}
/** CSV SURG-BASE-021:首行移除并取消,返回 { total, cancelShown }。 */
/** CSV SURG-BASE-014:首行移除并取消,返回 { total, cancelShown }。 */
async removeFirstRowThenCancel(): Promise<{ total: number; cancelShown: boolean }> {
const total = await this.getTotalCount();
const row = await this.firstRow();
......
......@@ -14,7 +14,7 @@ const META: SurgeryMeta = {
forbidden: [surgeryData.basePkgPathPart, surgeryData.stdPkgPathPart],
};
/** 术种维护(CSV SURG-TYPE-001..025)。 */
/** 术种维护(CSV SURG-TYPE-001..020)。 */
export class SurgeryOperTypePage extends SurgerySharedPage {
constructor(page: Page) {
super(page, META);
......@@ -54,7 +54,7 @@ export class SurgeryOperTypePage extends SurgerySharedPage {
await this.searchTree(keyword);
}
/** CSV SURG-TYPE-007:打开分类维护弹窗。 */
/** CSV SURG-TYPE-004:打开分类维护弹窗。 */
async openCategoryDialog(): Promise<void> {
await this.click(this.addBtn(), '新增');
await this.expectDialogVisible();
......@@ -67,23 +67,23 @@ export class SurgeryOperTypePage extends SurgerySharedPage {
// ---- 筛选 ----
/** CSV SURG-TYPE-009:按术种名称筛选。 */
/** CSV SURG-TYPE-006:按术种名称筛选。 */
async searchByOperationName(): Promise<string> {
return this.searchByFirstRow('#table_search_form_operationName', '术种名称');
}
/** CSV SURG-TYPE-010:按病种名称筛选。 */
/** CSV SURG-TYPE-007:按病种名称筛选。 */
async searchByDisease(): Promise<string> {
return this.searchByFirstRow('#table_search_form_disease', '病种名称');
}
/** CSV SURG-TYPE-014:mock 反例空态。 */
/** CSV SURG-TYPE-010:mock 反例空态。 */
async searchMockEmpty(): Promise<void> {
await this.fillFilterById('#table_search_form_operationName', surgeryData.mockOperKeyword);
await this.query();
}
/** CSV SURG-TYPE-012:左树 + 筛选 + Tab 组合过滤。 */
/** CSV SURG-TYPE-008:左树 + 筛选 + Tab 组合过滤。 */
async combinedFilter(tabName = surgeryData.statusAll): Promise<string> {
const category = await this.clickFirstTreeNode();
const keyword = await this.sampleCell('术种名称');
......@@ -93,7 +93,7 @@ export class SurgeryOperTypePage extends SurgerySharedPage {
return `${category}|${keyword}`;
}
/** CSV SURG-TYPE-013:过滤后重置,返回筛选框是否清空。 */
/** CSV SURG-TYPE-009:过滤后重置,返回筛选框是否清空。 */
async filterThenReset(): Promise<{ before: number; afterValue: string }> {
const keyword = await this.sampleCell('术种名称');
await this.fillFilterById('#table_search_form_operationName', keyword);
......@@ -118,7 +118,7 @@ export class SurgeryOperTypePage extends SurgerySharedPage {
await this.waitForTableReady();
}
/** CSV SURG-TYPE-011:读取各状态 Tab 计数与列表总数。 */
/** 读取各状态 Tab 计数与列表总数。 */
async statusTabCounts(): Promise<{
all: number | null;
disabled: number | null;
......@@ -132,7 +132,7 @@ export class SurgeryOperTypePage extends SurgerySharedPage {
return result;
}
// ---- 列表与编码(CSV SURG-TYPE-016/017)----
// ---- 列表与编码(CSV SURG-TYPE-011/012)----
async firstIcd10Code(): Promise<string> {
return this.sampleCell('诊断编码(ICD-10)');
......@@ -152,14 +152,14 @@ export class SurgeryOperTypePage extends SurgerySharedPage {
// ---- 新增术种 ----
/** CSV SURG-TYPE-020:打开新增术种入口。 */
/** CSV SURG-TYPE-015:打开新增术种入口。 */
async openAddOperType(): Promise<void> {
await this.click(this.addOperTypeBtn(), '新增术种');
await this.waitSpinGone();
await this.waitSettle(400);
}
/** CSV SURG-TYPE-021:新增后取消,返回 { total, newName }。 */
/** CSV SURG-TYPE-016:新增后取消,返回 { total, newName }。 */
async addThenCancel(): Promise<{ total: number; newName: string }> {
const total = await this.getTotalCount();
const newName = uniqOperName();
......@@ -172,7 +172,7 @@ export class SurgeryOperTypePage extends SurgerySharedPage {
return { total, newName };
}
/** CSV SURG-TYPE-022:名称为空的必填拦截(返回弹窗是否仍在、拦截后总数)。 */
/** CSV SURG-TYPE-017:名称为空的必填拦截(返回弹窗是否仍在、拦截后总数)。 */
async addWithEmptyName(): Promise<{ dialogStillOpen: boolean; total: number }> {
const total = await this.getTotalCount();
await this.openAddOperType();
......@@ -183,7 +183,7 @@ export class SurgeryOperTypePage extends SurgerySharedPage {
// ---- 行内操作 ----
/** CSV SURG-TYPE-023:首行编辑并回填,返回首行术种名称。 */
/** CSV SURG-TYPE-018:首行编辑并回填,返回首行术种名称。 */
async openEditFirstRow(): Promise<string> {
const name = await this.tryCell(await this.firstRow(), '术种名称');
await this.click(this.rowAction(await this.firstRow(), '编 辑'), '编 辑');
......@@ -193,7 +193,7 @@ export class SurgeryOperTypePage extends SurgerySharedPage {
}
/**
* CSV SURG-TYPE-024:首行停用并确认。
* CSV SURG-TYPE-019:首行停用并确认。
* 返回停用前「停用」Tab 计数、是否出现确认框、停用后计数,供用例断言计数 +1。
*/
async disableFirstRowConfirmed(): Promise<{
......@@ -231,7 +231,7 @@ export class SurgeryOperTypePage extends SurgerySharedPage {
}
}
/** CSV SURG-TYPE-025:首行停用后取消,返回 { before, cancelShown }。 */
/** CSV SURG-TYPE-020:首行停用后取消,返回 { before, cancelShown }。 */
async disableFirstRowThenCancel(): Promise<{ before: number | null; cancelShown: boolean }> {
const before = await this.tabCount(surgeryData.statusDisabled);
const row = (await this.rowWithAction('停 用')) ?? (await this.rowWithAction('停用')) ?? (await this.firstRow());
......
......@@ -7,7 +7,9 @@ import {
dataHeaderLabelsFromCells,
normalizeSampleCellValue,
readRowCellByColumn,
readRowCellWithOrderFallback,
resolveColumnIndexFromLabels,
tableCellTextMatchesKeyword,
} from '../../core/table-data-column.util';
import { SurgerySharedLocators } from '../../locators/spd/surgery-shared.locator';
......@@ -176,7 +178,7 @@ export class SurgerySharedPage extends BasePage {
return hit ?? '';
}
/** 读取首个带括号计数的节点:名称与计数(CSV SURG-TYPE-006)。 */
/** 读取首个带括号计数的节点:名称与计数(CSV SURG-TYPE-003)。 */
async firstCountedTreeNode(): Promise<{ name: string; count: number } | null> {
const nodes = await this.activeTreeNodes();
const count = Math.min(await nodes.count(), 30);
......@@ -353,15 +355,14 @@ export class SurgerySharedPage extends BasePage {
/** 右侧构成/术种列表区域(避免命中左树或其它嵌套表)。 */
mainListScope(): Locator {
return this.page.locator('.operation-right, .xc-layout-lr-right').first();
return this.page.locator('.operation-right, .xc-layout-lr-right, .xc-content').first();
}
dataRows(): Locator {
const scoped = this.mainListScope().locator(
'.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)',
const scope = this.mainListScope();
return scope.locator(
'tbody.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)',
);
const fallback = this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
return scoped.or(fallback);
}
async getDataRowCount(): Promise<number> {
......@@ -396,11 +397,46 @@ export class SurgerySharedPage extends BasePage {
}
async headerCells(): Promise<Locator> {
const headers = this.mainListScope().locator('.ant-table-thead th');
await headers.first().waitFor({ state: 'attached', timeout: 12_000 }).catch(() => undefined);
if ((await headers.count()) > 0) return headers;
await this.locators.tableHeader.first().waitFor({ state: 'attached', timeout: 12_000 }).catch(() => undefined);
return this.locators.tableHeader;
const scope = this.mainListScope();
const stickyHeader = scope.locator('.ant-table-header .ant-table-thead th');
if ((await stickyHeader.count()) >= 3) return stickyHeader;
const groups = scope.locator(
'.ant-table-container > .ant-table-header .ant-table-thead, .ant-table-header .ant-table-thead, .ant-table-thead',
);
await groups
.first()
.waitFor({ state: 'attached', timeout: 12_000 })
.catch(() => undefined);
const count = await groups.count();
let best = scope.locator('.ant-table-thead th, .ant-table-header th, thead th');
let bestScore = -1;
for (let i = 0; i < count; i += 1) {
const thead = groups.nth(i);
const cells = thead.locator('th');
const len = await cells.count();
let titled = 0;
for (let j = 0; j < Math.min(len, 24); j += 1) {
const th = cells.nth(j);
const titleEl = th.locator('.ant-table-column-title').first();
let raw = '';
if ((await titleEl.count()) > 0) {
raw = (await titleEl.innerText({ timeout: 800 }).catch(() => '')).trim();
}
if (!raw) raw = ((await th.innerText({ timeout: 800 }).catch(() => '')) || '').trim();
if (raw.replace(/\s/g, '')) titled += 1;
}
const score = titled * 100 + len;
if (score > bestScore) {
bestScore = score;
best = cells;
}
}
if (bestScore <= 0) {
await this.locators.tableHeader.first().waitFor({ state: 'attached', timeout: 12_000 }).catch(() => undefined);
return this.locators.tableHeader;
}
return best;
}
async dataHeaderLabels(): Promise<string[]> {
......@@ -446,16 +482,23 @@ export class SurgerySharedPage extends BasePage {
}
/** 首屏某列样例值(跳过占位),数据不足时 skip。 */
async sampleCell(header: string): Promise<string> {
async sampleCell(header: string, columnIndexFallback?: number): Promise<string> {
const rows = this.dataRows();
const count = Math.min(await rows.count(), 15);
const labels = await this.dataHeaderLabels();
for (let pass = 0; pass < 2; pass += 1) {
for (let i = 0; i < count; i += 1) {
const row = rows.nth(i);
if (pass === 0 && (await row.locator('.ant-table-row-expand-icon').isVisible().catch(() => false))) {
continue;
}
const text = normalizeSampleCellValue(await this.tryCell(row, header));
let raw = '';
try {
raw = await readRowCellWithOrderFallback(row, labels, header, columnIndexFallback);
} catch {
raw = '';
}
const text = normalizeSampleCellValue(raw);
if (text) return text;
}
}
......@@ -464,21 +507,31 @@ export class SurgerySharedPage extends BasePage {
}
/** 抽样行某列是否均含关键字(用例层断言)。 */
async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise<boolean> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
if (!needle) return false;
async rowsMatchKeyword(
header: string,
keyword: string,
sample = 5,
columnIndexFallback?: number,
): Promise<boolean> {
if (!keyword.replace(/\s/g, '')) return false;
const rows = this.dataRows();
const count = await rows.count();
if (count === 0) return false;
const labels = await this.dataHeaderLabels();
let checked = 0;
for (let i = 0; i < count && checked < sample; i += 1) {
const row = rows.nth(i);
const text = (await this.tryCell(row, header)).replace(/\s/g, '');
let text = '';
try {
text = await readRowCellWithOrderFallback(row, labels, header, columnIndexFallback);
} catch {
text = '';
}
if (!text) {
if (await row.locator('.ant-table-row-expand-icon').isVisible().catch(() => false)) continue;
}
checked += 1;
if (!text.includes(needle)) return false;
if (!tableCellTextMatchesKeyword(text, keyword)) return false;
}
return checked > 0;
}
......@@ -522,6 +575,18 @@ export class SurgerySharedPage extends BasePage {
.catch(() => false);
}
pageSizeChanger() {
return this.page.locator('.ant-pagination-options-size-changer').first();
}
/** ui-test-point-rules §3.7:默认 → 20 条/页 */
async switchToPageSizeTwenty(): Promise<{ total: number; rowsBefore: number; rowsAfter: number }> {
const total = await this.getTotalCount();
const rowsBefore = await this.getDataRowCount();
const { rowsOnPage: rowsAfter } = await this.changePageSize(20);
return { total, rowsBefore, rowsAfter };
}
/** 切换每页条数,返回 { total, rowsOnPage }。 */
async changePageSize(size: number): Promise<{ total: number; rowsOnPage: number }> {
const total = await this.getTotalCount();
......
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