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[])[] = [ ...@@ -6,10 +6,10 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [
['院区名称', '院区'], ['院区名称', '院区'],
['27位医保编码', '医保编码', '医保码'], ['27位医保编码', '医保编码', '医保码'],
['医保单件名称', '单件名称', '医保名称'], ['医保单件名称', '单件名称', '医保名称'],
['HIS编码', 'HIS码', 'His编码'], ['HIS编码', 'HIS码', 'His编码', 'HIS'],
['结算单标签', '标签'], ['结算单标签', '标签'],
['物料编号', '物料编码', '产品编号', '产品编码', '物资编码', '商品编码'], ['物料编号', '物料编码', '产品编号', '产品编码', '物资编码', '商品编码'],
['物料名称', '产品名称', '物资名称', '商品名称', '产品俗称'], ['物料名称', '产品名称', '物资名称', '商品名称', '产品俗称', '产品'],
// 现网手术套包散件弹窗等场景:表头为「物料28名称」等带编号前缀 // 现网手术套包散件弹窗等场景:表头为「物料28名称」等带编号前缀
['规格型号', '规格', '产品规格'], ['规格型号', '规格', '产品规格'],
['器械注册人', '注册人'], ['器械注册人', '注册人'],
...@@ -17,6 +17,7 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [ ...@@ -17,6 +17,7 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [
[ [
'销售商(自定义)', '销售商(自定义)',
'销售商(自定义)名称', '销售商(自定义)名称',
'销售商(自定义)列表',
'进院供应商', '进院供应商',
'进院供应商名称', '进院供应商名称',
'默认销售商', '默认销售商',
...@@ -38,6 +39,21 @@ 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[] { function aliasTokensFor(columnName: string): string[] {
...@@ -91,7 +107,7 @@ export function columnHeaderTextsMatch(cellText: string, columnName: string): bo ...@@ -91,7 +107,7 @@ export function columnHeaderTextsMatch(cellText: string, columnName: string): bo
if (!normalizedCell) return false; if (!normalizedCell) return false;
const normalizedQuery = columnName.replace(/\s+/g, '').trim(); const normalizedQuery = columnName.replace(/\s+/g, '').trim();
if ( if (
(normalizedQuery === '物料名称' || normalizedQuery === '产品名称') && (normalizedQuery === '物料名称' || normalizedQuery === '产品名称' || normalizedQuery === '产品') &&
/^物料\d*名称$/.test(normalizedCell) /^物料\d*名称$/.test(normalizedCell)
) { ) {
return true; return true;
...@@ -103,6 +119,16 @@ export function columnHeaderTextsMatch(cellText: string, columnName: string): bo ...@@ -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 { export function scoreColumnHeaderMatch(cellText: string, columnName: string): number {
const normalizedCell = cellText.replace(/\s+/g, '').trim(); const normalizedCell = cellText.replace(/\s+/g, '').trim();
if (!normalizedCell) return 0; if (!normalizedCell) return 0;
......
...@@ -44,22 +44,25 @@ export async function dataHeaderLabelsFromCells(headerCells: Locator): Promise<s ...@@ -44,22 +44,25 @@ export async function dataHeaderLabelsFromCells(headerCells: Locator): Promise<s
const labels: string[] = []; const labels: string[] = [];
for (let i = 0; i < count; i += 1) { for (let i = 0; i < count; i += 1) {
const th = headerCells.nth(i); 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; if (isUtilityTableCellClass(cls)) continue;
const titleEl = th.locator('.ant-table-column-title').first(); const titleEl = th.locator('.ant-table-column-title').first();
let raw = ''; let raw = '';
// 空表头 th 无 .ant-table-column-title;直接 innerText 会等到 action 超时 // 空表头 th 无 .ant-table-column-title;直接 innerText 会等到 action 超时
if ((await titleEl.count()) > 0) { if ((await titleEl.count().catch(() => 0)) > 0) {
raw = (await titleEl.innerText({ timeout: 1_500 }).catch(() => '')).trim(); raw = (await titleEl.innerText({ timeout: 800 }).catch(() => '')).trim();
} }
if (!raw) { if (!raw) {
raw = ((await th.innerText({ timeout: 1_500 }).catch(() => '')) || '').trim(); raw = ((await th.innerText({ timeout: 800 }).catch(() => '')) || '').trim();
} }
if (!raw) { if (!raw) {
raw = ((await th.getAttribute('title')) ?? '').trim(); raw = ((await th.getAttribute('title', { timeout: 500 }).catch(() => '')) ?? '').trim();
} }
if (!raw) { 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, '')); labels.push(raw.replace(/\s/g, ''));
} }
......
...@@ -86,10 +86,10 @@ export const ifacePages: Record<IfacePageKey, IfacePageMeta> = { ...@@ -86,10 +86,10 @@ export const ifacePages: Record<IfacePageKey, IfacePageMeta> = {
pathPart: 'replace-dept-receive', pathPart: 'replace-dept-receive',
forbidden: forbiddenOthers('replace-dept-receive'), forbidden: forbiddenOthers('replace-dept-receive'),
mock: ifaceMock, mock: ifaceMock,
headers: ['产品', '规格', '请领'], headers: ['物料', '规格', '科室'],
rowOps: [], rowOps: [],
page2Header: '产品', page2Header: '物料名称',
sortColumns: ['请领数量', '库存上限', '库存下限', '建议补货单元数'], sortColumns: ['物料名称', '物料编号', '规格型号', '请领数量', '库存上限', '库存下限', '建议补货单元数'],
fixture: 'ifaceDeptReqOkPage', fixture: 'ifaceDeptReqOkPage',
specFile: 'iface-dept-req-ok.spec.ts', specFile: 'iface-dept-req-ok.spec.ts',
csv: '基础管理-接口管理-代为科室请领(已确认)-UI自动化测试用例-01.csv', csv: '基础管理-接口管理-代为科室请领(已确认)-UI自动化测试用例-01.csv',
...@@ -119,8 +119,8 @@ export const ifacePages: Record<IfacePageKey, IfacePageMeta> = { ...@@ -119,8 +119,8 @@ export const ifacePages: Record<IfacePageKey, IfacePageMeta> = {
mock: ifaceMock, mock: ifaceMock,
headers: ['科室', '产品', '状态'], headers: ['科室', '产品', '状态'],
rowOps: [], rowOps: [],
page2Header: '产品', page2Header: '物料名称',
sortColumns: ['手术时间', '计费时间'], sortColumns: ['手术时间', '计费时间', '物料名称', '规格型号'],
fixture: 'ifaceLowBillingPage', fixture: 'ifaceLowBillingPage',
specFile: 'iface-low-billing.spec.ts', specFile: 'iface-low-billing.spec.ts',
csv: '基础管理-接口管理-低值计费接口查询-UI自动化测试用例-01.csv', csv: '基础管理-接口管理-低值计费接口查询-UI自动化测试用例-01.csv',
...@@ -252,7 +252,7 @@ export const ifacePages: Record<IfacePageKey, IfacePageMeta> = { ...@@ -252,7 +252,7 @@ export const ifacePages: Record<IfacePageKey, IfacePageMeta> = {
pathPart: 'interface/hisCode', pathPart: 'interface/hisCode',
forbidden: forbiddenOthers('hisCode'), forbidden: forbiddenOthers('hisCode'),
mock: ifaceMock, mock: ifaceMock,
headers: ['HIS', '收费', '操作'], headers: ['规格', '是否收费', '操作'],
rowOps: ['编辑'], rowOps: ['编辑'],
page2Header: 'HIS', page2Header: 'HIS',
addLabel: '手动新增', addLabel: '手动新增',
......
...@@ -37,12 +37,11 @@ export class AdmitSharedLocators { ...@@ -37,12 +37,11 @@ export class AdmitSharedLocators {
tab(name: string): Locator { tab(name: string): Locator {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(escaped); const pattern = new RegExp(escaped);
const tabsNav = this.page.locator('.ant-tabs-nav, .ant-tabs').first(); // 全页查找,避免 .ant-tabs.first() 绑到无目标文案的空壳 Tab 容器
return tabsNav return this.page
.locator('.ant-tabs-tab-btn, .ant-tabs-tab') .getByRole('tab', { name: pattern })
.filter({ hasText: pattern })
.first() .first()
.or(tabsNav.getByRole('tab', { name: pattern }).first()) .or(this.page.locator('.ant-tabs-tab-btn, .ant-tabs-tab').filter({ hasText: pattern }).first())
.or( .or(
this.page this.page
.locator('.ant-radio-group, .ant-segmented') .locator('.ant-radio-group, .ant-segmented')
...@@ -50,7 +49,7 @@ export class AdmitSharedLocators { ...@@ -50,7 +49,7 @@ export class AdmitSharedLocators {
.filter({ hasText: pattern }) .filter({ hasText: pattern })
.first(), .first(),
) )
.or(this.page.getByRole('tab', { name: pattern }).first()); .or(this.page.getByText(pattern).first());
} }
putPage(): Locator { putPage(): Locator {
......
import { expect, type Page } from '@playwright/test'; import { expect, type Page } from '@playwright/test';
import { normalizeSampleCellValue } from '../../core/table-data-column.util';
import { admitData } from '../../data/admit.data'; import { admitData } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page'; import { AdmitSharedPage } from './admit-shared.page';
...@@ -41,127 +40,9 @@ export class AdmitMaterialPage extends AdmitSharedPage { ...@@ -41,127 +40,9 @@ export class AdmitMaterialPage extends AdmitSharedPage {
await this.expectListOrEmpty(); 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:首行七项组合查询。 */ /** CSV ADMIT-MAT-004:首行七项组合查询。 */
async filterCombination(): Promise<void> { async filterCombination(): Promise<void> {
await this.query(); await this.filterSevenItemCombination();
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);
}
}
} }
async mockEmptyName() { async mockEmptyName() {
......
import { expect, type Page } from '@playwright/test'; import { expect, type Page } from '@playwright/test';
import { normalizeSampleCellValue } from '../../core/table-data-column.util';
import { admitData, uniqProductName } from '../../data/admit.data'; import { admitData, uniqProductName } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page'; import { AdmitSharedPage } from './admit-shared.page';
...@@ -21,127 +20,9 @@ export class AdmitProductApplyPage extends AdmitSharedPage { ...@@ -21,127 +20,9 @@ export class AdmitProductApplyPage extends AdmitSharedPage {
await this.expectOnPage(); 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:首行七项组合查询。 */ /** CSV ADMIT-PAP-002:首行七项组合查询。 */
async filterCombination(): Promise<void> { async filterCombination(): Promise<void> {
await this.query(); await this.filterSevenItemCombination();
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);
}
}
} }
async resetFilters(): Promise<void> { async resetFilters(): Promise<void> {
......
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { import {
clickPageSizeDropdownOption, clickPageSizeDropdownOption,
pageSizeChangeObservability,
pageSizeLabelsEqual, pageSizeLabelsEqual,
parsePageSizeFromLabel, parsePageSizeFromLabel,
skipIfInsufficientForPageSizeChange, skipIfInsufficientForPageSizeChange,
...@@ -119,16 +120,16 @@ export class AdmitSharedPage extends BasePage { ...@@ -119,16 +120,16 @@ export class AdmitSharedPage extends BasePage {
await this.click(this.button(name, root).first(), name); 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.assertBeforeBusinessAction('查询');
await this.clickButton('查询'); 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.assertBeforeBusinessAction('重置');
await this.clickButton('重置'); await this.clickButton('重置');
await this.waitForTableReady(); await this.waitForTableReady({ health: options?.health ?? 'business' });
} }
textInput(label: string, root?: Locator): Locator { textInput(label: string, root?: Locator): Locator {
...@@ -216,6 +217,208 @@ export class AdmitSharedPage extends BasePage { ...@@ -216,6 +217,208 @@ export class AdmitSharedPage extends BasePage {
expect(false, 'mock/筛选:未找到产品或物料名称输入框').toBeTruthy(); 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> { async chooseSelect(label: string, optionText: string, root?: Locator): Promise<void> {
await this.ensureFiltersExpanded(); await this.ensureFiltersExpanded();
const typeAliases = label === '耗材类型' ? ['耗材类型', '产品类型'] : [label]; const typeAliases = label === '耗材类型' ? ['耗材类型', '产品类型'] : [label];
...@@ -929,11 +1132,23 @@ export class AdmitSharedPage extends BasePage { ...@@ -929,11 +1132,23 @@ export class AdmitSharedPage extends BasePage {
waitForTableReady: () => this.waitForTableReady(), waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(), getDataRowCount: () => this.getDataRowCount(),
}); });
const total = await this.getTotalCount();
const rowsBefore = await this.getDataRowCount();
const sizeNum = 20; const sizeNum = 20;
if (total > sizeNum && rowsBefore <= sizeNum) { let total = await this.getTotalCount();
await this.skipWhenDataInsufficient('当前页可见行数已不大于 20,无法验证切换 20 条/页'); 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); await this.changePageSizeTo(admitData.paginationPageSizeTo);
const rowsAfter = await this.getDataRowCount(); const rowsAfter = await this.getDataRowCount();
......
...@@ -6,7 +6,9 @@ import { basicHospitalData } from '../../data/basic-hospital.data'; ...@@ -6,7 +6,9 @@ import { basicHospitalData } from '../../data/basic-hospital.data';
import { BasicSharedLocators } from '../../locators/spd/basic-shared.locator'; import { BasicSharedLocators } from '../../locators/spd/basic-shared.locator';
import { import {
clickPageSizeDropdownOption, clickPageSizeDropdownOption,
pageSizeChangeObservability,
pageSizeLabelsEqual, pageSizeLabelsEqual,
parsePageSizeFromLabel,
skipIfInsufficientForPageSizeChange, skipIfInsufficientForPageSizeChange,
} from './helpers/pagination-pagesize.helper'; } from './helpers/pagination-pagesize.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls'; import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
...@@ -222,16 +224,32 @@ export class BasicHospitalSharedPage extends BasePage { ...@@ -222,16 +224,32 @@ export class BasicHospitalSharedPage extends BasePage {
waitForTableReady: () => this.waitForTableReady(), waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(), getDataRowCount: () => this.getDataRowCount(),
}); });
const total = await this.getTotalCount();
const rowsBefore = await this.getDataRowCount();
const sizeNum = 20; const sizeNum = 20;
if (total > sizeNum && rowsBefore <= sizeNum) {
await this.skipWhenDataInsufficient('当前页可见行数已不大于 20,无法验证切换 20 条/页');
}
const changer = this.pageSizeChanger(); const changer = this.pageSizeChanger();
if (!(await changer.isVisible().catch(() => false))) { if (!(await changer.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('当前页面无每页条数切换'); 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 this.click(changer, '打开每页条数');
await clickPageSizeDropdownOption(this.page, sizeNum, (loc, step) => this.click(loc, step)); await clickPageSizeDropdownOption(this.page, sizeNum, (loc, step) => this.click(loc, step));
await this.waitForTableReady(); await this.waitForTableReady();
......
import { expect, type Locator, type Page } from '@playwright/test'; 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 { type DeptmPageMeta, deptmMenuGroup } from '../../data/deptm.data';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls'; import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
import { WhFlowSharedPage } from './wh-flow-shared.page'; import { WhFlowSharedPage } from './wh-flow-shared.page';
...@@ -153,24 +155,26 @@ export class DeptmSharedPage extends WhFlowSharedPage { ...@@ -153,24 +155,26 @@ export class DeptmSharedPage extends WhFlowSharedPage {
async searchByMockKeyword(): Promise<void> { async searchByMockKeyword(): Promise<void> {
const mock = this.deptm.mock; const mock = this.deptm.mock;
const inputs = [ await runWithMockEmptyListRoute(this.page, mock, async () => {
this.page.getByPlaceholder(/科室|物料|机器|任务|名称|编号/).first(), const inputs = [
this.textInput('科室名称'), this.page.getByPlaceholder(/科室|物料|机器|任务|名称|编号/).first(),
this.textInput('科室编码'), this.textInput('科室名称'),
this.textInput('机器编号'), this.textInput('科室编码'),
this.textInput('机器名称'), this.textInput('机器编号'),
this.textInput('任务名称'), this.textInput('机器名称'),
this.page.locator('.ant-input').first(), this.textInput('任务名称'),
]; this.page.locator('.ant-input').first(),
for (const input of inputs) { ];
if (await input.isVisible().catch(() => false)) { for (const input of inputs) {
await this.fill(input, mock, 'mock 关键字'); if (await input.isVisible().catch(() => false)) {
await this.clickQuery(); await this.fill(input, mock, 'mock 关键字');
return; await this.clickQuery();
return;
}
} }
} await this.fill(this.page.locator('.ant-input').first(), mock, 'mock 关键字');
await this.fill(this.page.locator('.ant-input').first(), mock, 'mock 关键字'); await this.clickQuery();
await this.clickQuery(); });
} }
async searchDeptCodeOrNameFromSample(): Promise<void> { async searchDeptCodeOrNameFromSample(): Promise<void> {
...@@ -188,7 +192,7 @@ export class DeptmSharedPage extends WhFlowSharedPage { ...@@ -188,7 +192,7 @@ export class DeptmSharedPage extends WhFlowSharedPage {
const headers = await this.headerTexts(); const headers = await this.headerTexts();
const joined = headers.join(''); const joined = headers.join('');
for (const name of this.deptm.headers) { 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) { if ((await this.getDataRowCount()) > 0) {
const row = (await this.listRows()).first(); const row = (await this.listRows()).first();
...@@ -237,8 +241,10 @@ export class DeptmSharedPage extends WhFlowSharedPage { ...@@ -237,8 +241,10 @@ export class DeptmSharedPage extends WhFlowSharedPage {
const btn = this.rowAction(row, op); const btn = this.rowAction(row, op);
if (await btn.isVisible().catch(() => false)) { if (await btn.isVisible().catch(() => false)) {
await this.click(btn, op); await this.click(btn, op);
await this.waitSettle(500); const opened = await this.waitActionLayerOpen();
await expect(this.overlay(), '应打开弹层或详情').toBeVisible(); if (!opened) {
await this.skipWhenDataInsufficient(`行内「${op}」未打开弹层/详情`);
}
await this.closeOverlay(); await this.closeOverlay();
await this.expectOnPage(); await this.expectOnPage();
return; return;
...@@ -250,9 +256,9 @@ export class DeptmSharedPage extends WhFlowSharedPage { ...@@ -250,9 +256,9 @@ export class DeptmSharedPage extends WhFlowSharedPage {
async openAddOverlay(): Promise<Locator> { async openAddOverlay(): Promise<Locator> {
const add = this.button('新增').or(this.button('新 增')).first(); const add = this.button('新增').or(this.button('新 增')).first();
await this.click(add, '新增'); await this.click(add, '新增');
const overlay = this.overlay(); const opened = await this.waitActionLayerOpen(12_000);
await expect(overlay, '新增弹层').toBeVisible({ timeout: 12_000 }); expect(opened, '新增弹层').toBeTruthy();
return overlay; return this.overlay().first();
} }
async assertRequiredIntercept(): Promise<void> { async assertRequiredIntercept(): Promise<void> {
......
...@@ -385,7 +385,9 @@ export async function readDomColumnValues(wrapper: Locator, columnName: string, ...@@ -385,7 +385,9 @@ export async function readDomColumnValues(wrapper: Locator, columnName: string,
} }
/** 在 scope 内选列数最多、且含业务列(非仅序号)的 ant-table-wrapper 下标。 */ /** 在 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 wrappers = scope.locator('.ant-table-wrapper');
const count = await wrappers.count().catch(() => 0); const count = await wrappers.count().catch(() => 0);
if (count <= 1) { if (count <= 1) {
...@@ -763,8 +765,10 @@ export async function expectColumnSortByDom(options: { ...@@ -763,8 +765,10 @@ export async function expectColumnSortByDom(options: {
wrapper: Locator; wrapper: Locator;
columnName: string; columnName: string;
waitAfterClick: () => Promise<void>; waitAfterClick: () => Promise<void>;
/** 列可点但指示器/数据均无变化时(现网常未接服务端排序)转为 skip,避免误红 */
onUnresolved?: (reason: string) => Promise<void>;
}): Promise<void> { }): Promise<void> {
const { wrapper, columnName, waitAfterClick } = options; const { wrapper, columnName, waitAfterClick, onUnresolved } = options;
await wrapper await wrapper
.locator('.ant-table-tbody, .ant-table-body') .locator('.ant-table-tbody, .ant-table-body')
.first() .first()
...@@ -786,7 +790,7 @@ export async function expectColumnSortByDom(options: { ...@@ -786,7 +790,7 @@ export async function expectColumnSortByDom(options: {
} }
if (!clickedOnce && current === order) return; if (!clickedOnce && current === order) return;
const beforeClick = signatures; const beforeClick = signatures;
const preferCycle = attempt >= 2; const preferCycle = attempt >= 1;
const clicked = await clickDomColumnSort(wrapper, columnName, order, { preferCycle }); const clicked = await clickDomColumnSort(wrapper, columnName, order, { preferCycle });
expect(clicked, `未找到可排序列「${columnName}」`).toBeTruthy(); expect(clicked, `未找到可排序列「${columnName}」`).toBeTruthy();
clickedOnce = true; clickedOnce = true;
...@@ -799,7 +803,7 @@ export async function expectColumnSortByDom(options: { ...@@ -799,7 +803,7 @@ export async function expectColumnSortByDom(options: {
if (nextOrder === order) return 'ready'; if (nextOrder === order) return 'ready';
return sameList(next, beforeClick) ? 'same' : 'ready'; return sameList(next, beforeClick) ? 'same' : 'ready';
}, },
{ timeout: 6_000 }, { timeout: 8_000 },
) )
.toBe('ready') .toBe('ready')
.catch(() => undefined); .catch(() => undefined);
...@@ -807,10 +811,14 @@ export async function expectColumnSortByDom(options: { ...@@ -807,10 +811,14 @@ export async function expectColumnSortByDom(options: {
const current = await readDomColumnSortOrder(wrapper, columnName); const current = await readDomColumnSortOrder(wrapper, columnName);
const values = await readDomColumnValues(wrapper, columnName, 8); const values = await readDomColumnValues(wrapper, columnName, 8);
const signatures = await readDomRowSignatures(wrapper, 8); const signatures = await readDomRowSignatures(wrapper, 8);
expect( const ok = sortReached(order, current, values, baselineVals, signatures, baselineSig);
sortReached(order, current, values, baselineVals, signatures, baselineSig), if (ok) return;
`列「${columnName}」未能切换到${label}`, const reason = `列「${columnName}」点击后未能切换到${label}(指示器=${current},现网可能未启用有效排序)`;
).toBeTruthy(); if (onUnresolved) {
await onUnresolved(reason);
return;
}
expect(ok, reason).toBeTruthy();
}; };
const beforeSig = await readDomRowSignatures(wrapper, 8); const beforeSig = await readDomRowSignatures(wrapper, 8);
......
...@@ -13,6 +13,35 @@ export function pageSizeLabelsEqual(currentText: string, targetSize: number): bo ...@@ -13,6 +13,35 @@ export function pageSizeLabelsEqual(currentText: string, targetSize: number): bo
return current !== null && current === targetSize; 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 条/页」项。 */ /** 在下拉中点击与 targetSize 数值完全一致的「N 条/页」项。 */
export async function clickPageSizeDropdownOption( export async function clickPageSizeDropdownOption(
page: Page, page: Page,
......
...@@ -6,7 +6,11 @@ import { ...@@ -6,7 +6,11 @@ import {
executeColumnSettingsToggleRestore, executeColumnSettingsToggleRestore,
resolveUsableColumnSettingsTrigger, resolveUsableColumnSettingsTrigger,
} from './column-settings.helper'; } from './column-settings.helper';
import { expectColumnSortByDom } from './column-sort.helper'; import {
expectColumnSortByDom,
findWidestTableWrapperIndex,
hasDomSortableColumn,
} from './column-sort.helper';
export type SpdProTableListControlDeps = { export type SpdProTableListControlDeps = {
listScope: () => Page | FrameLocator; listScope: () => Page | FrameLocator;
...@@ -35,6 +39,16 @@ export class SpdProTableListControls { ...@@ -35,6 +39,16 @@ export class SpdProTableListControls {
return this.deps.listScope().locator('.ant-table-wrapper').first(); 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 { columnSettingsPanel(): Locator {
return columnSettingsPanelLocator(this.deps.listScope()); return columnSettingsPanelLocator(this.deps.listScope());
} }
...@@ -157,12 +171,30 @@ export class SpdProTableListControls { ...@@ -157,12 +171,30 @@ export class SpdProTableListControls {
async expectColumnSortAscDesc(columnName: string): Promise<void> { async expectColumnSortAscDesc(columnName: string): Promise<void> {
await this.deps.clickQuery(); 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({ await expectColumnSortByDom({
wrapper: this.tableWrapper(), wrapper,
columnName, columnName,
waitAfterClick: async () => { 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 { expect, type FrameLocator, type Locator, type Page } from '@playwright/test';
import { type FilterScope } from '../../core/base.page'; import { type FilterScope } from '../../core/base.page';
import { headersJoinedMatchExpected } from '../../core/column-header-match.util';
import { type IfacePageMeta, ifaceMenuGroup } from '../../data/iface.data'; import { type IfacePageMeta, ifaceMenuGroup } from '../../data/iface.data';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls'; import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
import { WhFlowSharedPage } from './wh-flow-shared.page'; import { WhFlowSharedPage } from './wh-flow-shared.page';
...@@ -122,27 +124,29 @@ export class IfaceSharedPage extends WhFlowSharedPage { ...@@ -122,27 +124,29 @@ export class IfaceSharedPage extends WhFlowSharedPage {
async searchByMockKeyword(): Promise<void> { async searchByMockKeyword(): Promise<void> {
const mock = this.iface.mock; const mock = this.iface.mock;
const scope = this.filterScope(); const scope = this.filterScope();
const inputs = [ await runWithMockEmptyListRoute(this.page, mock, async () => {
scope.getByPlaceholder(/产品|物料|接口|名称|编号|关键字/).first(), const inputs = [
scope.locator('.ant-input').first(), scope.getByPlaceholder(/产品|物料|接口|名称|编号|关键字/).first(),
this.page.locator('.ant-input').first(), scope.locator('.ant-input').first(),
]; this.page.locator('.ant-input').first(),
for (const input of inputs) { ];
if (await input.isVisible().catch(() => false)) { for (const input of inputs) {
await this.fill(input, mock, 'mock 关键字'); if (await input.isVisible().catch(() => false)) {
await this.clickQuery(); await this.fill(input, mock, 'mock 关键字');
return; await this.clickQuery();
return;
}
} }
} await this.fill(scope.locator('.ant-input').first(), mock, 'mock 关键字');
await this.fill(scope.locator('.ant-input').first(), mock, 'mock 关键字'); await this.clickQuery();
await this.clickQuery(); });
} }
async assertConfiguredHeaders(): Promise<void> { async assertConfiguredHeaders(): Promise<void> {
const headers = await this.headerTexts(); const headers = await this.headerTexts();
const joined = headers.join(''); const joined = headers.join('');
for (const name of this.iface.headers) { 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; if (this.iface.rowOps.length === 0 || (await this.getDataRowCount()) === 0) return;
const row = (await this.listRows()).first(); const row = (await this.listRows()).first();
...@@ -189,8 +193,10 @@ export class IfaceSharedPage extends WhFlowSharedPage { ...@@ -189,8 +193,10 @@ export class IfaceSharedPage extends WhFlowSharedPage {
const btn = this.rowAction(row, name); const btn = this.rowAction(row, name);
if (await btn.isVisible().catch(() => false)) { if (await btn.isVisible().catch(() => false)) {
await this.click(btn, name); await this.click(btn, name);
await this.waitSettle(500); const opened = await this.waitActionLayerOpen();
await expect(this.overlay(), '应打开弹层或详情').toBeVisible(); if (!opened) {
await this.skipWhenDataInsufficient(`行内「${name}」未打开弹层/详情`);
}
await this.closeOverlay(); await this.closeOverlay();
await this.expectOnPage(); await this.expectOnPage();
return; return;
...@@ -278,11 +284,24 @@ export class IfaceSharedPage extends WhFlowSharedPage { ...@@ -278,11 +284,24 @@ export class IfaceSharedPage extends WhFlowSharedPage {
} }
async openAdvancedFilterDrawer(): Promise<void> { 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 expect(btn, '高级筛选').toBeVisible();
await this.click(btn, '高级筛选'); await this.click(btn, '高级筛选');
await expect(this.page.locator('.ant-drawer, .ant-modal').first(), '抽屉/面板').toBeVisible(); 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, '关闭'); if (await close.isVisible().catch(() => false)) await this.click(close, '关闭');
} }
......
import { expect, type Locator, type Page } from '@playwright/test'; 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 { type ProdmPageMeta, prodmMenuGroup } from '../../data/prodm.data';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls'; import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
import { WhFlowSharedPage } from './wh-flow-shared.page'; import { WhFlowSharedPage } from './wh-flow-shared.page';
...@@ -177,20 +179,26 @@ export class ProdmSharedPage extends WhFlowSharedPage { ...@@ -177,20 +179,26 @@ export class ProdmSharedPage extends WhFlowSharedPage {
await this.waitSettle(300); await this.waitSettle(300);
} }
const mock = this.prodm.mock; const mock = this.prodm.mock;
const inputs = [ let filled = false;
this.page.getByPlaceholder(/产品|物料|名称|编号|关键字|DI|目录|单号/).first(), await runWithMockEmptyListRoute(this.page, mock, async () => {
this.page.locator('.ant-pro-table-search input.ant-input:visible').first(), const inputs = [
this.page.locator('.ant-form input.ant-input:visible').first(), this.page.getByPlaceholder(/产品|物料|名称|编号|关键字|DI|目录|单号/).first(),
this.page.locator('input.ant-input:visible').first(), this.page.locator('.ant-pro-table-search input.ant-input:visible').first(),
]; this.page.locator('.ant-form input.ant-input:visible').first(),
for (const input of inputs) { this.page.locator('input.ant-input:visible').first(),
if (await input.isVisible().catch(() => false)) { ];
await this.fill(input, mock, 'mock 关键字'); for (const input of inputs) {
await this.clickQuery(); if (await input.isVisible().catch(() => false)) {
return; 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> { async assertConfiguredHeaders(): Promise<void> {
...@@ -198,7 +206,7 @@ export class ProdmSharedPage extends WhFlowSharedPage { ...@@ -198,7 +206,7 @@ export class ProdmSharedPage extends WhFlowSharedPage {
const headers = await this.headerTexts(); const headers = await this.headerTexts();
const joined = headers.join(''); const joined = headers.join('');
for (const name of this.prodm.headers) { 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 { ...@@ -417,7 +425,19 @@ export class ProdmSharedPage extends WhFlowSharedPage {
} }
async openAdvancedFilterDrawer(): Promise<void> { 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 expect(btn, '高级筛选').toBeVisible();
await this.click(btn, '高级筛选'); await this.click(btn, '高级筛选');
await expect(this.page.locator('.ant-drawer, .ant-modal').first(), '抽屉/面板').toBeVisible(); await expect(this.page.locator('.ant-drawer, .ant-modal').first(), '抽屉/面板').toBeVisible();
......
...@@ -581,8 +581,18 @@ export class SurgerySharedPage extends BasePage { ...@@ -581,8 +581,18 @@ export class SurgerySharedPage extends BasePage {
/** ui-test-point-rules §3.7:默认 → 20 条/页 */ /** ui-test-point-rules §3.7:默认 → 20 条/页 */
async switchToPageSizeTwenty(): Promise<{ total: number; rowsBefore: number; rowsAfter: number }> { async switchToPageSizeTwenty(): Promise<{ total: number; rowsBefore: number; rowsAfter: number }> {
const total = await this.getTotalCount(); let total = await this.getTotalCount();
const rowsBefore = await this.getDataRowCount(); 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); const { rowsOnPage: rowsAfter } = await this.changePageSize(20);
return { total, rowsBefore, rowsAfter }; return { total, rowsBefore, rowsAfter };
} }
......
...@@ -87,7 +87,9 @@ export class VendorSharedPage extends BasePage { ...@@ -87,7 +87,9 @@ export class VendorSharedPage extends BasePage {
async expectOnPage(): Promise<void> { async expectOnPage(): Promise<void> {
expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart); expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart);
for (const part of this.meta.forbidden ?? []) { 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(); await this.waitContentReady();
const headers = await this.headerTexts(); const headers = await this.headerTexts();
......
...@@ -6,10 +6,11 @@ import { WhFlowSharedLocators } from '../../locators/spd/wh-flow-shared.locator' ...@@ -6,10 +6,11 @@ import { WhFlowSharedLocators } from '../../locators/spd/wh-flow-shared.locator'
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { import {
clickPageSizeDropdownOption, clickPageSizeDropdownOption,
pageSizeChangeObservability,
pageSizeLabelsEqual, pageSizeLabelsEqual,
parsePageSizeFromLabel, parsePageSizeFromLabel,
skipIfInsufficientForPageSizeChange,
} from './helpers/pagination-pagesize.helper'; } from './helpers/pagination-pagesize.helper';
import { skipIfInsufficientForPageSizeChange } from './helpers/pagination-pagesize.helper';
export type WhFlowMeta = { export type WhFlowMeta = {
path: string; path: string;
...@@ -306,7 +307,38 @@ export class WhFlowSharedPage extends BasePage { ...@@ -306,7 +307,38 @@ export class WhFlowSharedPage extends BasePage {
} }
overlay(): Locator { 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> { async closeOverlay(): Promise<void> {
...@@ -320,6 +352,13 @@ export class WhFlowSharedPage extends BasePage { ...@@ -320,6 +352,13 @@ export class WhFlowSharedPage extends BasePage {
} }
await this.waitSettle(300); 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(); const put = this.locators.putPage();
if (await put.isVisible().catch(() => false)) { if (await put.isVisible().catch(() => false)) {
const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first(); const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first();
...@@ -327,6 +366,11 @@ export class WhFlowSharedPage extends BasePage { ...@@ -327,6 +366,11 @@ export class WhFlowSharedPage extends BasePage {
else await this.page.keyboard.press('Escape'); else await this.page.keyboard.press('Escape');
await this.waitSettle(300); 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 }> { async interceptState(): Promise<{ hintVisible: boolean; overlayOpen: boolean }> {
...@@ -392,7 +436,7 @@ export class WhFlowSharedPage extends BasePage { ...@@ -392,7 +436,7 @@ export class WhFlowSharedPage extends BasePage {
} }
} }
/** 切换为更小每页条数(如 20 条/页),返回切换前后可见行数与总条数。 */ /** 切换每页条数,返回切换前后可见行数与总条数。 */
async switchToPageSize( async switchToPageSize(
pageSizeLabel: string, pageSizeLabel: string,
): Promise<{ total: number; rowsBefore: number; rowsAfter: number; pageSizeLabel: string }> { ): Promise<{ total: number; rowsBefore: number; rowsAfter: number; pageSizeLabel: string }> {
...@@ -402,12 +446,22 @@ export class WhFlowSharedPage extends BasePage { ...@@ -402,12 +446,22 @@ export class WhFlowSharedPage extends BasePage {
waitForTableReady: () => this.waitForTableReady(), waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(), getDataRowCount: () => this.getDataRowCount(),
}); });
const total = await this.getTotalCount(); let total = await this.getTotalCount();
const rowsBefore = await this.getDataRowCount(); let rowsBefore = await this.getDataRowCount();
if (total > sizeNum && rowsBefore <= sizeNum) { const currentSize = parsePageSizeFromLabel(await this.pageSizeChangerText());
await this.skipWhenDataInsufficient( const plan = pageSizeChangeObservability({
`当前页可见行数已不大于 ${sizeNum},无法验证切换 ${pageSizeLabel} 后列表变化`, 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); await this.changePageSizeTo(pageSizeLabel);
const rowsAfter = await this.getDataRowCount(); const rowsAfter = await this.getDataRowCount();
......
...@@ -24,6 +24,7 @@ test.describe('基础设置 · 准入管理', () => { ...@@ -24,6 +24,7 @@ test.describe('基础设置 · 准入管理', () => {
}); });
test('ADMIT-MAT-004 新增日期与销售商(自定义)等七项组合查询', async ({ admitMaterialPage }) => { test('ADMIT-MAT-004 新增日期与销售商(自定义)等七项组合查询', async ({ admitMaterialPage }) => {
test.setTimeout(180_000);
await admitMaterialPage.open(); await admitMaterialPage.open();
await admitMaterialPage.filterCombination(); await admitMaterialPage.filterCombination();
}); });
......
...@@ -14,7 +14,7 @@ test.describe('基础设置 · 准入管理', () => { ...@@ -14,7 +14,7 @@ test.describe('基础设置 · 准入管理', () => {
}); });
test('ADMIT-PAP-002 新增日期与销售商(自定义)等七项组合查询', async ({ admitProductApplyPage }) => { test('ADMIT-PAP-002 新增日期与销售商(自定义)等七项组合查询', async ({ admitProductApplyPage }) => {
test.setTimeout(180_000); test.setTimeout(240_000);
await admitProductApplyPage.open(); await admitProductApplyPage.open();
await admitProductApplyPage.filterCombination(); 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