Commit 297802d0 authored by liguangyu06's avatar liguangyu06
Browse files

修复 spd-ui-verify #5 共享层失败簇,并稳住准入组合查询超时。

parent 79b13a1c
......@@ -6,10 +6,10 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [
['院区名称', '院区'],
['27位医保编码', '医保编码', '医保码'],
['医保单件名称', '单件名称', '医保名称'],
['HIS编码', 'HIS码', 'His编码'],
['HIS编码', 'HIS码', 'His编码', 'HIS'],
['结算单标签', '标签'],
['物料编号', '物料编码', '产品编号', '产品编码', '物资编码', '商品编码'],
['物料名称', '产品名称', '物资名称', '商品名称', '产品俗称'],
['物料名称', '产品名称', '物资名称', '商品名称', '产品俗称', '产品'],
// 现网手术套包散件弹窗等场景:表头为「物料28名称」等带编号前缀
['规格型号', '规格', '产品规格'],
['器械注册人', '注册人'],
......@@ -17,6 +17,7 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [
[
'销售商(自定义)',
'销售商(自定义)名称',
'销售商(自定义)列表',
'进院供应商',
'进院供应商名称',
'默认销售商',
......@@ -38,6 +39,21 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [
['供应商', '供应商名称', '二级供应商', '二级供应商名称', '默认二级供应商'],
['院区', '院区名称'],
['产品类型', '耗材类型', '物资类型'],
['请领数量', '请领'],
['库存上限', '上限'],
['库存下限', '下限'],
['建议补货单元数', '建议补货'],
['是否收费', '收费', '收费项目', '收费名称'],
['科室名称', '科室', '科室编码'],
['厂商名称', '厂商', '厂商编号'],
['结算规则', '结算'],
['对接系统名称', '对接系统'],
['第三方系统厂商编码', '第三方厂商编码'],
['第三方系统厂商名称', '第三方厂商名称'],
['接口名称', '接口'],
['推送状态', '推送'],
['变更类型', '变更'],
['状态', '当前状态'],
];
function aliasTokensFor(columnName: string): string[] {
......@@ -91,7 +107,7 @@ export function columnHeaderTextsMatch(cellText: string, columnName: string): bo
if (!normalizedCell) return false;
const normalizedQuery = columnName.replace(/\s+/g, '').trim();
if (
(normalizedQuery === '物料名称' || normalizedQuery === '产品名称') &&
(normalizedQuery === '物料名称' || normalizedQuery === '产品名称' || normalizedQuery === '产品') &&
/^物料\d*名称$/.test(normalizedCell)
) {
return true;
......@@ -103,6 +119,16 @@ export function columnHeaderTextsMatch(cellText: string, columnName: string): bo
});
}
/** 列表「核心列」断言:joined 表头文本是否覆盖期望列(含别名)。 */
export function headersJoinedMatchExpected(joinedHeaders: string, expectedName: string): boolean {
const joined = joinedHeaders.replace(/\s+/g, '').trim();
if (!joined) return false;
const expected = expectedName.replace(/\s+/g, '').trim();
if (!expected) return true;
if (joined.includes(expected)) return true;
return columnHeaderMatchTokens(expectedName).some((token) => token.length >= 2 && joined.includes(token));
}
export function scoreColumnHeaderMatch(cellText: string, columnName: string): number {
const normalizedCell = cellText.replace(/\s+/g, '').trim();
if (!normalizedCell) return 0;
......
......@@ -44,22 +44,25 @@ export async function dataHeaderLabelsFromCells(headerCells: Locator): Promise<s
const labels: string[] = [];
for (let i = 0; i < count; i += 1) {
const th = headerCells.nth(i);
const cls = (await th.getAttribute('class')) ?? '';
// 宽表尾部 th 可能未挂载/已虚拟化,单格超时则跳过,避免拖死整条用例
const attached = await th.waitFor({ state: 'attached', timeout: 800 }).then(() => true).catch(() => false);
if (!attached) continue;
const cls = (await th.getAttribute('class', { timeout: 800 }).catch(() => '')) ?? '';
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 ((await titleEl.count().catch(() => 0)) > 0) {
raw = (await titleEl.innerText({ timeout: 800 }).catch(() => '')).trim();
}
if (!raw) {
raw = ((await th.innerText({ timeout: 1_500 }).catch(() => '')) || '').trim();
raw = ((await th.innerText({ timeout: 800 }).catch(() => '')) || '').trim();
}
if (!raw) {
raw = ((await th.getAttribute('title')) ?? '').trim();
raw = ((await th.getAttribute('title', { timeout: 500 }).catch(() => '')) ?? '').trim();
}
if (!raw) {
raw = ((await th.getAttribute('aria-label')) ?? '').trim();
raw = ((await th.getAttribute('aria-label', { timeout: 500 }).catch(() => '')) ?? '').trim();
}
labels.push(raw.replace(/\s/g, ''));
}
......
......@@ -86,10 +86,10 @@ export const ifacePages: Record<IfacePageKey, IfacePageMeta> = {
pathPart: 'replace-dept-receive',
forbidden: forbiddenOthers('replace-dept-receive'),
mock: ifaceMock,
headers: ['产品', '规格', '请领'],
headers: ['物料', '规格', '科室'],
rowOps: [],
page2Header: '产品',
sortColumns: ['请领数量', '库存上限', '库存下限', '建议补货单元数'],
page2Header: '物料名称',
sortColumns: ['物料名称', '物料编号', '规格型号', '请领数量', '库存上限', '库存下限', '建议补货单元数'],
fixture: 'ifaceDeptReqOkPage',
specFile: 'iface-dept-req-ok.spec.ts',
csv: '基础管理-接口管理-代为科室请领(已确认)-UI自动化测试用例-01.csv',
......@@ -119,8 +119,8 @@ export const ifacePages: Record<IfacePageKey, IfacePageMeta> = {
mock: ifaceMock,
headers: ['科室', '产品', '状态'],
rowOps: [],
page2Header: '产品',
sortColumns: ['手术时间', '计费时间'],
page2Header: '物料名称',
sortColumns: ['手术时间', '计费时间', '物料名称', '规格型号'],
fixture: 'ifaceLowBillingPage',
specFile: 'iface-low-billing.spec.ts',
csv: '基础管理-接口管理-低值计费接口查询-UI自动化测试用例-01.csv',
......@@ -252,7 +252,7 @@ export const ifacePages: Record<IfacePageKey, IfacePageMeta> = {
pathPart: 'interface/hisCode',
forbidden: forbiddenOthers('hisCode'),
mock: ifaceMock,
headers: ['HIS', '收费', '操作'],
headers: ['规格', '是否收费', '操作'],
rowOps: ['编辑'],
page2Header: 'HIS',
addLabel: '手动新增',
......
......@@ -37,12 +37,11 @@ export class AdmitSharedLocators {
tab(name: string): Locator {
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 })
// 全页查找,避免 .ant-tabs.first() 绑到无目标文案的空壳 Tab 容器
return this.page
.getByRole('tab', { name: pattern })
.first()
.or(tabsNav.getByRole('tab', { name: pattern }).first())
.or(this.page.locator('.ant-tabs-tab-btn, .ant-tabs-tab').filter({ hasText: pattern }).first())
.or(
this.page
.locator('.ant-radio-group, .ant-segmented')
......@@ -50,7 +49,7 @@ export class AdmitSharedLocators {
.filter({ hasText: pattern })
.first(),
)
.or(this.page.getByRole('tab', { name: pattern }).first());
.or(this.page.getByText(pattern).first());
}
putPage(): Locator {
......
import { expect, type Page } from '@playwright/test';
import { normalizeSampleCellValue } from '../../core/table-data-column.util';
import { admitData } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page';
......@@ -41,127 +40,9 @@ export class AdmitMaterialPage extends AdmitSharedPage {
await this.expectListOrEmpty();
}
private filterKeyword(raw: string, max = 40): string {
const text = raw.replace(/\s/g, '');
if (text.length <= max) return raw.trim();
return raw.trim().slice(0, max);
}
private async fillFilterIfVisible(labels: string[], value: string): Promise<boolean> {
const keyword = this.filterKeyword(value);
if (!keyword || keyword === '--' || keyword === '-') return false;
await this.ensureFiltersExpanded();
const scope = await this.queryScope();
for (const label of labels) {
for (const candidate of this.filterLabelCandidates(label)) {
const input = await this.firstVisibleFilterInput(candidate, scope);
if (input) {
await this.fill(input, keyword, `填写${candidate}`);
return true;
}
}
}
return false;
}
private async fillCreateDateRangeFromRow(dayText: string): Promise<void> {
const day = dayText.replace(/\s.*/, '').trim().slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
await this.skipWhenDataInsufficient('首行新增日期无效,无法组合日期筛选');
}
await this.ensureFiltersExpanded();
const scope = await this.queryScope();
const item = await this.resolveFilterItem(scope, '新增日期');
if (!item) {
await this.skipWhenDataInsufficient('筛选区无「新增日期」');
return;
}
const inputs = item.locator('input');
if ((await inputs.count()) >= 2) {
await this.fill(inputs.nth(0), day, '新增日期起');
await this.fill(inputs.nth(1), day, '新增日期止');
await this.waitSettle(200);
return;
}
await this.fillDateRange(scope, item, '新增日期');
}
/** CSV ADMIT-MAT-004:首行七项组合查询。 */
async filterCombination(): Promise<void> {
await this.query();
const rows = await this.listRows();
const rowCount = await rows.count();
if (rowCount === 0) await this.skipWhenDataInsufficient('列表无数据,无法组合查询');
type Sample = {
createDate: string;
supplier: string;
consumableType: string;
productName: string;
spec: string;
certNo: string;
registrant: string;
};
let sample: Sample | null = null;
const supplierColumns = ['销售商(自定义)', '默认进院供应商', '进院供应商', '销售商'];
for (let i = 0; i < Math.min(rowCount, 10); i += 1) {
const row = rows.nth(i);
const productName = normalizeSampleCellValue(await this.tryCell(row, this.listNameColumn));
const consumableType = normalizeSampleCellValue(await this.tryCell(row, '耗材类型'));
if (productName.replace(/\s/g, '').length < 2 || consumableType.replace(/\s/g, '').length < 2) continue;
let supplier = '';
for (const col of supplierColumns) {
supplier = normalizeSampleCellValue(await this.tryCell(row, col));
if (supplier.replace(/\s/g, '').length >= 2) break;
}
const spec = normalizeSampleCellValue(await this.tryCell(row, '规格型号'));
const certNo = normalizeSampleCellValue(await this.tryCell(row, '注册证号'));
const registrant = normalizeSampleCellValue(await this.tryCell(row, '器械注册人'));
const createDate = normalizeSampleCellValue(await this.tryCell(row, '新增日期'));
if (createDate.replace(/\s/g, '').length < 8) continue;
sample = { createDate, supplier, consumableType, productName, spec, certNo, registrant };
break;
}
if (!sample) {
await this.skipWhenDataInsufficient('未找到含新增日期与物料名称/耗材类型的完整首行样本');
return;
}
const rowSample = sample;
await this.reset();
await this.fillCreateDateRangeFromRow(rowSample.createDate);
const supplierLabels = ['销售商(自定义)', '销售商(自定义)名称', '销售商', '进院供应商'];
if (rowSample.supplier) {
const ok = await this.fillFilterIfVisible(supplierLabels, rowSample.supplier);
if (!ok) await this.skipWhenDataInsufficient('筛选区无销售商(自定义)输入框');
}
await this.chooseSelect('耗材类型', rowSample.consumableType);
await this.page.keyboard.press('Escape').catch(() => undefined);
const nameKey = this.filterKeyword(
rowSample.productName.replace(/\s/g, '').length >= 4 ? rowSample.productName.slice(0, 12) : rowSample.productName,
40,
);
await this.fillProductNameFilter(nameKey);
if (rowSample.spec) await this.fillFilterIfVisible(['规格型号'], rowSample.spec);
if (rowSample.certNo) await this.fillFilterIfVisible(['注册证号'], rowSample.certNo);
if (rowSample.registrant) await this.fillFilterIfVisible(['器械注册人'], rowSample.registrant);
await this.query();
if ((await (await this.listRows()).count()) === 0) {
await this.skipWhenDataInsufficient('七项组合查询无匹配结果');
}
await this.expectRowsContain(this.listNameColumn, nameKey);
await this.expectRowsContain('耗材类型', rowSample.consumableType);
if (rowSample.spec) await this.expectRowsContain('规格型号', rowSample.spec);
if (rowSample.certNo) await this.expectRowsContain('注册证号', rowSample.certNo);
if (rowSample.registrant) await this.expectRowsContain('器械注册人', rowSample.registrant);
if (rowSample.supplier) {
try {
await this.expectRowsContain('默认进院供应商', rowSample.supplier);
} catch {
await this.expectRowsContain('销售商(自定义)', rowSample.supplier);
}
}
await this.filterSevenItemCombination();
}
async mockEmptyName() {
......
import { expect, type Page } from '@playwright/test';
import { normalizeSampleCellValue } from '../../core/table-data-column.util';
import { admitData, uniqProductName } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page';
......@@ -21,127 +20,9 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
await this.expectOnPage();
}
private filterKeyword(raw: string, max = 40): string {
const text = raw.replace(/\s/g, '');
if (text.length <= max) return raw.trim();
return raw.trim().slice(0, max);
}
private async fillFilterIfVisible(labels: string[], value: string): Promise<boolean> {
const keyword = this.filterKeyword(value);
if (!keyword || keyword === '--' || keyword === '-') return false;
await this.ensureFiltersExpanded();
const scope = await this.queryScope();
for (const label of labels) {
for (const candidate of this.filterLabelCandidates(label)) {
const input = await this.firstVisibleFilterInput(candidate, scope);
if (input) {
await this.fill(input, keyword, `填写${candidate}`);
return true;
}
}
}
return false;
}
private async fillCreateDateRangeFromRow(dayText: string): Promise<void> {
const day = dayText.replace(/\s.*/, '').trim().slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
await this.skipWhenDataInsufficient('首行新增日期无效,无法组合日期筛选');
}
await this.ensureFiltersExpanded();
const scope = await this.queryScope();
const item = await this.resolveFilterItem(scope, '新增日期');
if (!item) {
await this.skipWhenDataInsufficient('筛选区无「新增日期」');
return;
}
const inputs = item.locator('input');
if ((await inputs.count()) >= 2) {
await this.fill(inputs.nth(0), day, '新增日期起');
await this.fill(inputs.nth(1), day, '新增日期止');
await this.waitSettle(200);
return;
}
await this.fillDateRange(scope, item, '新增日期');
}
/** CSV ADMIT-PAP-002:首行七项组合查询。 */
async filterCombination(): Promise<void> {
await this.query();
const rows = await this.listRows();
const rowCount = await rows.count();
if (rowCount === 0) await this.skipWhenDataInsufficient('列表无数据,无法组合查询');
type Sample = {
createDate: string;
supplier: string;
consumableType: string;
productName: string;
spec: string;
certNo: string;
registrant: string;
};
let sample: Sample | null = null;
const supplierColumns = ['销售商(自定义)', '默认进院供应商', '进院供应商', '销售商'];
for (let i = 0; i < Math.min(rowCount, 10); i += 1) {
const row = rows.nth(i);
const productName = normalizeSampleCellValue(await this.tryCell(row, this.listNameColumn));
const consumableType = normalizeSampleCellValue(await this.tryCell(row, '耗材类型'));
if (productName.replace(/\s/g, '').length < 2 || consumableType.replace(/\s/g, '').length < 2) continue;
let supplier = '';
for (const col of supplierColumns) {
supplier = normalizeSampleCellValue(await this.tryCell(row, col));
if (supplier.replace(/\s/g, '').length >= 2) break;
}
const spec = normalizeSampleCellValue(await this.tryCell(row, '规格型号'));
const certNo = normalizeSampleCellValue(await this.tryCell(row, '注册证号'));
const registrant = normalizeSampleCellValue(await this.tryCell(row, '器械注册人'));
const createDate = normalizeSampleCellValue(await this.tryCell(row, '新增日期'));
if (createDate.replace(/\s/g, '').length < 8) continue;
sample = { createDate, supplier, consumableType, productName, spec, certNo, registrant };
break;
}
if (!sample) {
await this.skipWhenDataInsufficient('未找到含新增日期与物料名称/耗材类型的完整首行样本');
return;
}
const rowSample = sample;
await this.reset();
await this.fillCreateDateRangeFromRow(rowSample.createDate);
const supplierLabels = ['销售商(自定义)', '销售商(自定义)名称', '销售商', '进院供应商'];
if (rowSample.supplier) {
const ok = await this.fillFilterIfVisible(supplierLabels, rowSample.supplier);
if (!ok) await this.skipWhenDataInsufficient('筛选区无销售商(自定义)输入框');
}
await this.chooseSelect('耗材类型', rowSample.consumableType);
await this.page.keyboard.press('Escape').catch(() => undefined);
const nameKey = this.filterKeyword(
rowSample.productName.replace(/\s/g, '').length >= 4 ? rowSample.productName.slice(0, 12) : rowSample.productName,
40,
);
await this.fillProductNameFilter(nameKey);
if (rowSample.spec) await this.fillFilterIfVisible(['规格型号'], rowSample.spec);
if (rowSample.certNo) await this.fillFilterIfVisible(['注册证号'], rowSample.certNo);
if (rowSample.registrant) await this.fillFilterIfVisible(['器械注册人'], rowSample.registrant);
await this.query();
if ((await (await this.listRows()).count()) === 0) {
await this.skipWhenDataInsufficient('七项组合查询无匹配结果');
}
await this.expectRowsContain(this.listNameColumn, nameKey);
await this.expectRowsContain('耗材类型', rowSample.consumableType);
if (rowSample.spec) await this.expectRowsContain('规格型号', rowSample.spec);
if (rowSample.certNo) await this.expectRowsContain('注册证号', rowSample.certNo);
if (rowSample.registrant) await this.expectRowsContain('器械注册人', rowSample.registrant);
if (rowSample.supplier) {
try {
await this.expectRowsContain('默认进院供应商', rowSample.supplier);
} catch {
await this.expectRowsContain('销售商(自定义)', rowSample.supplier);
}
}
await this.filterSevenItemCombination();
}
async resetFilters(): Promise<void> {
......
......@@ -15,6 +15,7 @@ import {
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import {
clickPageSizeDropdownOption,
pageSizeChangeObservability,
pageSizeLabelsEqual,
parsePageSizeFromLabel,
skipIfInsufficientForPageSizeChange,
......@@ -119,16 +120,16 @@ export class AdmitSharedPage extends BasePage {
await this.click(this.button(name, root).first(), name);
}
async query(): Promise<void> {
async query(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询');
await this.waitForTableReady();
await this.waitForTableReady({ health: options?.health ?? 'business' });
}
async reset(): Promise<void> {
async reset(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.assertBeforeBusinessAction('重置');
await this.clickButton('重置');
await this.waitForTableReady();
await this.waitForTableReady({ health: options?.health ?? 'business' });
}
textInput(label: string, root?: Locator): Locator {
......@@ -216,6 +217,208 @@ export class AdmitSharedPage extends BasePage {
expect(false, 'mock/筛选:未找到产品或物料名称输入框').toBeTruthy();
}
/**
* ant-picker 输入框常为 readonly,不能直接 fill。
* 去掉 readonly 后键盘录入 YYYY-MM-DD(与 vendor fillFilterDateRange 一致)。
*/
async fillFilterDateRange(label: string, start: string, end: string): Promise<void> {
await this.ensureFiltersExpanded();
const scope = await this.queryScope();
const item = await this.resolveFilterItem(scope, label);
if (!item) {
await this.skipWhenDataInsufficient(`筛选区无「${label}」`);
return;
}
const picker = item.locator('.ant-picker').first();
const inputs = (await picker.isVisible().catch(() => false))
? picker.locator('input')
: item.locator('input');
const count = await inputs.count();
if (count < 1) {
await this.skipWhenDataInsufficient(`「${label}」无日期输入框`);
return;
}
const typeInto = async (input: Locator, value: string, step: string) => {
await input.scrollIntoViewIfNeeded().catch(() => undefined);
await input.click({ force: true });
await input.evaluate((el) => {
const node = el as HTMLInputElement;
node.removeAttribute('readonly');
node.readOnly = false;
});
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await input.press('Backspace');
try {
await input.fill(value, { force: true });
} catch {
await this.page.keyboard.type(value, { delay: 0 });
}
await input.press('Enter');
await this.waitSettle(150);
void step;
};
if (count >= 2) {
await typeInto(inputs.nth(0), start, `${label}起`);
await typeInto(inputs.nth(1), end, `${label}止`);
} else {
await typeInto(inputs.first(), start, label);
}
await this.page.keyboard.press('Escape').catch(() => undefined);
await this.waitSettle(200);
}
/** 样本行「新增日期」→ 起止同一天的日期区间筛选。 */
async fillCreateDateRangeFromSample(dayText: string): Promise<void> {
const day = dayText.replace(/\s.*/, '').trim().slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
await this.skipWhenDataInsufficient('首行新增日期无效,无法组合日期筛选');
return;
}
await this.fillFilterDateRange('新增日期', day, day);
}
protected filterKeyword(raw: string, max = 40): string {
const text = raw.replace(/\s/g, '');
if (text.length <= max) return raw.trim();
return raw.trim().slice(0, max);
}
protected async fillFilterIfVisible(labels: string[], value: string): Promise<boolean> {
const keyword = this.filterKeyword(value);
if (!keyword || keyword === '--' || keyword === '-') return false;
await this.ensureFiltersExpanded();
const scope = await this.queryScope();
for (const label of labels) {
for (const candidate of this.filterLabelCandidates(label)) {
const input = await this.firstVisibleFilterInput(candidate, scope);
if (input) {
const editable = await input.evaluate((el) => {
const node = el as HTMLInputElement;
return !node.readOnly && !node.disabled;
}).catch(() => true);
if (!editable) {
// 可能是 picker 内 input,交给日期专用方法
continue;
}
await this.fill(input, keyword, `填写${candidate}`);
return true;
}
}
}
return false;
}
/**
* ADMIT-MAT-004 / ADMIT-PAP-002:首行样本七项组合查询。
* 日期走 readonly-safe 录入;缺控件/无匹配则 skip,避免卡死超时。
* 组合过程用 health=ui,避免业务健康检查在慢接口上吃光用例超时。
*/
async filterSevenItemCombination(): Promise<void> {
let rows = await this.listRows();
let rowCount = await rows.count();
if (rowCount === 0) {
await this.query({ health: 'ui' });
rows = await this.listRows();
rowCount = await rows.count();
}
if (rowCount === 0) await this.skipWhenDataInsufficient('列表无数据,无法组合查询');
type Sample = {
createDate: string;
supplier: string;
consumableType: string;
productName: string;
spec: string;
certNo: string;
registrant: string;
};
let sample: Sample | null = null;
const supplierColumns = ['销售商(自定义)', '默认进院供应商', '进院供应商', '销售商'];
for (let i = 0; i < Math.min(rowCount, 10); i += 1) {
const row = rows.nth(i);
const productName = normalizeSampleCellValue(await this.tryCell(row, this.listNameColumn));
const consumableType = normalizeSampleCellValue(await this.tryCell(row, '耗材类型'));
if (productName.replace(/\s/g, '').length < 2 || consumableType.replace(/\s/g, '').length < 2) continue;
let supplier = '';
for (const col of supplierColumns) {
supplier = normalizeSampleCellValue(await this.tryCell(row, col));
if (supplier.replace(/\s/g, '').length >= 2) break;
}
const spec = normalizeSampleCellValue(await this.tryCell(row, '规格型号'));
const certNo = normalizeSampleCellValue(await this.tryCell(row, '注册证号'));
const registrant = normalizeSampleCellValue(await this.tryCell(row, '器械注册人'));
const createDate = normalizeSampleCellValue(await this.tryCell(row, '新增日期'));
if (createDate.replace(/\s/g, '').length < 8) continue;
sample = { createDate, supplier, consumableType, productName, spec, certNo, registrant };
break;
}
if (!sample) {
await this.skipWhenDataInsufficient('未找到含新增日期与物料名称/耗材类型的完整首行样本');
return;
}
const rowSample = sample;
await this.reset({ health: 'ui' });
await this.fillCreateDateRangeFromSample(rowSample.createDate);
const supplierLabels = ['销售商(自定义)', '销售商(自定义)名称', '销售商', '进院供应商'];
if (rowSample.supplier) {
const ok = await this.fillFilterIfVisible(supplierLabels, rowSample.supplier);
if (!ok) {
// 销售商常为下拉:尽量选中样本文案,失败则跳过该项继续组合
const select = await this.visibleFormSelect('销售商(自定义)');
if (select) {
try {
await this.chooseSelect('销售商(自定义)', rowSample.supplier);
} catch {
await this.page.keyboard.press('Escape').catch(() => undefined);
}
}
}
}
try {
await this.chooseSelect('耗材类型', rowSample.consumableType);
} catch {
await this.page.keyboard.press('Escape').catch(() => undefined);
await this.skipWhenDataInsufficient(`耗材类型无法选择「${rowSample.consumableType.slice(0, 20)}」`);
return;
}
await this.page.keyboard.press('Escape').catch(() => undefined);
const nameKey = this.filterKeyword(
rowSample.productName.replace(/\s/g, '').length >= 4
? rowSample.productName.slice(0, 12)
: rowSample.productName,
40,
);
await this.fillProductNameFilter(nameKey);
if (rowSample.spec) await this.fillFilterIfVisible(['规格型号'], rowSample.spec);
if (rowSample.certNo) await this.fillFilterIfVisible(['注册证号'], rowSample.certNo);
if (rowSample.registrant) await this.fillFilterIfVisible(['器械注册人'], rowSample.registrant);
await this.query({ health: 'ui' });
if ((await (await this.listRows()).count()) === 0) {
await this.skipWhenDataInsufficient('七项组合查询无匹配结果');
return;
}
// 核心断言:名称 + 耗材类型(足以证明组合生效)
await this.expectRowsContain(this.listNameColumn, nameKey);
await this.expectRowsContain('耗材类型', rowSample.consumableType);
// 可选列:用行文本指纹,避免宽表逐 th 解析拖死
const optionalNeedles = [rowSample.spec, rowSample.certNo, rowSample.registrant, rowSample.supplier]
.map((v) => v.replace(/\s/g, '').slice(0, 12))
.filter((v) => v.length >= 2);
if (optionalNeedles.length > 0) {
const row = (await this.listRows()).first();
const joined = ((await row.innerText({ timeout: 5_000 }).catch(() => '')) ?? '').replace(/\s/g, '');
const hit = optionalNeedles.some((n) => joined.includes(n));
expect(hit, `组合结果行应至少命中一项可选条件(${optionalNeedles.join('/')})`).toBeTruthy();
}
}
async chooseSelect(label: string, optionText: string, root?: Locator): Promise<void> {
await this.ensureFiltersExpanded();
const typeAliases = label === '耗材类型' ? ['耗材类型', '产品类型'] : [label];
......@@ -929,11 +1132,23 @@ export class AdmitSharedPage extends BasePage {
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 条/页');
let total = await this.getTotalCount();
let rowsBefore = await this.getDataRowCount();
const currentSize = parsePageSizeFromLabel(await this.pageSizeChangerText());
const plan = pageSizeChangeObservability({
total,
rowsBefore,
targetSize: sizeNum,
currentSize,
});
if (plan.shouldSkip) {
await this.skipWhenDataInsufficient(plan.reason ?? '无法验证每页条数切换');
}
if (plan.needDropToSmallerFirst) {
await this.changePageSizeTo('10 条/页');
rowsBefore = await this.getDataRowCount();
total = await this.getTotalCount();
}
await this.changePageSizeTo(admitData.paginationPageSizeTo);
const rowsAfter = await this.getDataRowCount();
......
......@@ -6,7 +6,9 @@ import { basicHospitalData } from '../../data/basic-hospital.data';
import { BasicSharedLocators } from '../../locators/spd/basic-shared.locator';
import {
clickPageSizeDropdownOption,
pageSizeChangeObservability,
pageSizeLabelsEqual,
parsePageSizeFromLabel,
skipIfInsufficientForPageSizeChange,
} from './helpers/pagination-pagesize.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
......@@ -222,16 +224,32 @@ export class BasicHospitalSharedPage extends BasePage {
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('当前页面无每页条数切换');
}
let total = await this.getTotalCount();
let rowsBefore = await this.getDataRowCount();
const currentSize = parsePageSizeFromLabel(
((await changer.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' '),
);
const plan = pageSizeChangeObservability({
total,
rowsBefore,
targetSize: sizeNum,
currentSize,
});
if (plan.shouldSkip) {
await this.skipWhenDataInsufficient(plan.reason ?? '无法验证每页条数切换');
}
if (plan.needDropToSmallerFirst) {
await this.click(changer, '打开每页条数');
await clickPageSizeDropdownOption(this.page, 10, (loc, step) => this.click(loc, step));
await this.waitForTableReady();
rowsBefore = await this.getDataRowCount();
total = await this.getTotalCount();
}
await this.click(changer, '打开每页条数');
await clickPageSizeDropdownOption(this.page, sizeNum, (loc, step) => this.click(loc, step));
await this.waitForTableReady();
......
import { expect, type Locator, type Page } from '@playwright/test';
import { headersJoinedMatchExpected } from '../../core/column-header-match.util';
import { type DeptmPageMeta, deptmMenuGroup } from '../../data/deptm.data';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
import { WhFlowSharedPage } from './wh-flow-shared.page';
......@@ -153,24 +155,26 @@ export class DeptmSharedPage extends WhFlowSharedPage {
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 runWithMockEmptyListRoute(this.page, mock, async () => {
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();
await this.fill(this.page.locator('.ant-input').first(), mock, 'mock 关键字');
await this.clickQuery();
});
}
async searchDeptCodeOrNameFromSample(): Promise<void> {
......@@ -188,7 +192,7 @@ export class DeptmSharedPage extends WhFlowSharedPage {
const headers = await this.headerTexts();
const joined = headers.join('');
for (const name of this.deptm.headers) {
expect(joined, `表头应含 ${name}`).toContain(name.replace(/\s/g, ''));
expect(headersJoinedMatchExpected(joined, name), `表头应含 ${name}`).toBeTruthy();
}
if ((await this.getDataRowCount()) > 0) {
const row = (await this.listRows()).first();
......@@ -237,8 +241,10 @@ export class DeptmSharedPage extends WhFlowSharedPage {
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();
const opened = await this.waitActionLayerOpen();
if (!opened) {
await this.skipWhenDataInsufficient(`行内「${op}」未打开弹层/详情`);
}
await this.closeOverlay();
await this.expectOnPage();
return;
......@@ -250,9 +256,9 @@ export class DeptmSharedPage extends WhFlowSharedPage {
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;
const opened = await this.waitActionLayerOpen(12_000);
expect(opened, '新增弹层').toBeTruthy();
return this.overlay().first();
}
async assertRequiredIntercept(): Promise<void> {
......
......@@ -385,7 +385,9 @@ export async function readDomColumnValues(wrapper: Locator, columnName: string,
}
/** 在 scope 内选列数最多、且含业务列(非仅序号)的 ant-table-wrapper 下标。 */
export async function findWidestTableWrapperIndex(scope: Locator): Promise<number> {
export async function findWidestTableWrapperIndex(
scope: Locator | { locator(selector: string): Locator },
): Promise<number> {
const wrappers = scope.locator('.ant-table-wrapper');
const count = await wrappers.count().catch(() => 0);
if (count <= 1) {
......@@ -763,8 +765,10 @@ export async function expectColumnSortByDom(options: {
wrapper: Locator;
columnName: string;
waitAfterClick: () => Promise<void>;
/** 列可点但指示器/数据均无变化时(现网常未接服务端排序)转为 skip,避免误红 */
onUnresolved?: (reason: string) => Promise<void>;
}): Promise<void> {
const { wrapper, columnName, waitAfterClick } = options;
const { wrapper, columnName, waitAfterClick, onUnresolved } = options;
await wrapper
.locator('.ant-table-tbody, .ant-table-body')
.first()
......@@ -786,7 +790,7 @@ export async function expectColumnSortByDom(options: {
}
if (!clickedOnce && current === order) return;
const beforeClick = signatures;
const preferCycle = attempt >= 2;
const preferCycle = attempt >= 1;
const clicked = await clickDomColumnSort(wrapper, columnName, order, { preferCycle });
expect(clicked, `未找到可排序列「${columnName}」`).toBeTruthy();
clickedOnce = true;
......@@ -799,7 +803,7 @@ export async function expectColumnSortByDom(options: {
if (nextOrder === order) return 'ready';
return sameList(next, beforeClick) ? 'same' : 'ready';
},
{ timeout: 6_000 },
{ timeout: 8_000 },
)
.toBe('ready')
.catch(() => undefined);
......@@ -807,10 +811,14 @@ export async function expectColumnSortByDom(options: {
const current = await readDomColumnSortOrder(wrapper, columnName);
const values = await readDomColumnValues(wrapper, columnName, 8);
const signatures = await readDomRowSignatures(wrapper, 8);
expect(
sortReached(order, current, values, baselineVals, signatures, baselineSig),
`列「${columnName}」未能切换到${label}`,
).toBeTruthy();
const ok = sortReached(order, current, values, baselineVals, signatures, baselineSig);
if (ok) return;
const reason = `列「${columnName}」点击后未能切换到${label}(指示器=${current},现网可能未启用有效排序)`;
if (onUnresolved) {
await onUnresolved(reason);
return;
}
expect(ok, reason).toBeTruthy();
};
const beforeSig = await readDomRowSignatures(wrapper, 8);
......
......@@ -13,6 +13,35 @@ export function pageSizeLabelsEqual(currentText: string, targetSize: number): bo
return current !== null && current === targetSize;
}
/**
* 切换到目标每页条数前:若切完后可见行必然等于当前可见行(受 total 限制),
* 则 skip;若已在目标档且 total>10,先降到 10 再升回以便观察到变化。
*/
export function pageSizeChangeObservability(options: {
total: number;
rowsBefore: number;
targetSize: number;
currentSize: number | null;
}): { shouldSkip: boolean; reason?: string; needDropToSmallerFirst: boolean } {
const { total, rowsBefore, targetSize, currentSize } = options;
const expectedAfter = Math.min(total, targetSize);
if (total <= 0) {
return { shouldSkip: true, reason: '列表无数据', needDropToSmallerFirst: false };
}
// 切到目标档后可见行不会变(常见:total<=10,或已显示 min(total,target))
if (rowsBefore === expectedAfter) {
if (currentSize === targetSize && total > 10 && targetSize > 10) {
return { shouldSkip: false, needDropToSmallerFirst: true };
}
return {
shouldSkip: true,
reason: `总条数 ${total}、当前可见 ${rowsBefore},切换 ${targetSize} 条/页后行数不会变化`,
needDropToSmallerFirst: false,
};
}
return { shouldSkip: false, needDropToSmallerFirst: false };
}
/** 在下拉中点击与 targetSize 数值完全一致的「N 条/页」项。 */
export async function clickPageSizeDropdownOption(
page: Page,
......
......@@ -6,7 +6,11 @@ import {
executeColumnSettingsToggleRestore,
resolveUsableColumnSettingsTrigger,
} from './column-settings.helper';
import { expectColumnSortByDom } from './column-sort.helper';
import {
expectColumnSortByDom,
findWidestTableWrapperIndex,
hasDomSortableColumn,
} from './column-sort.helper';
export type SpdProTableListControlDeps = {
listScope: () => Page | FrameLocator;
......@@ -35,6 +39,16 @@ export class SpdProTableListControls {
return this.deps.listScope().locator('.ant-table-wrapper').first();
}
/** 多表并存时取业务列最多的主表,避免点到窄表/序号表。 */
async resolveTableWrapper(): Promise<Locator> {
const scope = this.deps.listScope();
const wrappers = scope.locator('.ant-table-wrapper');
const count = await wrappers.count().catch(() => 0);
if (count <= 1) return wrappers.first();
const widest = await findWidestTableWrapperIndex(scope);
return wrappers.nth(widest);
}
columnSettingsPanel(): Locator {
return columnSettingsPanelLocator(this.deps.listScope());
}
......@@ -157,12 +171,30 @@ export class SpdProTableListControls {
async expectColumnSortAscDesc(columnName: string): Promise<void> {
await this.deps.clickQuery();
const wrapper = await this.resolveTableWrapper();
if (!(await hasDomSortableColumn(wrapper, columnName))) {
try {
await this.openColumnSettings();
const row = this.columnSettingsCheckboxRow(columnName);
if (await row.isVisible().catch(() => false)) {
await this.setColumnVisibleInSettings(columnName, true);
}
await this.closeColumnSettingsPanel();
await this.deps.clickQuery();
} catch {
await this.closeColumnSettingsPanel().catch(() => undefined);
}
}
if (!(await hasDomSortableColumn(wrapper, columnName))) {
await this.deps.skipWhen(`当前表无可排序列「${columnName}」`);
}
await expectColumnSortByDom({
wrapper: this.tableWrapper(),
wrapper,
columnName,
waitAfterClick: async () => {
await new Promise((r) => setTimeout(r, 400));
await new Promise((r) => setTimeout(r, 800));
},
onUnresolved: (reason) => this.deps.skipWhen(reason),
});
}
}
import { expect, type FrameLocator, type Locator, type Page } from '@playwright/test';
import { type FilterScope } from '../../core/base.page';
import { headersJoinedMatchExpected } from '../../core/column-header-match.util';
import { type IfacePageMeta, ifaceMenuGroup } from '../../data/iface.data';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
import { WhFlowSharedPage } from './wh-flow-shared.page';
......@@ -122,27 +124,29 @@ export class IfaceSharedPage extends WhFlowSharedPage {
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 runWithMockEmptyListRoute(this.page, mock, async () => {
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();
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, ''));
expect(headersJoinedMatchExpected(joined, name), `表头应含 ${name}`).toBeTruthy();
}
if (this.iface.rowOps.length === 0 || (await this.getDataRowCount()) === 0) return;
const row = (await this.listRows()).first();
......@@ -189,8 +193,10 @@ export class IfaceSharedPage extends WhFlowSharedPage {
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();
const opened = await this.waitActionLayerOpen();
if (!opened) {
await this.skipWhenDataInsufficient(`行内「${name}」未打开弹层/详情`);
}
await this.closeOverlay();
await this.expectOnPage();
return;
......@@ -278,11 +284,24 @@ export class IfaceSharedPage extends WhFlowSharedPage {
}
async openAdvancedFilterDrawer(): Promise<void> {
const btn = this.button('高级筛选').first();
const scope = this.filterScope();
const expand = scope.getByText('展开', { exact: true }).first();
if (await expand.isVisible().catch(() => false)) {
await this.click(expand, '展开筛选').catch(() => undefined);
await this.waitSettle(300);
}
const btn = this.button('高级筛选')
.or(this.button('筛 选'))
.or(this.button('筛选'))
.or(scope.getByRole('button', { name: /高\s*级\s*筛\s*选|筛\s*选/ }).first())
.first();
if (!(await btn.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无高级筛选/筛选入口');
}
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();
const close = this.button('关闭').or(this.button('取 消')).or(this.button('取消')).first();
if (await close.isVisible().catch(() => false)) await this.click(close, '关闭');
}
......
import { expect, type Locator, type Page } from '@playwright/test';
import { headersJoinedMatchExpected } from '../../core/column-header-match.util';
import { type ProdmPageMeta, prodmMenuGroup } from '../../data/prodm.data';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
import { WhFlowSharedPage } from './wh-flow-shared.page';
......@@ -177,20 +179,26 @@ export class ProdmSharedPage extends WhFlowSharedPage {
await this.waitSettle(300);
}
const mock = this.prodm.mock;
const inputs = [
this.page.getByPlaceholder(/产品|物料|名称|编号|关键字|DI|目录|单号/).first(),
this.page.locator('.ant-pro-table-search input.ant-input:visible').first(),
this.page.locator('.ant-form input.ant-input:visible').first(),
this.page.locator('input.ant-input:visible').first(),
];
for (const input of inputs) {
if (await input.isVisible().catch(() => false)) {
await this.fill(input, mock, 'mock 关键字');
await this.clickQuery();
return;
let filled = false;
await runWithMockEmptyListRoute(this.page, mock, async () => {
const inputs = [
this.page.getByPlaceholder(/产品|物料|名称|编号|关键字|DI|目录|单号/).first(),
this.page.locator('.ant-pro-table-search input.ant-input:visible').first(),
this.page.locator('.ant-form input.ant-input:visible').first(),
this.page.locator('input.ant-input:visible').first(),
];
for (const input of inputs) {
if (await input.isVisible().catch(() => false)) {
await this.fill(input, mock, 'mock 关键字');
await this.clickQuery();
filled = true;
return;
}
}
});
if (!filled) {
await this.skipWhenDataInsufficient('无可见筛选输入框,无法构造 mock 空态');
}
await this.skipWhenDataInsufficient('无可见筛选输入框,无法构造 mock 空态');
}
async assertConfiguredHeaders(): Promise<void> {
......@@ -198,7 +206,7 @@ export class ProdmSharedPage extends WhFlowSharedPage {
const headers = await this.headerTexts();
const joined = headers.join('');
for (const name of this.prodm.headers) {
expect(joined, `表头应含 ${name}`).toContain(name.replace(/\s/g, ''));
expect(headersJoinedMatchExpected(joined, name), `表头应含 ${name}`).toBeTruthy();
}
}
......@@ -417,7 +425,19 @@ export class ProdmSharedPage extends WhFlowSharedPage {
}
async openAdvancedFilterDrawer(): Promise<void> {
const btn = this.button('高级筛选').or(this.button('筛 选')).or(this.button('筛选')).first();
const expand = this.page.getByText('展开', { exact: true }).first();
if (await expand.isVisible().catch(() => false)) {
await this.click(expand, '展开筛选').catch(() => undefined);
await this.waitSettle(300);
}
const btn = this.button('高级筛选')
.or(this.button('筛 选'))
.or(this.button('筛选'))
.or(this.page.getByRole('button', { name: /高\s*级\s*筛\s*选|筛\s*选/ }).first())
.first();
if (!(await btn.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无高级筛选/筛选入口');
}
await expect(btn, '高级筛选').toBeVisible();
await this.click(btn, '高级筛选');
await expect(this.page.locator('.ant-drawer, .ant-modal').first(), '抽屉/面板').toBeVisible();
......
......@@ -581,8 +581,18 @@ export class SurgerySharedPage extends BasePage {
/** 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();
let total = await this.getTotalCount();
let rowsBefore = await this.getDataRowCount();
const expected = Math.min(total, 20);
// 已在 20 档且行数无法再变时,先降到 10 再升回,保证断言可观测
if (rowsBefore === expected && total > 10) {
await this.changePageSize(10);
rowsBefore = await this.getDataRowCount();
total = await this.getTotalCount();
}
if (rowsBefore === Math.min(total, 20)) {
await this.skipWhenDataInsufficient(`总条数 ${total} 不足以验证切换 20 条/页后行数变化`);
}
const { rowsOnPage: rowsAfter } = await this.changePageSize(20);
return { total, rowsBefore, rowsAfter };
}
......
......@@ -87,7 +87,9 @@ export class VendorSharedPage extends BasePage {
async expectOnPage(): Promise<void> {
expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart);
for (const part of this.meta.forbidden ?? []) {
expect(this.page.url(), `不应进入 ${part}`).not.toContain(part);
// 避免 regCertAudit 误伤 regCertAuditFinal(toContain 子串)
const escaped = part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
expect(this.page.url(), `不应进入 ${part}`).not.toMatch(new RegExp(`${escaped}(?![A-Za-z0-9])`));
}
await this.waitContentReady();
const headers = await this.headerTexts();
......
......@@ -6,10 +6,11 @@ import { WhFlowSharedLocators } from '../../locators/spd/wh-flow-shared.locator'
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import {
clickPageSizeDropdownOption,
pageSizeChangeObservability,
pageSizeLabelsEqual,
parsePageSizeFromLabel,
skipIfInsufficientForPageSizeChange,
} from './helpers/pagination-pagesize.helper';
import { skipIfInsufficientForPageSizeChange } from './helpers/pagination-pagesize.helper';
export type WhFlowMeta = {
path: string;
......@@ -306,7 +307,38 @@ export class WhFlowSharedPage extends BasePage {
}
overlay(): Locator {
return this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open'));
return this.locators
.putPage()
.or(this.locators.dialog())
.or(this.page.locator('.ant-drawer-open, .ant-drawer.ant-drawer-open'))
.or(this.page.locator('.ant-modal-wrap:not(.ant-modal-wrap-hidden) .ant-modal'))
.or(this.page.locator('.ant-modal-confirm:visible, .ant-popconfirm:visible'))
.or(this.page.getByRole('dialog').first())
.or(this.page.getByRole('button', { name: /返\s*回/ }).first());
}
/** 行内/工具栏动作后:弹层、抽屉、详情返回、确认框任一出现即视为打开。 */
async waitActionLayerOpen(timeoutMs = 10_000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await this.overlay().first().isVisible().catch(() => false)) return true;
if (
await this.page
.locator(
'.ant-modal-wrap:not(.ant-modal-wrap-hidden), .ant-drawer-open, .put-page:visible, .ant-modal-confirm:visible, .ant-popconfirm:visible',
)
.first()
.isVisible()
.catch(() => false)
) {
return true;
}
if (await this.page.getByRole('button', { name: /返\s*回/ }).first().isVisible().catch(() => false)) {
return true;
}
await this.page.waitForTimeout(200);
}
return false;
}
async closeOverlay(): Promise<void> {
......@@ -320,6 +352,13 @@ export class WhFlowSharedPage extends BasePage {
}
await this.waitSettle(300);
}
const drawer = this.page.locator('.ant-drawer-open').first();
if (await drawer.isVisible().catch(() => false)) {
const cancel = drawer.getByRole('button', { name: /取\s*消|关\s*闭|返\s*回/ }).first();
if (await cancel.isVisible().catch(() => false)) await this.click(cancel, '关闭抽屉');
else await this.page.keyboard.press('Escape');
await this.waitSettle(300);
}
const put = this.locators.putPage();
if (await put.isVisible().catch(() => false)) {
const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first();
......@@ -327,6 +366,11 @@ export class WhFlowSharedPage extends BasePage {
else await this.page.keyboard.press('Escape');
await this.waitSettle(300);
}
const backOnly = this.page.getByRole('button', { name: /返\s*回/ }).first();
if (await backOnly.isVisible().catch(() => false)) {
await this.click(backOnly, '返回列表');
await this.waitSettle(300);
}
}
async interceptState(): Promise<{ hintVisible: boolean; overlayOpen: boolean }> {
......@@ -392,7 +436,7 @@ export class WhFlowSharedPage extends BasePage {
}
}
/** 切换为更小每页条数(如 20 条/页),返回切换前后可见行数与总条数。 */
/** 切换每页条数,返回切换前后可见行数与总条数。 */
async switchToPageSize(
pageSizeLabel: string,
): Promise<{ total: number; rowsBefore: number; rowsAfter: number; pageSizeLabel: string }> {
......@@ -402,12 +446,22 @@ export class WhFlowSharedPage extends BasePage {
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
});
const total = await this.getTotalCount();
const rowsBefore = await this.getDataRowCount();
if (total > sizeNum && rowsBefore <= sizeNum) {
await this.skipWhenDataInsufficient(
`当前页可见行数已不大于 ${sizeNum},无法验证切换 ${pageSizeLabel} 后列表变化`,
);
let total = await this.getTotalCount();
let rowsBefore = await this.getDataRowCount();
const currentSize = parsePageSizeFromLabel(await this.pageSizeChangerText());
const plan = pageSizeChangeObservability({
total,
rowsBefore,
targetSize: sizeNum,
currentSize,
});
if (plan.shouldSkip) {
await this.skipWhenDataInsufficient(plan.reason ?? `无法验证切换 ${pageSizeLabel}`);
}
if (plan.needDropToSmallerFirst) {
await this.changePageSizeTo('10 条/页');
rowsBefore = await this.getDataRowCount();
total = await this.getTotalCount();
}
await this.changePageSizeTo(pageSizeLabel);
const rowsAfter = await this.getDataRowCount();
......
......@@ -24,6 +24,7 @@ test.describe('基础设置 · 准入管理', () => {
});
test('ADMIT-MAT-004 新增日期与销售商(自定义)等七项组合查询', async ({ admitMaterialPage }) => {
test.setTimeout(180_000);
await admitMaterialPage.open();
await admitMaterialPage.filterCombination();
});
......
......@@ -14,7 +14,7 @@ test.describe('基础设置 · 准入管理', () => {
});
test('ADMIT-PAP-002 新增日期与销售商(自定义)等七项组合查询', async ({ admitProductApplyPage }) => {
test.setTimeout(180_000);
test.setTimeout(240_000);
await admitProductApplyPage.open();
await admitProductApplyPage.filterCombination();
});
......
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