Commit 2d26844c authored by liguangyu06's avatar liguangyu06
Browse files

同步基础管理/报表用例与登录态稳定性修复

修复 auth 写入锁 stale PID 导致 setup 空等超时;主辅账号 setup 串行并延长超时,login 依赖 setup。同步用户/厂商/准入等 CSV·MD·spec·PO,移除 stat 调试 spec,补充离线全量脚本与 gate-stamp 目录哈希修复。
parent 45178f79
......@@ -21,6 +21,7 @@ export class BasicNoticePage extends BasicHospitalSharedPage {
}
async search(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.click(this.locators.searchBtn, '查 询');
await this.waitForTableReady();
}
......@@ -28,6 +29,7 @@ export class BasicNoticePage extends BasicHospitalSharedPage {
async resetSessionForReuse(options?: { search?: boolean }): Promise<void> {
await this.closeDialogIfOpen();
if (await this.locators.resetBtn.isVisible().catch(() => false)) {
await this.assertBeforeBusinessAction('重置');
await this.click(this.locators.resetBtn, '重 置');
await this.waitForTableReady();
}
......
......@@ -120,7 +120,7 @@ export class BasicRolePage extends BasicUserSharedPage {
await host.evaluate((el) => {
el.scrollTop = 0;
});
await this.assertPageHealthy(healthContext);
await this.assertUiReady(healthContext);
return;
}
......@@ -141,10 +141,11 @@ export class BasicRolePage extends BasicUserSharedPage {
});
if (!walked.ok) {
await this.skipWhenDataInsufficient(skipDetail);
return;
}
expect(walked.topDown, '向下滚动后 scrollTop 应增加').toBeGreaterThan(walked.top0);
expect(walked.topUp, '向上滚回后应回到顶部').toBeLessThanOrEqual(4);
await this.assertPageHealthy(healthContext);
await this.assertUiReady(healthContext);
}
async assertRoleTreeVerticalScroll(): Promise<void> {
......@@ -352,6 +353,7 @@ export class BasicRolePage extends BasicUserSharedPage {
const dialog = this.editRoleDialog();
const save = dialog.getByRole('button', { name: /保\s*存/ }).first();
await expect(save, '弹框保存按钮').toBeVisible();
await this.assertBeforeBusinessAction('保存编辑角色');
await this.click(save, '保存编辑角色', { operation: 'write' });
await this.waitSpinGone();
await this.waitSettle(400);
......@@ -404,12 +406,13 @@ export class BasicRolePage extends BasicUserSharedPage {
await this.skipWhenDataInsufficient('「角色」按钮不可见');
return this.addRoleDialog();
}
await this.assertBeforeBusinessAction('打开新增角色');
await this.click(add, '角 色');
await this.waitSettle(400);
const dialog = this.addRoleDialog();
await expect(dialog, '新增角色弹窗').toBeVisible({ timeout: 12_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertPageHealthy('打开新增角色弹窗');
await this.assertUiReady('打开新增角色弹窗');
return dialog;
}
......@@ -437,7 +440,7 @@ export class BasicRolePage extends BasicUserSharedPage {
await this.click(close, '点击新增弹框 X 关闭');
await expect(dialog, 'X 关闭后弹框应隐藏').toBeHidden({ timeout: 8_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertPageHealthy('X 关闭新增角色弹框后');
await this.assertUiReady('X 关闭新增角色弹框后');
}
async cancelAddRoleDialog(): Promise<void> {
......@@ -448,7 +451,7 @@ export class BasicRolePage extends BasicUserSharedPage {
await this.click(cancel, '取消新增角色');
await expect(dialog, '取消后弹框应关闭').toBeHidden({ timeout: 8_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertPageHealthy('取消新增角色后');
await this.assertUiReady('取消新增角色后');
}
async cancelAddRole(): Promise<void> {
......
......@@ -18,7 +18,7 @@ export class BasicHospitalSharedPage extends BasePage {
await this.goto(path);
await this.waitSpinGone();
await this.waitSettle(400);
await this.assertPageHealthy(`打开 ${path}`);
await this.assertUiReady(`打开 ${path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
......@@ -34,6 +34,7 @@ export class BasicHospitalSharedPage extends BasePage {
await this.page.waitForURL((url) => url.pathname.includes(pathPart), { timeout: 20_000 });
await this.waitSpinGone();
await this.waitSettle(400);
await this.assertUiReady(`菜单进入${menuName}`);
}
async expectUrlContains(part: string): Promise<void> {
......@@ -61,10 +62,10 @@ export class BasicHospitalSharedPage extends BasePage {
return match ? Number(match[1]) : 0;
}
async waitForTableReady(): Promise<void> {
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertPageHealthy('列表加载');
await this.assertAfterTableWait('列表加载', options?.health ?? 'business');
}
async expectListOrEmpty(): Promise<void> {
......
......@@ -40,7 +40,7 @@ export class BasicUserSharedPage extends BasePage {
await this.waitSpinGone();
await this.waitSettle(400);
await this.waitContentReady();
await this.assertPageHealthy(`打开 ${this.meta.path}`);
await this.assertUiReady(`打开 ${this.meta.path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
......@@ -63,7 +63,7 @@ export class BasicUserSharedPage extends BasePage {
await this.waitSpinGone();
await this.waitSettle(600);
await this.waitContentReady();
await this.assertPageHealthy(`菜单进入${this.meta.menu}`);
await this.assertUiReady(`菜单进入${this.meta.menu}`);
}
async waitContentReady(): Promise<void> {
......@@ -71,13 +71,13 @@ export class BasicUserSharedPage extends BasePage {
.locator('.ant-table-thead th, .ant-empty, .menu-role-tree, .ant-tree, [placeholder="姓名、登录账号"]')
.first();
await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitForTableReady();
await this.waitForTableReady({ health: 'ui' });
}
async waitForTableReady(): Promise<void> {
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertPageHealthy('列表加载');
await this.assertAfterTableWait('列表加载', options?.health ?? 'business');
}
button(name: string, root?: Locator): Locator {
......@@ -89,11 +89,13 @@ export class BasicUserSharedPage extends BasePage {
}
async query(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询');
await this.waitForTableReady();
}
async reset(): Promise<void> {
await this.assertBeforeBusinessAction('重置');
await this.clickButton('重置');
await this.waitForTableReady();
}
......
......@@ -30,11 +30,13 @@ export class BasicUserUnionPage extends BasicUserPage {
for (let i = 0; i < count; i += 1) {
const row = rows.nth(i);
const sampleDept = await this.tryCell(row, '所属科室');
const samplePhone = await this.tryCell(row, '联系方式');
const phoneNorm = samplePhone.replace(/\s/g, '');
if (!sampleDept || sampleDept === '--') continue;
if (!phoneNorm || samplePhone === '--' || phoneNorm.length < 4) continue;
const sampleUserName = await this.cellText(row, '姓名');
const sampleLoginName = await this.cellText(row, '登录账号');
const sampleHospital = await this.cellText(row, '所属医院');
const samplePhone = await this.tryCell(row, '联系方式');
const rawRole = await this.tryCell(row, '角色');
const sampleRole = rawRole.replace(/[((].*$/, '').replace(/…|\.{3}.*/, '').trim();
const sampleStatus = await this.inferRowStatus(row);
......@@ -98,16 +100,36 @@ export class BasicUserUnionPage extends BasicUserPage {
if (sample.sampleHospital) {
await this.selectFormOption('所属医院', sample.sampleHospital).catch(() => undefined);
}
await this.expandAdvanced();
const panel = this.page.locator('.ant-drawer-open, .ant-pro-table-search-query-filter, .advance-filter-panel').first();
const root = (await panel.isVisible().catch(() => false)) ? panel : this.page;
const phone = sample.samplePhone.replace(/\s/g, '');
if (phone && phone !== '--') {
const telInput = root.locator('#form_item_tel').first();
if (await telInput.isVisible().catch(() => false)) {
await this.fill(telInput, sample.samplePhone.trim(), '联系方式');
}
}
if (sample.sampleDept && sample.sampleDept !== '--') {
await this.selectFormOption('科室', sample.sampleDept).catch(() => undefined);
}
if (sample.sampleRole) {
await this.selectFormOption('角色', sample.sampleRole).catch(() => undefined);
}
const filterBtn = this.locators.button('筛选', root).first();
if (await filterBtn.isVisible().catch(() => false)) {
await this.click(filterBtn, '筛 选');
} else {
await this.query();
}
await this.waitForTableReady();
}
async filterByDept(dept: string): Promise<void> {
await this.expandAdvanced();
const panel = this.page.locator('.ant-drawer-open, .ant-pro-table-search-query-filter, .advance-filter-panel').first();
const root = (await panel.isVisible().catch(() => false)) ? panel : this.page;
await this.selectFormOption('状态', sample.sampleStatus);
await this.selectFormOption('科室', dept);
const filterBtn = this.locators.button('筛选', root).first();
if (await filterBtn.isVisible().catch(() => false)) {
await this.click(filterBtn, '筛 选');
......
import { expect, test, type Locator, type Page } from '@playwright/test';
import { expect, test, type Page } from '@playwright/test';
import {
centerReportEntryData,
centerReportEntryDrawerFields,
type CenterReportEntryColumnKey,
type EntryListSampleRow,
} from '../../data/center-report-entry.data';
import { timeouts } from '../../config/timeouts.config';
import { BasePage } from '../../core/base.page';
import { columnHeaderMatchTokens } from '../../core/column-header-match.util';
import { CenterReportEntryLocators } from '../../locators/spd/center-report-entry.locator';
import { iframeCssSelector, tableHasColumnSorter } from '../../locators/spd/center-report-shared.locator';
import { DrawerFormControls } from './helpers/drawer-form-controls.helper';
import { clickColumnSortControl, expectColumnSortByDom } from './helpers/column-sort.helper';
import {
ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
revealColumnSettingsTriggerByScroll,
} from './helpers/column-settings.helper';
import { retryReportLoad } from './helpers/report-load-retry.helper';
import { TableScrollLayoutControls } from './helpers/table-scroll-layout.helper';
import { DownloadCenterComponent } from '../components/download-center.component';
import {
expectAdvancedDrawerSyncedFromQuick as assertDrawerSyncedFromQuick,
type DrawerQuickSyncContext,
} from './helpers/drawer-quick-sync.helper';
import { centerReportEntryData } from '../../data/center-report-entry.data';
import {
deptReportSheetComboQuickFields,
standardProductNameDateSyncFields,
type DeptQuickFieldDef,
} from '../../data/dept-report-field-defs';
import { executeExportReport } from './helpers/export-report.helper';
import { skipIfInsufficientForPageSizeChange as skipPageSizeIfInsufficient } from './helpers/pagination-pagesize.helper';
import { skipIfInsufficientForPage2 as skipPage2IfInsufficient } from './helpers/pagination-page2.helper';
import type { SampleRow } from './center-report-shared.page';
import { CenterReportSharedPage } from './center-report-shared.page';
export const CENTER_REPORT_ENTRY_PATH = centerReportEntryData.entryPath;
const drawerSyncQuickFields = deptReportSheetComboQuickFields.filter(
(field) => field.key === 'productName' || field.key === 'accountDate',
);
function usableSampleValue(value?: string): string | undefined {
const trimmed = value?.trim();
......@@ -43,190 +19,17 @@ function usableSampleValue(value?: string): string | undefined {
return trimmed;
}
export class CenterReportEntryPage extends BasePage {
private readonly locators: CenterReportEntryLocators;
private readonly drawerControls: DrawerFormControls;
private readonly columnSettingsControls: ColumnSettingsControls;
private readonly tableScrollLayoutControls: TableScrollLayoutControls;
private readonly downloadCenter: DownloadCenterComponent;
protected pageHealthScope() {
return { page: this.page, roots: [this.locators.report] };
}
/**
* 中心库入库汇总。行展开、维度、分页、导出走 CenterReportSharedPage,
* 这里只保留汇总页的组合查询与快捷区带入抽屉。
*/
export class CenterReportEntryPage extends CenterReportSharedPage {
constructor(page: Page) {
super(page);
this.locators = new CenterReportEntryLocators(page);
this.downloadCenter = new DownloadCenterComponent(page);
this.drawerControls = new DrawerFormControls({
report: this.locators.report,
drawerTitle: this.locators.drawerTitle,
drawerFormItem: (label) => this.locators.drawerFormItem(label),
click: (locator, stepName) => this.click(locator, stepName),
});
this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.locators.columnSettingsPanel(),
columnSettingsTrigger: this.locators.columnSettingsTrigger,
reportBody: this.locators.report.locator('body'),
clickOutsideTarget: this.locators.productName,
tableClickTarget: this.locators.tableBody,
});
this.tableScrollLayoutControls = new TableScrollLayoutControls({
page: this.page,
report: this.locators.report,
});
}
async open(): Promise<void> {
await this.goto(CENTER_REPORT_ENTRY_PATH);
await this.expectLoaded();
}
async expectLoaded(): Promise<void> {
await retryReportLoad(
async () => {
await this.page
.locator(iframeCssSelector('centerPages/entry'))
.first()
.waitFor({ state: 'attached', timeout: Math.max(timeouts.pageReady, 40_000) });
await this.waitVisible(this.locators.productName, timeouts.pageReady);
await this.waitVisible(this.locators.searchBtn);
await this.waitVisible(this.locators.advancedFilter);
await this.assertPageHealthy('页面加载完成');
},
() => this.goto(CENTER_REPORT_ENTRY_PATH),
(ms) => this.page.waitForTimeout(ms),
);
}
async search(): Promise<void> {
await this.click(this.locators.searchBtn, '点击查询');
await this.locators.tableBody.waitFor({ state: 'visible', timeout: 30_000 });
await this.locators.report
.locator('.ant-spin-spinning')
.waitFor({ state: 'hidden', timeout: 30_000 })
.catch(() => undefined);
await this.assertPageHealthy('查询完成后');
}
async fillProductName(value: string): Promise<void> {
await this.fill(this.locators.productName, value, '填写产品名称');
}
async resetQuickFilter(): Promise<void> {
await this.click(this.locators.resetBtn, '点击重置');
}
async openAdvancedFilter(): Promise<void> {
await this.click(this.locators.advancedFilter, '打开高级筛选');
await this.waitVisible(this.locators.drawerTitle);
}
async expectAdvancedDrawer(): Promise<void> {
await expect(this.locators.drawerTitle).toHaveText('高级筛选');
}
async applyAdvancedFilter(): Promise<void> {
if (!(await this.locators.drawerTitle.isVisible())) {
await this.openAdvancedFilter();
}
await this.click(this.locators.drawerFilterBtn, '点击筛选');
await this.locators.tableBody.waitFor({ state: 'visible', timeout: 30_000 });
await this.locators.report
.locator('.ant-spin-spinning')
.waitFor({ state: 'hidden', timeout: 30_000 })
.catch(() => undefined);
}
async resetAdvancedDrawer(): Promise<void> {
await this.click(this.locators.drawerResetBtn, '抽屉重置');
}
async expectDrawerBatchNoEmpty(): Promise<void> {
await expect(this.locators.batchNoInput).toHaveValue('');
}
async isDimensionChecked(label: (typeof centerReportEntryData.dimensions)[number]): Promise<boolean> {
const input = this.locators.dimensionCheckbox(label).locator('input[type="checkbox"]');
return input.isChecked();
}
async fillAccountDateRange(start: string, end: string): Promise<void> {
await this.click(this.locators.accountDateStart, '打开记账日期选择器');
const panel = this.locators.report.locator('.ant-picker-dropdown:visible').last();
await panel.waitFor({ state: 'visible', timeout: 10_000 });
await this.pickDateInPanel(panel, start);
await this.pickDateInPanel(panel, end);
await this.page.keyboard.press('Escape');
}
private async pickDateInPanel(panel: Locator, date: string): Promise<void> {
const cell = panel.locator(`td[title="${date}"]`);
for (let i = 0; i < 12 && (await cell.count()) === 0; i += 1) {
await panel.locator('.ant-picker-header-prev-btn').first().click();
}
await cell.last().click();
}
private async dismissDrawerOverlay(): Promise<void> {
await this.drawerControls.dismissOverlay();
super(page, centerReportEntryData);
}
async selectQuickBillType(optionText: string): Promise<void> {
const select = this.locators.quickFormItem('单据类型').locator('.ant-select');
await this.drawerControls.selectAntSelect(select, '单据类型', optionText);
await this.page.keyboard.press('Escape');
}
async selectQuickBillTypeFirst(): Promise<void> {
const select = this.locators.quickFormItem('单据类型').locator('.ant-select');
await this.drawerControls.selectAntSelect(select, '单据类型');
await this.page.keyboard.press('Escape');
}
async selectDrawerSelect(label: string, optionText?: string): Promise<void> {
await this.drawerControls.selectDrawerSelect(label, optionText);
}
async fillDrawerTextByLabel(label: string, value: string): Promise<void> {
const item = this.locators.drawerFormItem(label);
if ((await item.count()) === 0) return;
const input = item.locator('input:not([type="hidden"])').first();
if ((await input.count()) === 0) return;
await this.fill(input, value, `高级筛选填写${label}`);
}
async fillAdvancedDrawerFromSample(
sample: EntryListSampleRow,
options?: { skipKeys?: readonly string[] },
): Promise<void> {
const skipKeys = new Set(options?.skipKeys ?? []);
for (const field of centerReportEntryDrawerFields) {
if (skipKeys.has(field.key)) continue;
const value = sample[field.key]?.trim();
if (!value) continue;
switch (field.type) {
case 'text':
if (field.key === 'batchNo') {
await this.fillBatchNo(value);
} else if (field.key === 'sourceUnit') {
await this.fill(this.locators.sourceUnitInput, value, '填写来源单位');
} else if (field.key === 'supplier') {
await this.fill(this.locators.supplierInput, value, '填写进院供应商');
} else {
await this.fillDrawerTextByLabel(field.label, value);
}
break;
case 'select':
await this.selectDrawerSelect(field.label, value);
break;
default:
break;
}
}
}
async searchWithAllConditions(): Promise<EntryListSampleRow> {
/** STAT-IES 全量条件:维度 + 产品 + 单据类型 + 抽屉,空结果时收窄再查。 */
async searchWithAllConditions(): Promise<SampleRow> {
test.setTimeout(timeouts.comboTest);
await this.checkAllDimensions();
await this.search();
......@@ -235,23 +38,23 @@ export class CenterReportEntryPage extends BasePage {
await this.checkAllDimensions();
await this.search();
}
let sample = await this.extractSampleRow();
for (let rowIndex = 0; rowIndex < 5; rowIndex += 1) {
if (sample.productCode?.trim() || sample.productName?.trim()) break;
if ((await this.locators.dataRows.count()) <= rowIndex) break;
sample = await this.extractSampleRow(rowIndex);
}
const usedName =
(await this.fillProductNameUntilHasRows(usableSampleValue(sample.productCode))) ??
(await this.fillProductNameUntilHasRows(usableSampleValue(sample.productName)));
expect(
usedName,
`产品ID/名称「${sample.productCode ?? sample.productName ?? '未知'}」查询无数据`,
).toBeTruthy();
expect(usedName, `产品ID/名称「${sample.productCode ?? sample.productName ?? '未知'}」查询无数据`).toBeTruthy();
sample.productName = usedName;
if (sample.billType?.trim()) {
await this.selectQuickBillType(sample.billType);
await this.selectQuickFormSelect('单据类型', sample.billType);
await this.page.keyboard.press('Escape');
}
await this.search();
......@@ -262,43 +65,66 @@ export class CenterReportEntryPage extends BasePage {
await this.search();
}
const beforeDrawer = await this.locators.dataRows.count();
if (beforeDrawer > 0) {
if ((await this.locators.dataRows.count()) > 0) {
await this.openAdvancedFilter();
await this.fillAdvancedDrawerFromSample(sample);
await this.applyAdvancedFilter();
if ((await this.locators.dataRows.count()) === 0) {
await this.openAdvancedFilter();
await this.fillAdvancedDrawerFromSample(sample, { skipKeys: ['sourceUnit', 'supplier'] });
await this.fillDrawerFieldDefs(
sample,
(this.data.drawerFields ?? []).filter((field) => field.key !== 'sourceUnit' && field.key !== 'supplier'),
{ onlyIfPresent: true },
);
await this.applyAdvancedFilter();
}
if ((await this.locators.dataRows.count()) === 0) {
await this.page.keyboard.press('Escape').catch(() => undefined);
await this.dismissDrawerOverlay();
await this.closeAdvancedDrawer();
await this.fillProductName(usedName!);
if (sample.billType?.trim()) {
await this.selectQuickBillType(sample.billType);
await this.selectQuickFormSelect('单据类型', sample.billType);
await this.page.keyboard.press('Escape');
}
await this.search();
}
}
if ((await this.locators.dataRows.count()) === 0 && usedName) {
await this.resetQuickFilter();
await this.checkAllDimensions();
await this.fillProductName(usedName);
if (sample.billType?.trim()) {
await this.selectQuickBillType(sample.billType);
await this.selectQuickFormSelect('单据类型', sample.billType);
await this.page.keyboard.press('Escape');
}
await this.search();
}
expect(
await this.locators.dataRows.count(),
'全量组合查询后应至少保留 1 条匹配数据',
).toBeGreaterThan(0);
expect(await this.locators.dataRows.count(), '全量组合查询后应至少保留 1 条匹配数据').toBeGreaterThan(0);
return sample;
}
/** 表头/单元格错位时产品名称会粘上规格数字,先用原值再去掉尾部数字重试。 */
async fillQuickFiltersForDrawerSync(sample: SampleRow): Promise<void> {
await this.applyQuickFields(sample, drawerSyncQuickFields, { onlyIfPresent: true });
}
async expectAdvancedDrawerSyncedFromQuick(sample: SampleRow): Promise<void> {
await super.expectAdvancedDrawerSyncedFromQuick(
sample,
drawerSyncQuickFields,
standardProductNameDateSyncFields,
);
}
async expectCombinedFilterResult(sample: SampleRow): Promise<void> {
await this.expectQueryWithoutError();
expect(await this.locators.dataRows.count(), '组合查询后无匹配数据,疑似组合逻辑缺陷').toBeGreaterThan(0);
const productName = usableSampleValue(sample.productName);
if (productName) await this.expectFirstRowContains(productName);
await this.expectSummaryVisible();
await this.expectPaginationTotal();
}
private async fillProductNameUntilHasRows(raw?: string): Promise<string | undefined> {
const seed = usableSampleValue(raw);
if (!seed) return undefined;
......@@ -316,876 +142,6 @@ export class CenterReportEntryPage extends BasePage {
}
return undefined;
}
private drawerQuickSyncContext(): DrawerQuickSyncContext {
return {
productName: this.locators.productName,
quickFormItem: (label) => this.locators.quickFormItem(label),
drawerFormItem: (label) => this.locators.drawerFormItem(label),
getQuickDateRangeDisplay: (label) => this.getQuickDateRangeDisplay(label),
getQuickSelectValue: (label) => this.getQuickSelectValue(label),
};
}
async getQuickDateRangeDisplay(label: string): Promise<string> {
const item = this.locators.quickFormItem(label);
const picker = item.locator('.ant-picker').first();
if ((await picker.count()) === 0) return '';
return (await picker.innerText()).replace(/\s+/g, ' ').trim();
}
async getQuickSelectValue(label: string): Promise<string> {
const item = this.locators.quickFormItem(label);
const selected = item.locator('.ant-select-selection-item').first();
if ((await selected.count()) === 0) return '';
return (await selected.innerText()).trim();
}
async fillQuickFiltersForDrawerSync(sample: EntryListSampleRow): Promise<void> {
const productKeyword =
sample.productCode ?? sample.productName ?? this.getSampleValue(sample, 'productName');
await this.fillProductName(productKeyword);
const accountDate = sample.accountDate?.trim();
if (accountDate) {
const dateOnly = accountDate.split(/\s+/)[0].replace(/\//g, '-');
await this.fillAccountDateRange(dateOnly, dateOnly);
}
}
async expectAdvancedDrawerSyncedFromQuick(sample: EntryListSampleRow): Promise<void> {
const quickFields: readonly DeptQuickFieldDef[] = deptReportSheetComboQuickFields.filter(
(field) => field.key === 'productName' || field.key === 'accountDate',
);
await assertDrawerSyncedFromQuick(
this.drawerQuickSyncContext(),
sample,
quickFields,
standardProductNameDateSyncFields,
);
}
async expectCombinedFilterResult(sample: EntryListSampleRow): Promise<void> {
await this.expectQueryWithoutError();
const rowCount = await this.locators.dataRows.count();
expect(rowCount, '组合查询后无匹配数据,疑似组合逻辑缺陷').toBeGreaterThan(0);
const productName = usableSampleValue(sample.productName);
if (productName) await this.expectFirstRowContains(productName);
await this.expectSummaryVisible();
await this.expectPaginationTotal();
}
async getFirstRowText(): Promise<string> {
await expect(this.locators.dataRows.first()).toBeVisible({ timeout: 15_000 });
return (await this.locators.dataRows.first().innerText()).trim();
}
async getColumnValues(columnName: string, maxRows = 8): Promise<string[]> {
const headers = (await this.locators.tableHeaderCells().allTextContents()).map((h) =>
h.replace(/\s+/g, '').trim(),
);
const normalizedTarget = columnName.replace(/\s+/g, '');
const columnIndex = headers.findIndex((header) => {
return header.includes(normalizedTarget) || normalizedTarget.includes(header);
});
if (columnIndex < 0) {
return [];
}
const rowCount = Math.min(await this.locators.dataRows.count(), maxRows);
const values: string[] = [];
for (let i = 0; i < rowCount; i += 1) {
const cell = this.locators.dataRows.nth(i).locator('td').nth(columnIndex);
values.push((await cell.innerText()).replace(/\s+/g, ' ').trim());
}
return values;
}
async getRowSignatures(maxRows = 8): Promise<string[]> {
const rowCount = Math.min(await this.locators.dataRows.count(), maxRows);
const signatures: string[] = [];
for (let i = 0; i < rowCount; i += 1) {
signatures.push((await this.locators.dataRows.nth(i).innerText()).replace(/\s+/g, ' ').trim());
}
return signatures;
}
async expectVisibleRowCountAtMost(maxRows: number): Promise<void> {
const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
const primaryRows = wrapper.locator(
'.ant-table-container > .ant-table-body .ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row):not(.ant-table-expanded-row)',
);
await expect
.poll(
async () => {
const primary = await primaryRows.count();
if (primary > 0) return primary;
return this.locators.dataRows.count();
},
{ timeout: 8_000 },
)
.toBeLessThanOrEqual(maxRows);
}
async fillBatchNo(value: string): Promise<void> {
await this.fill(this.locators.batchNoInput, value, '填写批号');
}
async checkDimension(label: (typeof centerReportEntryData.dimensions)[number]): Promise<void> {
const wrapper = this.locators.dimensionCheckbox(label);
const input = wrapper.locator('input[type="checkbox"]');
if (!(await input.isChecked())) {
await this.click(wrapper, `勾选${label}`);
}
}
async checkAllDimensions(): Promise<void> {
for (const label of centerReportEntryData.dimensions) {
await this.checkDimension(label);
}
}
async extractSampleRow(rowIndex = centerReportEntryData.sampleRowIndex): Promise<EntryListSampleRow> {
await expect(this.locators.dataRows.nth(rowIndex)).toBeVisible({ timeout: 15_000 });
const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
const byHeader = await wrapper.evaluate((root, targetRowIndex: number) => {
const thead =
root.querySelector('.ant-table-container > .ant-table-header .ant-table-thead') ??
root.querySelector('.ant-table-header .ant-table-thead') ??
root.querySelector('.ant-table-content .ant-table-thead') ??
root.querySelector('.ant-table-thead');
const headerRow =
thead?.querySelector('tr:last-child') ??
thead?.querySelector('tr') ??
root.querySelector('.ant-table-thead tr');
const headers = Array.from(headerRow?.querySelectorAll('th') ?? []).map((th) => {
const titleEl = th.querySelector('.ant-table-column-title');
return (titleEl?.textContent ?? th.textContent ?? '').replace(/\s+/g, '').trim();
});
const body =
root.querySelector('.ant-table-container > .ant-table-body table tbody.ant-table-tbody') ??
root.querySelector('.ant-table-content > table > tbody.ant-table-tbody') ??
root.querySelector('tbody.ant-table-tbody');
const rows = Array.from(body?.querySelectorAll(':scope > tr.ant-table-row') ?? []).filter(
(tr) =>
!tr.classList.contains('ant-table-measure-row') &&
!tr.classList.contains('ant-table-expanded-row'),
);
const row = rows[targetRowIndex];
const result: Record<string, string> = {};
if (!row) return result;
const tds = Array.from(row.querySelectorAll(':scope > td'));
for (let c = 0; c < headers.length && c < tds.length; c += 1) {
const key = headers[c];
if (!key || key === '序号') continue;
const value = (tds[c]?.textContent ?? '').replace(/\s+/g, ' ').trim();
if (value) result[key] = value;
}
return result;
}, rowIndex);
const sample: EntryListSampleRow = {};
for (const [columnKey, headerLabel] of Object.entries(centerReportEntryData.columns)) {
const normalizedLabel = headerLabel.replace(/\s+/g, '');
const value = byHeader[normalizedLabel];
if (value) sample[columnKey as CenterReportEntryColumnKey] = value;
}
expect(Object.keys(sample).length, `首行未解析到列表字段,表头:${Object.keys(byHeader).join('、')}`).toBeGreaterThan(0);
if (!sample.productName?.trim() && !sample.productCode?.trim()) {
const row = this.locators.dataRows.nth(rowIndex);
const cells = (await row.locator('td').allTextContents()).map((c) => c.replace(/\s+/g, ' ').trim());
const productCell = cells.find(
(c) =>
c &&
c !== '--' &&
(/产品/.test(c) || /^[\u4e00-\u9fa5A-Za-z][\u4e00-\u9fa5A-Za-z0-9._-]{2,}/.test(c)),
);
if (productCell) {
sample.productName = productCell.replace(/[\d.]+$/u, '').trim() || productCell;
}
}
return sample;
}
async searchAndExtractSample(rowIndex?: number): Promise<EntryListSampleRow> {
await this.search();
return this.extractSampleRow(rowIndex);
}
getSampleValue(sample: EntryListSampleRow, key: CenterReportEntryColumnKey): string {
const value = sample[key]?.trim();
expect(value, `列表首行缺少列:${centerReportEntryData.columns[key]}`).toBeTruthy();
return value!;
}
async expectTableHeaders(): Promise<void> {
// 维度勾选后列变宽,部分表头在右侧滚动区;用整表文本存在性断言,避免 .ant-table-column-title 精确可见误杀
const headers = (
await this.locators.report.locator('.ant-table-thead th').evaluateAll((ths) =>
ths.map((th) => {
const title = th.querySelector('.ant-table-column-title');
return ((title?.textContent ?? th.textContent) ?? '').replace(/\s+/g, '').trim();
}),
)
).filter(Boolean);
const required = [...centerReportEntryData.coreTableHeaders];
const hits: string[] = [];
const missing: string[] = [];
for (const name of required) {
const normalized = name.replace(/\s+/g, '');
const found = headers.some((h) => h.includes(normalized) || normalized.includes(h));
if (found) hits.push(name);
else missing.push(name);
}
// 至少命中一半核心列(维度展开后个别列可能隐藏/改名)
expect(
hits.length,
`表头缺少「${missing.join('、')}」,当前表头:${headers.join('、')}`,
).toBeGreaterThanOrEqual(Math.max(3, Math.ceil(required.length / 2)));
for (const name of hits.slice(0, 4)) {
const header = this.locators.tableHeader(name);
await header.scrollIntoViewIfNeeded().catch(() => undefined);
await this.scrollColumnIntoView(name).catch(() => undefined);
}
}
async expectTableHorizontalScrollAndLayout(): Promise<void> {
const { horizontalScrollAnchorColumn, horizontalScrollTargetColumn, horizontalScrollDragDelta } =
centerReportEntryData;
await this.tableScrollLayoutControls.expectHorizontalScrollAndLayout(
horizontalScrollAnchorColumn,
horizontalScrollTargetColumn,
horizontalScrollDragDelta,
(name) => this.locators.report.locator('.ant-table-thead').getByText(name, { exact: true }),
);
}
async expectSummaryVisible(): Promise<void> {
for (const keyword of centerReportEntryData.summaryKeywords) {
await expect(this.locators.summaryText(keyword)).toBeVisible();
}
for (const keyword of centerReportEntryData.outboundSummaryKeywords) {
await expect(this.locators.summaryText(keyword)).toHaveCount(0);
}
}
async expectEmptyList(): Promise<void> {
await this.expectMockEmptyList();
}
/** mock 反例:仅校验当前页空列表/空态且页面无报错。 */
async expectMockEmptyList(): Promise<void> {
await this.assertPageHealthy('mock反例查询后');
const rowCount = await this.locators.dataRows.count();
const emptyLocator = this.locators.report.locator('.ant-empty, .ant-table-placeholder').first();
const emptyVisible = await emptyLocator.isVisible().catch(() => false);
if (rowCount === 0 || emptyVisible) {
if (emptyVisible) {
await expect(emptyLocator).toBeVisible();
}
return;
}
expect(rowCount, 'mock反例查询后当前页仍显示业务数据行').toBe(0);
}
async searchWithMockFromConfig(): Promise<void> {
await this.searchWithMockNotExistProduct();
}
async searchWithMockNotExistProduct(): Promise<void> {
await this.fillProductName(centerReportEntryData.mockNotExistProduct);
await this.search();
}
async expectColumnSortCase(columnName: string): Promise<void> {
const sortable = await this.isColumnSortable(columnName);
if (!sortable) {
const required = centerReportEntryData.sortColumnCases.some((c) => c.column === columnName);
if (required) {
expect(sortable, `列「${columnName}」应为可排序列,但当前无排序控件`).toBeTruthy();
}
test.skip(true, `${columnName} 列当前不可排序`);
}
await this.expectColumnSortToggle(columnName);
}
async expandFirstRow(): Promise<void> {
await expect(this.locators.firstExpandIcon).toBeVisible({ timeout: 15_000 });
await this.click(this.locators.firstExpandIcon, '展开首行');
await this.waitVisible(this.locators.expandedRow, 15_000);
}
async collapseFirstRow(): Promise<void> {
await this.click(this.locators.firstExpandIcon, '收起首行');
await expect(this.locators.expandedRow).toBeHidden({ timeout: 10_000 });
}
async expectExpandedRow(): Promise<void> {
await expect(this.locators.expandedRow).toBeVisible();
const expandBtn = this.locators.firstExpandIcon;
const expanded =
(await expandBtn.getAttribute('aria-label')) === '关闭行' ||
(await expandBtn.evaluate((el) => el.classList.contains('ant-table-row-expand-icon-expanded')));
expect(expanded).toBeTruthy();
}
async expectSubTableHeaders(): Promise<void> {
const requiredHeaders = centerReportEntryData.subTableHeaders.filter((h) => h !== '查看');
for (const name of requiredHeaders) {
await expect(this.locators.expandedSubTableHeader(name)).toBeVisible();
}
const viewAction = this.locators.subTableViewButton(0);
await expect(viewAction).toBeVisible();
}
async getSubTableInboundOrderNo(rowIndex = 0): Promise<string> {
const subTable = this.locators.expandedRow.locator('.ant-table').first();
const headers = (await subTable.locator('.ant-table-thead th').allTextContents()).map((h) =>
h.replace(/\s+/g, '').trim(),
);
const orderColIndex = headers.findIndex((h) => h.includes('入库单号') || h === '单号');
const subRow = this.locators.subTableRow(rowIndex);
await expect(subRow).toBeVisible();
const cellIndex = orderColIndex >= 0 ? orderColIndex : 0;
const orderNo = (await subRow.locator('td').nth(cellIndex).innerText()).trim();
expect(orderNo.length).toBeGreaterThan(0);
return orderNo;
}
async clickSubTableViewDetail(rowIndex = 0): Promise<string> {
const orderNo = await this.getSubTableInboundOrderNo(rowIndex);
const expanded = this.locators.expandedRow;
const scrollHosts = expanded.locator('.ant-table-body, .ant-table-content, .ant-table-scroll');
const hostCount = await scrollHosts.count();
for (let i = 0; i < hostCount; i += 1) {
await scrollHosts
.nth(i)
.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
})
.catch(() => undefined);
}
const viewBtn = this.locators.subTableViewButton(rowIndex);
await viewBtn.scrollIntoViewIfNeeded().catch(() => undefined);
await expect(viewBtn, '子表「查看」按钮应可见').toBeVisible({ timeout: 10_000 });
await this.click(viewBtn, '点击子表查看');
return orderNo;
}
async expectQueryWithoutError(): Promise<void> {
expect(await this.hasVisibleErrorNotice(), '查询后页面出现错误提示').toBeFalsy();
}
private async hasVisibleErrorNotice(): Promise<boolean> {
const errorLocators = [
this.page.locator('.ant-message-error'),
this.page.locator('.ant-notification-notice-error'),
this.locators.report.locator('.ant-message-error'),
this.locators.report.locator('.ant-notification-notice-error'),
];
for (const locator of errorLocators) {
if ((await locator.count()) > 0 && (await locator.first().isVisible())) {
return true;
}
}
return false;
}
private async isOrderNoVisibleOnDetailSurfaces(orderNo: string): Promise<boolean> {
const popupPages = this.page.context().pages().filter((p) => p !== this.page);
const surfaces: Array<Pick<Page, 'getByText'>> = [this.page, ...popupPages];
for (const surface of surfaces) {
if ((await surface.getByText(orderNo).count()) > 0) {
return true;
}
}
for (const frame of this.page.frames()) {
if ((await frame.getByText(orderNo).count()) > 0) {
return true;
}
}
return false;
}
private async hasDetailNavigationSignal(
tabCountBefore: number,
urlBefore: string,
popup: Page | null,
): Promise<boolean> {
if (popup) return true;
if (this.page.url() !== urlBefore) return true;
if ((await this.page.locator('.ant-tabs-tab').count()) > tabCountBefore) return true;
const topModal = await this.page.locator('.ant-modal:visible').count();
const topDrawer = await this.page.locator('.ant-drawer-open').count();
const reportModal = await this.locators.report.locator('.ant-modal:visible').count();
const detailIframe = await this.page
.locator('iframe[src*="stock"], iframe[src*="inStock"], iframe[src*="psi"], iframe[src*="detail"]')
.count();
if (topModal + topDrawer + reportModal + detailIframe > 0) return true;
for (const keyword of centerReportEntryData.detailTabKeywords) {
if ((await this.page.locator('.ant-tabs-tab-active').filter({ hasText: keyword }).count()) > 0) {
return true;
}
}
return false;
}
async expectViewDetailNavigation(orderNo: string, navigationContext?: {
tabCountBefore: number;
urlBefore: string;
popup: Page | null;
}): Promise<void> {
expect(await this.hasVisibleErrorNotice()).toBeFalsy();
const ctx = navigationContext ?? {
tabCountBefore: await this.page.locator('.ant-tabs-tab').count(),
urlBefore: this.page.url(),
popup: null as Page | null,
};
await expect
.poll(async () => {
if (await this.hasVisibleErrorNotice()) return false;
const navigated = await this.hasDetailNavigationSignal(ctx.tabCountBefore, ctx.urlBefore, ctx.popup);
const hasOrderNo = await this.isOrderNoVisibleOnDetailSurfaces(orderNo);
return navigated && hasOrderNo;
}, { timeout: 20_000 })
.toBeTruthy();
}
async openSubTableViewDetail(rowIndex = 0): Promise<void> {
const tabCountBefore = await this.page.locator('.ant-tabs-tab').count();
const urlBefore = this.page.url();
const popupPromise = this.page.waitForEvent('popup', { timeout: 20_000 }).catch(() => null);
const orderNo = await this.clickSubTableViewDetail(rowIndex);
const popup = await popupPromise;
await this.expectViewDetailNavigation(orderNo, { tabCountBefore, urlBefore, popup });
}
async expectSubTableQuantityMatchesMainRow(sample?: EntryListSampleRow): Promise<void> {
const rowSample = sample ?? (await this.extractSampleRow(0));
const mainQtyText = rowSample.inboundQty;
if (!mainQtyText?.trim()) {
await this.guardedSkip('首行缺少入库数量,跳过主子表数量抽样');
return;
}
const mainQty = parseFloat(mainQtyText.replace(/,/g, ''));
expect(Number.isFinite(mainQty)).toBeTruthy();
const subTable = this.locators.expandedRow.locator('.ant-table').first();
const subHeaders = (await subTable.locator('.ant-table-thead th').allTextContents()).map((h) =>
h.replace(/\s+/g, '').trim(),
);
const qtyColIndex = subHeaders.findIndex(
(h) => h.includes('入库数量') || (h.includes('数量') && !h.includes('单号')),
);
const subRows = subTable.locator('tbody tr');
expect(await subRows.count()).toBeGreaterThan(0);
if (qtyColIndex < 0) {
expect(mainQty).toBeGreaterThan(0);
return;
}
let sum = 0;
const rowCount = await subRows.count();
for (let i = 0; i < rowCount; i += 1) {
const text = (await subRows.nth(i).locator('td').nth(qtyColIndex).innerText()).trim();
const num = parseFloat(text.replace(/,/g, ''));
if (Number.isFinite(num)) sum += num;
}
expect(Math.abs(sum - mainQty)).toBeLessThanOrEqual(0.01 * Math.max(mainQty, 1));
}
private async getRowCellByHeader(row: Locator, headerName: string): Promise<string> {
const index = await this.getColumnIndex(headerName);
return (await row.locator('td').nth(index).innerText()).trim();
}
private async getColumnIndex(columnName: string): Promise<number> {
const headers = await this.locators.tableHeaderCells().allTextContents();
const normalized = headers.map((h) => h.replace(/\s+/g, '').trim());
const target = columnName.replace(/\s+/g, '');
const index = normalized.findIndex((h) => h === target || h.startsWith(target));
expect(index, `未找到列:${columnName}`).toBeGreaterThanOrEqual(0);
return index;
}
async scrollColumnIntoView(columnName: string): Promise<void> {
const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
const sorter = this.locators.columnSorter(columnName);
const body = wrapper.locator('.ant-table-body, .ant-table-content').first();
await body.waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined);
const isSorterVisible = async (): Promise<boolean> =>
(await sorter.count()) > 0 && (await sorter.isVisible().catch(() => false));
if (!(await isSorterVisible())) {
await body.evaluate((el) => {
(el as HTMLElement).scrollLeft = 0;
}).catch(() => undefined);
const delta = centerReportEntryData.horizontalScrollDragDelta ?? 320;
for (let attempt = 0; attempt < 10 && !(await isSorterVisible()); attempt += 1) {
await body.evaluate((el, dx) => {
(el as HTMLElement).scrollLeft += dx;
}, delta).catch(() => undefined);
}
}
const scrollHosts = wrapper.locator(
'.ant-table-body, .ant-table-header .ant-table-content, .ant-table-content',
);
await scrollHosts.first().waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined);
await scrollHosts.evaluateAll((containers, searchTokens) => {
const root = containers[0]?.closest('.ant-table');
const headers = root?.querySelectorAll('.ant-table-thead th') ?? [];
const tokens = searchTokens as string[];
let target: HTMLElement | undefined;
let bestScore = 0;
for (const cell of Array.from(headers)) {
if (!cell.querySelector('.ant-table-column-sorter')) continue;
if ((cell as HTMLElement).offsetParent === null) continue;
const clone = cell.cloneNode(true) as HTMLElement;
clone
.querySelectorAll('.ant-table-resize-handle, .ant-table-column-sorter, .anticon')
.forEach((el) => el.remove());
const normalized = (clone.textContent ?? '').replace(/\s+/g, '').trim();
if (!normalized) continue;
for (const token of tokens) {
if (!token) continue;
let score = 0;
if (normalized === token) score = token.length + 1000;
else if (normalized.includes(token)) score = token.length;
else if (normalized.length >= 4 && token.includes(normalized)) score = normalized.length;
if (score > bestScore) {
bestScore = score;
target = cell as HTMLElement;
}
}
}
if (!target) return;
const thRect = target.getBoundingClientRect();
for (const container of containers) {
const host = container as HTMLElement;
const hostRect = host.getBoundingClientRect();
if (thRect.left < hostRect.left + 96) {
host.scrollLeft -= hostRect.left - thRect.left + 96;
} else if (thRect.right > hostRect.right - 96) {
host.scrollLeft += thRect.right - hostRect.right + 96;
}
}
}, columnHeaderMatchTokens(columnName));
await sorter.scrollIntoViewIfNeeded().catch(() => undefined);
}
async isColumnSortable(columnName: string): Promise<boolean> {
const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
await wrapper
.locator('.ant-table-tbody, .ant-table-body')
.first()
.waitFor({ state: 'attached', timeout: 20_000 })
.catch(() => undefined);
const hasSorter = await tableHasColumnSorter(wrapper, columnName).catch(() => false);
if (!hasSorter) {
await this.assertPageHealthy(`列「${columnName}」不可排序`);
}
return hasSorter;
}
async clickColumnSort(columnName: string, order?: 'ascend' | 'descend'): Promise<void> {
await this.scrollColumnIntoView(columnName);
const header = this.locators.columnHeaderCell(columnName);
const sorter = this.locators.columnSorter(columnName);
if ((await sorter.count()) > 0) {
await sorter.waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined);
} else {
await this.waitVisible(header, 15_000);
}
const label = order === 'descend' ? '降序' : order === 'ascend' ? '升序' : '排序';
await test.step(`点击${columnName}列${label}`, async () => {
await clickColumnSortControl({
header,
sorter,
up: this.locators.columnSorterUp(columnName),
down: this.locators.columnSorterDown(columnName),
order,
});
});
await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined);
}
async recoverTableData(): Promise<void> {
await this.resetQuickFilter();
await this.search();
}
async expectColumnSortToggle(columnName: string): Promise<void> {
const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
await expectColumnSortByDom({
wrapper,
columnName,
waitAfterClick: async () => {
await this.locators.report
.locator('.ant-spin-spinning')
.waitFor({ state: 'hidden', timeout: 12_000 })
.catch(() => undefined);
},
});
}
async expectColumnSortIndicatorActive(columnName: string): Promise<void> {
const header = this.locators.columnHeaderCell(columnName);
const sorter = this.locators.columnSorter(columnName);
await expect
.poll(async () => {
const ariaSort = await header.getAttribute('aria-sort');
if (ariaSort === 'ascending' || ariaSort === 'descending') return true;
const sorterActive = await sorter.evaluate((el) => el.classList.contains('active'));
const upActive = await this.locators
.columnSorterUp(columnName)
.evaluate((el) => el.classList.contains('active') || el.classList.contains('on'));
const downActive = await this.locators
.columnSorterDown(columnName)
.evaluate((el) => el.classList.contains('active') || el.classList.contains('on'));
return sorterActive || upActive || downActive;
}, { timeout: 15_000 })
.toBeTruthy();
}
async openColumnSettings(): Promise<void> {
const panel = this.locators.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) {
return;
}
const triggerCandidates = [this.locators.columnSettingsTrigger];
let trigger = await findUsableColumnSettingsTrigger(triggerCandidates);
if (!trigger) {
await revealColumnSettingsTriggerByScroll({
scroll: this.tableScrollLayoutControls,
tableWrapper: this.locators.report.locator('.ant-table-wrapper').first(),
triggerCandidates,
horizontalScrollTargetColumn: centerReportEntryData.horizontalScrollTargetColumn,
horizontalScrollDragDelta: centerReportEntryData.horizontalScrollDragDelta,
scrollColumnIntoView: async (column) => {
await this.locators.tableHeader(column).scrollIntoViewIfNeeded().catch(() => undefined);
},
});
trigger = await findUsableColumnSettingsTrigger(triggerCandidates);
}
if (!trigger) {
await this.guardedSkip('列设置齿轮不可见');
}
await test.step('打开列设置', async () => {
await trigger!.click({ force: true });
});
await this.waitVisible(panel, 10_000);
}
async expectColumnSettingsPanel(): Promise<void> {
const panel = this.locators.columnSettingsPanel();
await expect(panel).toBeVisible();
await expect(panel.getByText('列展示')).toBeVisible();
await expect(panel.getByText('字段设置')).toBeVisible();
await expect(panel.getByText('重置')).toBeVisible();
}
async closeColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls.closePanel();
}
async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise<void> {
const row = this.locators.columnSettingsCheckboxRow(columnName);
await expect(row).toBeVisible();
const checkbox = row.locator('input[type="checkbox"]').first();
if ((await checkbox.isChecked()) !== visible) {
await row.click();
}
}
async expectTableHeaderVisible(columnName: string, visible: boolean): Promise<void> {
const header = this.locators.columnHeaderCell(columnName).or(this.locators.tableHeader(columnName));
if (visible) {
await expect(header.first()).toBeVisible({ timeout: 10_000 });
} else {
await expect(this.locators.tableHeader(columnName)).toHaveCount(0);
}
}
async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise<void> {
const checkbox = this.locators.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first();
if (checked) {
await expect(checkbox).toBeChecked();
} else {
await expect(checkbox).not.toBeChecked();
}
}
async pickToggleColumnName(): Promise<string> {
for (const name of centerReportEntryData.columnSettingsToggleCandidates) {
if (await this.locators.tableHeader(name).isVisible().catch(() => false)) {
return name;
}
}
return centerReportEntryData.columnSettingsToggleCandidates[0];
}
/** 场景:取消勾选非核心列后表头隐藏,再勾选后表头恢复。 */
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) =>
this.expectSettingsCheckboxChecked(name, checked),
});
}
async expectPaginationTotal(): Promise<void> {
await expect(this.locators.paginationTotal).toHaveText(/共\s+\d+\s+条/);
}
/** 场景1:列表无数据或不足 2 页时 skip。 */
async skipIfInsufficientForPage2(): Promise<void> {
await skipPage2IfInsufficient({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
getDataRowCount: async () => this.locators.dataRows.count(),
hasPaginationItem: async (pageNum) => (await this.locators.paginationItem(pageNum).count()) > 0,
});
}
/** 场景1:切换至第2页标准流程。 */
async executePage2SwitchTest(): Promise<void> {
await this.skipIfInsufficientForPage2();
const first = await this.getFirstRowText();
await this.goToPage(2);
await this.expectActivePage(2);
const second = await this.getFirstRowText();
expect(first, '第1页首行应有数据').toBeTruthy();
expect(second, '第2页首行应有数据').toBeTruthy();
expect(second).not.toEqual(first);
}
async goToPage(pageNum: number): Promise<void> {
if (pageNum === 2) {
await this.skipIfInsufficientForPage2();
}
const item = this.locators.paginationItem(pageNum);
if ((await item.count()) === 0) {
if (pageNum === 2) {
await this.skipWhenDataInsufficient(`查询结果不足${pageNum}页`);
} else {
await this.guardedSkip(`总页数不足 ${pageNum} 页`);
}
}
await this.locators.paginationTotal.scrollIntoViewIfNeeded();
await test.step(`跳转第 ${pageNum} 页`, async () => {
await item.evaluate((node) => {
const link = node.querySelector('a') as HTMLElement | null;
(link ?? (node as HTMLElement)).click();
});
});
await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined);
await expect
.poll(async () => (await this.locators.paginationActiveItem().innerText()).trim(), { timeout: 20_000 })
.toBe(String(pageNum));
if ((await this.locators.dataRows.count()) === 0) {
if (pageNum === 2) {
await this.skipWhenDataInsufficient(`第 ${pageNum} 页无数据行`);
} else {
await this.guardedSkip(`第 ${pageNum} 页无数据行`);
}
}
}
async expectActivePage(pageNum: number): Promise<void> {
await expect(this.locators.paginationActiveItem()).toHaveText(String(pageNum), { timeout: 5_000 });
}
/** 场景2:列表无数据时 skip。 */
async skipIfInsufficientForPageSizeChange(): Promise<void> {
await skipPageSizeIfInsufficient({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
getDataRowCount: async () => this.locators.dataRows.count(),
});
}
/** 场景2:切换每页条数标准流程。 */
async executePageSizeChangeTest(
fromLabel: string,
toLabel: string,
maxRows: number,
): Promise<void> {
await this.skipIfInsufficientForPageSizeChange();
await this.changePageSize(fromLabel, toLabel);
await this.expectVisibleRowCountAtMost(maxRows);
}
async changePageSize(fromLabel: string, toLabel: string): Promise<void> {
const changer = this.locators.report.locator('.ant-pagination-options-size-changer').first();
if ((await changer.count()) > 0) {
await this.click(changer, '打开每页条数');
} else {
await this.click(this.locators.report.getByText(fromLabel, { exact: true }), '打开每页条数');
}
const option = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: toLabel })
.first()
.or(
this.locators.report
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: toLabel })
.first(),
);
if ((await option.count()) === 0) {
await this.click(
this.page.getByText(toLabel, { exact: true }).or(this.locators.report.getByText(toLabel, { exact: true })),
`选择${toLabel}`,
);
} else {
await this.click(option, `选择${toLabel}`);
}
await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined);
}
async expectFirstRowContains(keyword: string | RegExp): Promise<void> {
if (typeof keyword === 'string' && /\d{4}[-/]\d{1,2}([-/]\d{1,2})?\s*~\s*\d{4}/.test(keyword)) {
return;
}
await expect(this.locators.dataRows.first()).toBeVisible({ timeout: 15_000 });
await expect(this.locators.dataRows.first()).toContainText(keyword);
}
async exportReport(): Promise<void> {
await executeExportReport({
page: this.page,
clickExport: () => this.click(this.locators.exportBtn, '点击导出'),
hasVisibleErrorNotice: () => this.hasVisibleErrorNotice(),
assertHealthy: () => this.assertPageHealthy('导出后'),
});
}
/** @deprecated 使用 exportReport() */
async exportReportDirect(): Promise<void> {
await this.exportReport();
}
async expectProductNameEmpty(): Promise<void> {
await expect(this.locators.productName).toHaveValue('');
}
async expectProductName(value: string): Promise<void> {
await expect(this.locators.productName).toHaveValue(value);
}
}
export const CENTER_REPORT_ENTRY_PATH = centerReportEntryData.reportPath;
......@@ -340,10 +340,10 @@ export class CenterReportSharedPage extends BasePage {
const loadOnce = async () => {
// 先等 iframe 挂上,避免在壳页上空等 searchBtn
const iframe = this.page.locator(iframeCssSelector(this.data.iframeSrc)).first();
await iframe.waitFor({ state: 'attached', timeout: Math.max(timeouts.pageReady, 25_000) });
await iframe.waitFor({ state: 'attached', timeout: timeouts.pageReady });
const frame = await iframe.elementHandle().then((handle) => handle?.contentFrame() ?? null);
await frame?.waitForLoadState('domcontentloaded').catch(() => undefined);
await this.waitVisible(this.locators.searchBtn, Math.max(timeouts.pageReady, 25_000));
await this.waitVisible(this.locators.searchBtn, timeouts.pageReady);
await this.waitVisible(this.locators.resetBtn, timeouts.expect * 2);
if (this.data.hasAdvancedFilter) {
await this.waitVisible(this.locators.advancedFilter, timeouts.expect).catch(() => undefined);
......@@ -354,10 +354,9 @@ export class CenterReportSharedPage extends BasePage {
.catch(() => undefined);
// 默认不等表就绪:显式 search() 再等,避免 open+search 双轮询
if (this.data.waitTableOnOpen) {
await this.waitForTableReady().catch(() => undefined);
await this.waitForTableReady({ health: 'ui' }).catch(() => undefined);
}
}
await this.assertPageHealthy('页面加载完成');
};
await retryReportLoad(
......@@ -366,19 +365,24 @@ export class CenterReportSharedPage extends BasePage {
await this.page.reload({ waitUntil: 'domcontentloaded' }).catch(() => undefined);
await this.goto(this.data.reportPath);
},
(ms) => this.page.waitForTimeout(ms),
(ms) => new Promise((resolve) => setTimeout(resolve, ms)),
);
}
async search(): Promise<void> {
await this.waitForSearchLoadingDone();
await waitAntSpinGone(this.locators.report);
if (await isSearchButtonLoading(this.locators.searchBtn)) {
await this.waitForSearchLoadingDone();
}
if (await isAntSpinVisible(this.locators.report)) {
await waitAntSpinGone(this.locators.report);
}
if (!(await this.locators.searchBtn.isVisible().catch(() => false))) {
await this.ensureQuickFilterExpanded().catch(() => undefined);
}
await this.assertBeforeBusinessAction('查询');
await this.click(this.locators.searchBtn, '点击查询');
if (this.data.isChart) {
await this.waitForSearchLoadingDone();
await waitSearchButtonIdle(this.locators.searchBtn, timeouts.searchLoading, timeouts.tableIdleGrace);
return;
}
await this.waitForTableReady();
......@@ -390,7 +394,7 @@ export class CenterReportSharedPage extends BasePage {
}
/** 等待列表查询结束:loading 结束且有数据行或空态。 */
async waitForTableReady(): Promise<void> {
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
if (this.page.isClosed()) return;
await this.locators.tableBody
.waitFor({ state: 'attached', timeout: timeouts.tableAttached })
......@@ -415,12 +419,13 @@ export class CenterReportSharedPage extends BasePage {
if (await isAntSpinVisible(this.locators.report.locator('.ant-table-wrapper'))) return 'loading';
const anyRow = await this.locators.tableBody.locator('tr').count().catch(() => 0);
if (anyRow > 0) return 'ready';
// 行或空态晚一拍渲染时不要立刻判空;超过 8 秒仍无 spin 再放行,避免无占位符的空表卡满超时
if (Date.now() - started < 8_000) return 'loading';
// 无 spin、按钮也不在 loading 时只留短窗口,避免空表卡满超时
if (Date.now() - started < timeouts.tableIdleGrace) return 'loading';
return 'idle';
}, Math.max(timeouts.tableReady, 35_000));
}, timeouts.tableReady);
if (!this.page.isClosed()) {
await this.assertPageHealthy('查询完成后');
const mode = options?.health ?? 'business';
await this.assertAfterTableWait(mode === 'ui' ? '列表就绪' : '查询完成后', mode);
}
}
......@@ -1603,6 +1608,7 @@ export class CenterReportSharedPage extends BasePage {
await executeExportReport({
page: this.page,
prepareRowSelection: options?.prepareRowSelection,
prepareBusinessAction: () => this.assertBeforeBusinessAction('导出'),
clickExport: () =>
this.click(exportBtn, `点击${tableLabel}导出`, { operation: 'write' }),
hasVisibleErrorNotice: () => this.hasVisibleErrorNotice(),
......@@ -2755,11 +2761,7 @@ export class CenterReportSharedPage extends BasePage {
await this.selectQuickFormSelect(label, value);
return;
}
if (await input.isVisible().catch(() => false)) {
await this.fill(input, value, `填写${label}`);
return;
}
await input.fill(value, { force: true, timeout: 8_000 }).catch(() => undefined);
await this.fill(input, value, `填写${label}`);
return;
}
if (
......@@ -2792,11 +2794,7 @@ export class CenterReportSharedPage extends BasePage {
await this.selectQuickFormSelect(label, value);
return;
}
if (await byId.isVisible().catch(() => false)) {
await this.fill(byId, value, `填写${label}`);
} else {
await byId.fill(value, { force: true, timeout: 8_000 }).catch(() => undefined);
}
await this.fill(byId, value, `填写${label}`);
}
}
}
......@@ -3932,7 +3930,7 @@ export class CenterReportSharedPage extends BasePage {
if (!found) {
await this.toggleFilterArea();
await this.toggleFilterArea();
await this.assertPageHealthy('收起展开筛选区后');
await this.assertUiReady('收起展开筛选区后');
return;
}
......
import { expect, test, type Locator, type Page } from '@playwright/test';
import { test, type Page } from '@playwright/test';
import { centerReportSheetData, type CenterReportSheetColumnKey, centerReportSheetDrawerFields } from '../../data/center-report-sheet.data';
import { timeouts } from '../../config/timeouts.config';
import { BasePage } from '../../core/base.page';
import { columnHeaderMatchTokens } from '../../core/column-header-match.util';
import { CenterReportSheetLocators } from '../../locators/spd/center-report-sheet.locator';
import { iframeCssSelector, tableHasColumnSorter } from '../../locators/spd/center-report-shared.locator';
import { DownloadCenterComponent } from '../components/download-center.component';
import { DrawerFormControls } from './helpers/drawer-form-controls.helper';
import { clickColumnSortControl, expectColumnSortByDom } from './helpers/column-sort.helper';
import { centerReportSheetData } from '../../data/center-report-sheet.data';
import {
ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
revealColumnSettingsTriggerByScroll,
} from './helpers/column-settings.helper';
import { retryReportLoad } from './helpers/report-load-retry.helper';
import { TableScrollLayoutControls } from './helpers/table-scroll-layout.helper';
import {
detailSheetSummaryMatch,
} from '../../data/center-report-shared.types';
import { ReportSummaryMatchControls } from './helpers/report-summary-match.helper';
import {
expectAdvancedDrawerSyncedFromQuick as assertDrawerSyncedFromQuick,
type DrawerQuickSyncContext,
} from './helpers/drawer-quick-sync.helper';
import {
deptReportSheetComboQuickFields,
standardProductNameDateSyncFields,
type DeptQuickFieldDef,
deptReportSheetComboQuickFields,
} from '../../data/dept-report-field-defs';
import { executeExportReport } from './helpers/export-report.helper';
import { skipIfInsufficientForPageSizeChange as skipPageSizeIfInsufficient } from './helpers/pagination-pagesize.helper';
import { skipIfInsufficientForPage2 as skipPage2IfInsufficient } from './helpers/pagination-page2.helper';
export const CENTER_REPORT_SHEET_PATH =
'/spd/form/centerReport/report/centerPages/sheet';
export type ListSampleRow = Partial<Record<CenterReportSheetColumnKey, string>>;
export class CenterReportSheetPage extends BasePage {
private readonly locators: CenterReportSheetLocators;
protected pageHealthScope() {
return { page: this.page, roots: [this.locators.report] };
}
private readonly drawerControls: DrawerFormControls;
private readonly columnSettingsControls: ColumnSettingsControls;
private readonly tableScrollLayoutControls: TableScrollLayoutControls;
private readonly downloadCenter: DownloadCenterComponent;
private readonly summaryMatchControls: ReportSummaryMatchControls;
import type { SampleRow } from './center-report-shared.page';
import { CenterReportSharedPage } from './center-report-shared.page';
const drawerSyncQuickFields = deptReportSheetComboQuickFields.filter(
(field) => field.key === 'productName' || field.key === 'accountDate',
);
/**
* 中心库出入库明细。通用查询、排序、列设置、分页、导出走 CenterReportSharedPage,
* 这里只保留明细页的组合查询与快捷区带入抽屉。
*/
export class CenterReportSheetPage extends CenterReportSharedPage {
constructor(page: Page) {
super(page);
this.locators = new CenterReportSheetLocators(page);
this.downloadCenter = new DownloadCenterComponent(page);
this.drawerControls = new DrawerFormControls({
report: this.locators.report,
drawerTitle: this.locators.drawerTitle,
drawerFormItem: (label) => this.locators.drawerFormItem(label),
click: (locator, stepName) => this.click(locator, stepName),
});
this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.locators.columnSettingsPanel(),
columnSettingsTrigger: this.locators.columnSettingsTrigger,
reportBody: this.locators.report.locator('body'),
clickOutsideTarget: this.locators.productName,
tableClickTarget: this.locators.tableBody,
});
this.tableScrollLayoutControls = new TableScrollLayoutControls({
page: this.page,
report: this.locators.report,
});
this.summaryMatchControls = new ReportSummaryMatchControls({
profile: detailSheetSummaryMatch,
reportBody: this.locators.report,
dataRows: this.locators.dataRows,
paginationTotal: this.locators.paginationTotal,
sampleRowIndex: centerReportSheetData.sampleRowIndex,
searchAndExtractSample: (rowIndex) => this.searchAndExtractSample(rowIndex),
extractSampleRow: (rowIndex) => this.extractSampleRow(rowIndex),
resetQuickFilter: () => this.resetQuickFilter(),
fillProductName: (value) => this.fillProductName(value),
openAdvancedFilter: () => this.openAdvancedFilter(),
fillBatchNo: (value) => this.fillBatchNo(value),
fillDrawerTextByLabel: (label, value) => this.fillDrawerTextByLabel(label, value),
applyAdvancedFilter: () => this.applyAdvancedFilter(),
fillQuickDateRange: (_label, start, end) => this.fillAccountDateRange(start, end),
guardedSkip: (reason) => this.guardedSkip(reason),
});
}
async open(): Promise<void> {
await this.goto(CENTER_REPORT_SHEET_PATH);
await this.expectLoaded();
}
async expectLoaded(): Promise<void> {
await retryReportLoad(
async () => {
await this.page
.locator(iframeCssSelector('centerPages/sheet'))
.first()
.waitFor({ state: 'attached', timeout: Math.max(timeouts.pageReady, 40_000) });
await this.waitVisible(this.locators.searchBtn, timeouts.pageReady);
if (!(await this.locators.productName.isVisible().catch(() => false))) {
const toggle = this.locators.report.getByText(/^(展开|收起)$/).first();
if (await toggle.isVisible().catch(() => false)) {
await toggle.click().catch(() => undefined);
}
}
await this.waitVisible(this.locators.productName, timeouts.pageReady);
await this.waitVisible(this.locators.advancedFilter);
await this.assertPageHealthy('页面加载完成');
},
() => this.goto(CENTER_REPORT_SHEET_PATH),
(ms) => this.page.waitForTimeout(ms),
);
}
async expectTitle(): Promise<void> {
await expect(this.page).toHaveTitle(/FLI\+/);
}
async search(): Promise<void> {
await this.click(this.locators.searchBtn, '点击查询');
await this.locators.tableBody.waitFor({ state: 'visible', timeout: 30_000 });
await this.assertPageHealthy('查询完成后');
}
/** 默认查询并从列表提取首行样本(不写死查询条件)。 */
async searchAndExtractSample(rowIndex = centerReportSheetData.sampleRowIndex): Promise<ListSampleRow> {
await this.search();
return this.extractSampleRow(rowIndex);
}
/** 按表头列名读取指定行,返回 columnKey → 单元格文本。 */
async extractSampleRow(rowIndex = 0): Promise<ListSampleRow> {
const headers = await this.locators.tableHeaderCells().allTextContents();
const row = this.locators.dataRows.nth(rowIndex);
await expect(row).toBeVisible({ timeout: 15_000 });
const cells = await row.locator('td').allTextContents();
const normalizedHeaders = headers.map((h) => h.replace(/\s+/g, '').trim());
const byHeader: Record<string, string> = {};
for (let i = 0; i < Math.min(normalizedHeaders.length, cells.length); i += 1) {
const key = normalizedHeaders[i];
if (!key || key === '序号') {
continue;
}
const value = cells[i]?.trim();
if (value) {
byHeader[key] = value;
}
}
const sample: ListSampleRow = {};
for (const [columnKey, headerLabel] of Object.entries(centerReportSheetData.columns)) {
const aliases = centerReportSheetData.columnAliases[columnKey as CenterReportSheetColumnKey] ?? [];
const labels = [headerLabel, ...aliases];
for (const label of labels) {
const normalizedLabel = label.replace(/\s+/g, '');
const value = byHeader[normalizedLabel];
if (value) {
sample[columnKey as CenterReportSheetColumnKey] = value;
break;
}
}
}
expect(Object.keys(sample).length).toBeGreaterThan(0);
return sample;
}
getSampleValue(sample: ListSampleRow, key: CenterReportSheetColumnKey): string {
const value = sample[key]?.trim();
expect(value, `列表首行缺少列:${centerReportSheetData.columns[key]}`).toBeTruthy();
return value!;
}
async fillProductName(value: string): Promise<void> {
await this.fill(this.locators.productName, value, '填写产品名称');
}
async resetQuickFilter(): Promise<void> {
await this.click(this.locators.resetBtn, '点击重置');
}
async resetAdvancedDrawer(): Promise<void> {
await this.click(this.locators.drawerResetBtn, '抽屉重置');
}
async expectDrawerBatchNoEmpty(): Promise<void> {
await expect(this.locators.batchNoInput).toHaveValue('');
}
async expectProductNameEmpty(): Promise<void> {
await expect(this.locators.productName).toHaveValue('');
}
async searchWithMockFromConfig(): Promise<void> {
await this.searchWithMockNotExistProduct();
}
async searchWithMockNotExistProduct(): Promise<void> {
await this.fillProductName(centerReportSheetData.mockNotExistProduct);
await this.search();
}
async expectColumnSortCase(columnName: string): Promise<void> {
const sortable = await this.isColumnSortable(columnName);
if (!sortable) {
const required = centerReportSheetData.sortColumnCases.some((c) => c.column === columnName);
if (required) {
expect(sortable, `列「${columnName}」应为可排序列,但当前无排序控件`).toBeTruthy();
}
test.skip(true, `${columnName} 列当前不可排序`);
}
await this.expectColumnSortToggle(columnName);
}
async expectEmptyList(): Promise<void> {
await this.expectMockEmptyList();
}
/** mock 反例:仅校验当前页空列表/空态且页面无报错。 */
async expectMockEmptyList(): Promise<void> {
await this.assertPageHealthy('mock反例查询后');
const rowCount = await this.locators.dataRows.count();
const emptyLocator = this.locators.report.locator('.ant-empty, .ant-table-placeholder').first();
const emptyVisible = await emptyLocator.isVisible().catch(() => false);
if (rowCount === 0 || emptyVisible) {
if (emptyVisible) {
await expect(emptyLocator).toBeVisible();
}
return;
}
expect(rowCount, 'mock反例查询后当前页仍显示业务数据行').toBe(0);
}
async getFirstRowText(): Promise<string> {
await expect
.poll(async () => this.locators.dataRows.count(), { timeout: 20_000 })
.toBeGreaterThan(0);
const rows = this.locators.dataRows;
const count = await rows.count();
let row = rows.first();
for (let i = 0; i < Math.min(count, 20); i += 1) {
const candidate = rows.nth(i);
if (await candidate.isVisible().catch(() => false)) {
row = candidate;
break;
}
}
await row.scrollIntoViewIfNeeded().catch(() => undefined);
return ((await row.innerText().catch(() => '')) ?? '').trim();
}
async getColumnValues(columnName: string, maxRows = 8): Promise<string[]> {
const headers = (await this.locators.tableHeaderCells().allTextContents()).map((h) =>
h.replace(/\s+/g, '').trim(),
);
const normalizedTarget = columnName.replace(/\s+/g, '');
const columnIndex = headers.findIndex((header) => {
return header.includes(normalizedTarget) || normalizedTarget.includes(header);
});
if (columnIndex < 0) {
return [];
}
const rowCount = Math.min(await this.locators.dataRows.count(), maxRows);
const values: string[] = [];
for (let i = 0; i < rowCount; i += 1) {
const cell = this.locators.dataRows.nth(i).locator('td').nth(columnIndex);
values.push((await cell.innerText()).replace(/\s+/g, ' ').trim());
}
return values;
}
async getRowSignatures(maxRows = 8): Promise<string[]> {
const rowCount = Math.min(await this.locators.dataRows.count(), maxRows);
const signatures: string[] = [];
for (let i = 0; i < rowCount; i += 1) {
signatures.push((await this.locators.dataRows.nth(i).innerText()).replace(/\s+/g, ' ').trim());
}
return signatures;
}
/** 场景1:列表无数据或不足 2 页时 skip。 */
async skipIfInsufficientForPage2(): Promise<void> {
await skipPage2IfInsufficient({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
getDataRowCount: async () => this.locators.dataRows.count(),
hasPaginationItem: async (pageNum) => (await this.locators.paginationItem(pageNum).count()) > 0,
});
}
/** 场景1:切换至第2页标准流程。 */
async executePage2SwitchTest(): Promise<void> {
await this.skipIfInsufficientForPage2();
const first = await this.getFirstRowText();
await this.goToPage(2);
await this.expectActivePage(2);
const second = await this.getFirstRowText();
expect(first, '第1页首行应有数据').toBeTruthy();
expect(second, '第2页首行应有数据').toBeTruthy();
expect(second).not.toEqual(first);
}
async goToPage(pageNum: number): Promise<void> {
if (pageNum === 2) {
await this.skipIfInsufficientForPage2();
}
const item = this.locators.paginationItem(pageNum);
if ((await item.count()) === 0) {
if (pageNum === 2) {
await this.skipWhenDataInsufficient(`查询结果不足${pageNum}页`);
} else {
await this.guardedSkip(`总页数不足 ${pageNum} 页`);
}
}
await this.locators.paginationTotal.scrollIntoViewIfNeeded();
await test.step(`跳转第 ${pageNum} 页`, async () => {
await item.evaluate((node) => {
const link = node.querySelector('a') as HTMLElement | null;
(link ?? (node as HTMLElement)).click();
});
});
await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined);
await expect
.poll(async () => (await this.locators.paginationActiveItem().innerText()).trim(), { timeout: 20_000 })
.toBe(String(pageNum));
if ((await this.locators.dataRows.count()) === 0) {
if (pageNum === 2) {
await this.skipWhenDataInsufficient(`第 ${pageNum} 页无数据行`);
} else {
await this.guardedSkip(`第 ${pageNum} 页无数据行`);
}
}
}
async expectActivePage(pageNum: number): Promise<void> {
await expect(this.locators.paginationActiveItem()).toHaveText(String(pageNum), { timeout: 5_000 });
}
/** 场景2:列表无数据时 skip。 */
async skipIfInsufficientForPageSizeChange(): Promise<void> {
await skipPageSizeIfInsufficient({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
getDataRowCount: async () => this.locators.dataRows.count(),
});
}
/** 场景2:切换每页条数标准流程。 */
async executePageSizeChangeTest(
fromLabel: string,
toLabel: string,
maxRows: number,
): Promise<void> {
await this.skipIfInsufficientForPageSizeChange();
await this.changePageSize(fromLabel, toLabel);
await this.expectVisibleRowCountAtMost(maxRows);
}
async changePageSize(fromLabel: string, toLabel: string): Promise<void> {
const changer = this.locators.report.locator('.ant-pagination-options-size-changer').first();
if ((await changer.count()) > 0) {
await this.click(changer, '打开每页条数');
} else {
await this.click(this.locators.report.getByText(fromLabel, { exact: true }), '打开每页条数');
}
const option = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: toLabel })
.first()
.or(
this.locators.report
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: toLabel })
.first(),
);
if ((await option.count()) === 0) {
await this.click(
this.page.getByText(toLabel, { exact: true }).or(this.locators.report.getByText(toLabel, { exact: true })),
`选择${toLabel}`,
);
} else {
await this.click(option, `选择${toLabel}`);
}
await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined);
}
async expectVisibleRowCountAtMost(maxRows: number): Promise<void> {
const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
const primaryRows = wrapper.locator(
'.ant-table-container > .ant-table-body .ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row):not(.ant-table-expanded-row)',
);
await expect
.poll(
async () => {
const primary = await primaryRows.count();
if (primary > 0) return primary;
return this.locators.dataRows.count();
},
{ timeout: 8_000 },
)
.toBeLessThanOrEqual(maxRows);
}
async openAdvancedFilter(): Promise<void> {
await this.click(this.locators.advancedFilter, '打开高级筛选');
await this.waitVisible(this.locators.drawerTitle);
}
async expectAdvancedDrawer(): Promise<void> {
await expect(this.locators.drawerTitle).toHaveText('高级筛选');
}
private async dismissDrawerOverlay(): Promise<void> {
await this.drawerControls.dismissOverlay();
}
private async ensureAdvancedDrawerOpen(): Promise<void> {
if (!(await this.locators.drawerTitle.isVisible())) {
await this.openAdvancedFilter();
}
}
async applyAdvancedFilter(): Promise<void> {
await this.ensureAdvancedDrawerOpen();
await this.dismissDrawerOverlay();
await this.click(this.locators.drawerFilterBtn, '点击筛选');
await this.locators.tableBody.waitFor({ state: 'visible', timeout: 30_000 });
}
async fillBatchNo(value: string): Promise<void> {
await this.fill(this.locators.batchNoInput, value, '填写批号');
}
async fillAccountDateRange(start: string, end: string): Promise<void> {
await this.click(this.locators.accountDateStart, '打开记账日期选择器');
const panel = this.locators.report.locator('.ant-picker-dropdown:visible').last();
await panel.waitFor({ state: 'visible', timeout: 10_000 });
await this.pickDateInPanel(panel, start);
await this.pickDateInPanel(panel, end);
await this.page.keyboard.press('Escape');
}
private async pickDateInPanel(panel: Locator, date: string): Promise<void> {
const cell = panel.locator(`td[title="${date}"]`);
for (let i = 0; i < 12 && (await cell.count()) === 0; i += 1) {
await panel.locator('.ant-picker-header-prev-btn').first().click();
}
await cell.last().click();
}
private drawerLabelFor(key: CenterReportSheetColumnKey, fallback: string): string {
return centerReportSheetData.drawerLabels[key] ?? fallback;
}
async selectDrawerCascader(label: string, value: string): Promise<void> {
await this.drawerControls.selectCascader(label, value);
}
async selectQuickBillType(optionText: string): Promise<void> {
const select = this.locators.quickFormItem('单据类型').locator('.ant-select');
await this.drawerControls.selectAntSelect(select, '单据类型', optionText);
await this.page.keyboard.press('Escape');
}
async selectQuickBillTypeFirst(): Promise<void> {
const select = this.locators.quickFormItem('单据类型').locator('.ant-select');
await this.drawerControls.selectAntSelect(select, '单据类型');
await this.page.keyboard.press('Escape');
}
async selectDrawerSelect(label: string, optionText?: string): Promise<void> {
await this.drawerControls.selectDrawerSelect(label, optionText);
}
async fillDrawerDateRange(label: string, start: string, end: string): Promise<void> {
const item = this.locators.drawerFormItem(label);
if ((await item.count()) === 0) {
return;
}
await item.scrollIntoViewIfNeeded();
const startInput = item.getByPlaceholder('开始时间').first();
if ((await startInput.count()) === 0) {
return;
}
await this.click(startInput, `打开${label}选择器`);
const panel = this.locators.report.locator('.ant-picker-dropdown:visible').last();
await panel.waitFor({ state: 'visible', timeout: 10_000 });
await this.pickDateInPanel(panel, start);
await this.pickDateInPanel(panel, end);
await this.dismissDrawerOverlay();
}
async fillDrawerTextByLabel(label: string, value: string): Promise<void> {
const item = this.locators.drawerFormItem(label);
if ((await item.count()) === 0) {
return;
}
await item.scrollIntoViewIfNeeded();
const input = item.locator('input:not([type="hidden"])').first();
if ((await input.count()) === 0) {
return;
}
await this.fill(input, value, `高级筛选填写${label}`);
}
/** 按配置顺序填写高级筛选抽屉全部可用字段(样本有值才填)。 */
async fillAdvancedDrawerFromSample(sample: ListSampleRow): Promise<void> {
await this.ensureAdvancedDrawerOpen();
for (const field of centerReportSheetDrawerFields) {
await this.ensureAdvancedDrawerOpen();
const value = sample[field.key]?.trim();
if (!value) {
continue;
}
const label = field.label ?? this.drawerLabelFor(field.key, centerReportSheetData.columns[field.key]);
switch (field.type) {
case 'text':
if (field.key === 'batchNo') {
await this.fillBatchNo(value);
} else {
await this.fillDrawerTextByLabel(label, value);
}
break;
case 'select':
await this.selectDrawerSelect(label, value);
break;
case 'cascader':
await this.selectDrawerCascader(label, value);
break;
case 'dateRange': {
const dateOnly = value.split(/\s+/)[0];
await this.fillDrawerDateRange(label, dateOnly, dateOnly);
break;
}
default:
break;
}
}
super(page, centerReportSheetData);
}
async fillAdvancedDrawerAllFields(sample: ListSampleRow): Promise<void> {
await this.fillAdvancedDrawerFromSample(sample);
}
/** STAT-IOD-004:快捷区 + 高级筛选全量条件组合查询(条件均来自列表样本) */
async searchWithAllConditions(): Promise<ListSampleRow> {
/** STAT-IOD-004:快捷区 + 高级筛选全量条件(样本有值才写)。 */
async searchWithAllConditions(): Promise<SampleRow> {
test.setTimeout(timeouts.comboTest);
const sample = await this.searchAndExtractSample();
const productKeyword =
sample.productCode ?? sample.productName ?? this.getSampleValue(sample, 'productName');
await this.fillProductName(productKeyword);
const accountDate = this.getSampleValue(sample, 'accountDate');
const dateOnly = accountDate.split(/\s+/)[0];
await this.fillAccountDateRange(dateOnly, dateOnly);
if (sample.billType?.trim()) {
await this.selectQuickBillType(sample.billType);
}
await this.applyQuickFields(sample, deptReportSheetComboQuickFields, { onlyIfPresent: true });
await this.openAdvancedFilter();
await this.fillAdvancedDrawerFromSample(sample);
await this.applyAdvancedFilter();
return sample;
}
private drawerQuickSyncContext(): DrawerQuickSyncContext {
return {
productName: this.locators.productName,
quickFormItem: (label) => this.locators.quickFormItem(label),
drawerFormItem: (label) => this.locators.drawerFormItem(label),
getQuickDateRangeDisplay: (label) => this.getQuickDateRangeDisplay(label),
getQuickSelectValue: (label) => this.getQuickSelectValue(label),
};
async fillAdvancedDrawerFromSample(sample: SampleRow): Promise<void> {
const fields = (this.data.drawerFields ?? []).filter((field) => field.type !== 'date');
await this.fillDrawerFieldDefs(sample, fields, { onlyIfPresent: true });
await this.fillOperationDate(sample.operationDate);
}
async getQuickDateRangeDisplay(label: string): Promise<string> {
const item = this.locators.quickFormItem(label);
const picker = item.locator('.ant-picker').first();
if ((await picker.count()) === 0) return '';
return (await picker.innerText()).replace(/\s+/g, ' ').trim();
}
async getQuickSelectValue(label: string): Promise<string> {
const item = this.locators.quickFormItem(label);
const selected = item.locator('.ant-select-selection-item').first();
if ((await selected.count()) === 0) return '';
return (await selected.innerText()).trim();
/** 填入与抽屉重叠的快捷区字段(产品名称、记账日期)。 */
async fillQuickFiltersForDrawerSync(sample: SampleRow): Promise<void> {
await this.applyQuickFields(sample, drawerSyncQuickFields, { onlyIfPresent: true });
}
/** 填入与抽屉重叠的快捷区字段(产品名称、记账日期),供联动带入校验。 */
async fillQuickFiltersForDrawerSync(sample: ListSampleRow): Promise<void> {
const productKeyword =
sample.productCode ?? sample.productName ?? this.getSampleValue(sample, 'productName');
await this.fillProductName(productKeyword);
const accountDate = sample.accountDate?.trim();
if (accountDate) {
const dateOnly = accountDate.split(/\s+/)[0].replace(/\//g, '-');
await this.fillAccountDateRange(dateOnly, dateOnly);
}
}
async expectAdvancedDrawerSyncedFromQuick(sample: ListSampleRow): Promise<void> {
const quickFields: readonly DeptQuickFieldDef[] = deptReportSheetComboQuickFields.filter(
(field) => field.key === 'productName' || field.key === 'accountDate',
);
await assertDrawerSyncedFromQuick(
this.drawerQuickSyncContext(),
async expectAdvancedDrawerSyncedFromQuick(sample: SampleRow): Promise<void> {
await super.expectAdvancedDrawerSyncedFromQuick(
sample,
quickFields,
drawerSyncQuickFields,
standardProductNameDateSyncFields,
);
}
async expectCombinedFilterResult(sample: ListSampleRow): Promise<void> {
async expectCombinedFilterResult(sample: SampleRow): Promise<void> {
await this.expectQueryWithoutError();
if (sample.productCode) {
await this.expectFirstRowContains(sample.productCode);
} else if (sample.productName) {
await this.expectFirstRowContains(sample.productName);
}
if (sample.batchNo) {
await this.expectFirstRowContains(sample.batchNo);
}
if (sample.campus) {
await this.expectFirstRowContains(sample.campus);
}
if (sample.barcode) {
await this.expectFirstRowContains(sample.barcode);
}
if (sample.orderNo) {
await this.expectFirstRowContains(sample.orderNo);
}
if (sample.sourceTarget) {
await this.expectFirstRowContains(sample.sourceTarget);
}
if (sample.billMode) {
await this.expectFirstRowContains(sample.billMode);
}
if (sample.operator) {
await this.expectFirstRowContains(sample.operator);
}
if (sample.consumableCategory) {
await this.expectFirstRowContains(sample.consumableCategory);
}
if (sample.supplier) {
await this.expectFirstRowContains(sample.supplier);
for (const key of [
'batchNo',
'campus',
'barcode',
'orderNo',
'sourceTarget',
'billMode',
'operator',
'consumableCategory',
'supplier',
] as const) {
const value = sample[key]?.trim();
if (value && value !== '--' && value !== '-') {
await this.expectFirstRowContains(value);
}
}
await this.expectSummaryVisible();
await this.expectPaginationTotal();
}
async expectTableHeaders(): Promise<void> {
for (const name of ['产品编号', '产品名称', '批号', '进院供应商', '院区名称']) {
await expect(this.locators.tableHeader(name)).toBeVisible();
}
}
/** 横向拖拉滚动列表,并校验表头/表体列无变形错位或异常间隙。 */
async expectTableHorizontalScrollAndLayout(): Promise<void> {
const { horizontalScrollAnchorColumn, horizontalScrollTargetColumn, horizontalScrollDragDelta } =
centerReportSheetData;
await this.tableScrollLayoutControls.expectHorizontalScrollAndLayout(
horizontalScrollAnchorColumn,
horizontalScrollTargetColumn,
horizontalScrollDragDelta,
(name) => this.locators.report.locator('.ant-table-thead').getByText(name, { exact: true }),
);
}
/** 点击指定列升序或降序按钮(Ant Design:▲ / ▼ 分别激活)。 */
async clickColumnSort(columnName: string, order?: 'ascend' | 'descend'): Promise<void> {
await this.scrollColumnIntoView(columnName);
const header = this.locators.columnHeaderCell(columnName);
const sorter = this.locators.columnSorter(columnName);
if ((await sorter.count()) > 0) {
await sorter.waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined);
} else {
await this.waitVisible(header, 15_000);
}
const label = order === 'descend' ? '降序' : order === 'ascend' ? '升序' : '排序';
await test.step(`点击${columnName}列${label}`, async () => {
await clickColumnSortControl({
header,
sorter,
up: this.locators.columnSorterUp(columnName),
down: this.locators.columnSorterDown(columnName),
order,
});
});
await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined);
await this.locators.tableBody.waitFor({ state: 'visible', timeout: 15_000 }).catch(() => undefined);
}
/** 横向滚动使目标列排序按钮进入可点击区域(同步滚动表头与表体)。 */
async scrollColumnIntoView(columnName: string): Promise<void> {
const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
const sorter = this.locators.columnSorter(columnName);
const body = wrapper.locator('.ant-table-body, .ant-table-content').first();
await body.waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined);
const isSorterVisible = async (): Promise<boolean> =>
(await sorter.count()) > 0 && (await sorter.isVisible().catch(() => false));
if (!(await isSorterVisible())) {
await body.evaluate((el) => {
(el as HTMLElement).scrollLeft = 0;
}).catch(() => undefined);
const delta = centerReportSheetData.horizontalScrollDragDelta ?? 320;
for (let attempt = 0; attempt < 10 && !(await isSorterVisible()); attempt += 1) {
await body.evaluate((el, dx) => {
(el as HTMLElement).scrollLeft += dx;
}, delta).catch(() => undefined);
}
}
const scrollHosts = wrapper.locator(
'.ant-table-body, .ant-table-header .ant-table-content, .ant-table-content',
);
await scrollHosts.first().waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined);
await scrollHosts.evaluateAll((containers, searchTokens) => {
const root = containers[0]?.closest('.ant-table');
const headers = root?.querySelectorAll('.ant-table-thead th') ?? [];
const tokens = searchTokens as string[];
let target: HTMLElement | undefined;
let bestScore = 0;
for (const cell of Array.from(headers)) {
if (!cell.querySelector('.ant-table-column-sorter')) continue;
if ((cell as HTMLElement).offsetParent === null) continue;
const clone = cell.cloneNode(true) as HTMLElement;
clone
.querySelectorAll('.ant-table-resize-handle, .ant-table-column-sorter, .anticon')
.forEach((el) => el.remove());
const normalized = (clone.textContent ?? '').replace(/\s+/g, '').trim();
if (!normalized) continue;
for (const token of tokens) {
if (!token) continue;
let score = 0;
if (normalized === token) score = token.length + 1000;
else if (normalized.includes(token)) score = token.length;
else if (normalized.length >= 4 && token.includes(normalized)) score = normalized.length;
if (score > bestScore) {
bestScore = score;
target = cell as HTMLElement;
}
}
}
if (!target) {
return;
}
const thRect = target.getBoundingClientRect();
for (const container of containers) {
const host = container as HTMLElement;
const hostRect = host.getBoundingClientRect();
if (thRect.left < hostRect.left + 96) {
host.scrollLeft -= hostRect.left - thRect.left + 96;
} else if (thRect.right > hostRect.right - 96) {
host.scrollLeft += thRect.right - hostRect.right + 96;
}
}
}, columnHeaderMatchTokens(columnName));
await sorter.scrollIntoViewIfNeeded().catch(() => undefined);
}
/** 当前页面指定列是否带排序控件。只扫 DOM,不等 sticky 表头定位器。 */
async isColumnSortable(columnName: string): Promise<boolean> {
const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
await wrapper
.locator('.ant-table-tbody, .ant-table-body')
.first()
.waitFor({ state: 'attached', timeout: 20_000 })
.catch(() => undefined);
const hasSorter = await tableHasColumnSorter(wrapper, columnName).catch(() => false);
if (!hasSorter) {
await this.assertPageHealthy(`列「${columnName}」不可排序`);
}
return hasSorter;
}
/** 重置快捷区并重新查询,恢复列表数据(排序后偶发空表时使用)。 */
async recoverTableData(): Promise<void> {
await this.resetQuickFilter();
await this.search();
}
/** 读取当前页表头中带 .ant-table-column-sorter 的可见列名(去重)。 */
async getVisibleSortableColumnNames(): Promise<string[]> {
const headerRow = this.locators.report.locator('.ant-table-thead tr').last();
const headers = headerRow.locator('th');
const count = await headers.count();
const sortable: string[] = [];
for (let i = 0; i < count; i += 1) {
const th = headers.nth(i);
const sorter = th.locator('.ant-table-column-sorter');
if ((await sorter.count()) === 0 || !(await sorter.isVisible())) {
continue;
}
const titleLocator = th.locator('.ant-table-column-title').first();
const title =
(await titleLocator.count()) > 0
? (await titleLocator.innerText()).trim()
: (await th.innerText()).replace(/\s+/g, ' ').trim();
if (!title || title === '序号' || sortable.includes(title)) {
continue;
}
sortable.push(title);
}
if (sortable.length === 0) {
for (const name of centerReportSheetData.sortableColumnCandidates) {
const sorter = this.locators.columnSorter(name);
if ((await sorter.count()) > 0 && (await sorter.isVisible())) {
sortable.push(name);
}
}
return sortable;
}
const prioritized: string[] = [];
for (const name of centerReportSheetData.sortableColumnCandidates) {
if (sortable.includes(name)) {
prioritized.push(name);
}
}
for (const name of sortable) {
if (!prioritized.includes(name)) {
prioritized.push(name);
}
}
return prioritized;
}
/** 单列排序:DOM 内点击,指示器或行序任一成立即通过。 */
async expectColumnSortToggle(columnName: string): Promise<void> {
const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
await expectColumnSortByDom({
wrapper,
columnName,
waitAfterClick: async () => {
await this.locators.report
.locator('.ant-spin-spinning')
.waitFor({ state: 'hidden', timeout: 12_000 })
.catch(() => undefined);
},
});
}
async expectColumnSortIndicatorActive(columnName: string): Promise<void> {
const header = this.locators.columnHeaderCell(columnName);
const sorter = this.locators.columnSorter(columnName);
await expect
.poll(async () => {
const ariaSort = await header.getAttribute('aria-sort');
if (ariaSort === 'ascending' || ariaSort === 'descending') {
return true;
}
const sorterActive = await sorter.evaluate((el) => el.classList.contains('active'));
const upActive = await this.locators
.columnSorterUp(columnName)
.evaluate((el) => el.classList.contains('active') || el.classList.contains('on'));
const downActive = await this.locators
.columnSorterDown(columnName)
.evaluate((el) => el.classList.contains('active') || el.classList.contains('on'));
return sorterActive || upActive || downActive;
}, { timeout: 15_000 })
.toBeTruthy();
}
async expectColumnSorterVisible(columnName: string): Promise<void> {
await expect(this.locators.columnSorter(columnName)).toBeVisible({ timeout: 15_000 });
}
/** 断言列处于升序或降序(匹配 .active / .on 指示器)。 */
async expectColumnSortOrder(columnName: string, order: 'ascend' | 'descend'): Promise<void> {
const indicator =
order === 'ascend'
? this.locators.columnSorterUp(columnName)
: this.locators.columnSorterDown(columnName);
await expect(indicator).toHaveClass(/active|on/);
}
/** 读取当前页指定列的单元格文本(按表头列名,默认最多 10 行以控时)。 */
async getColumnTexts(columnName: string, maxRows = 10): Promise<string[]> {
const index = await this.getColumnIndex(columnName);
const rows = this.locators.dataRows;
const count = Math.min(await rows.count(), maxRows);
const texts: string[] = [];
for (let i = 0; i < count; i += 1) {
texts.push((await rows.nth(i).locator('td').nth(index).innerText()).trim());
}
return texts.filter((t) => t.length > 0);
}
private async getColumnIndex(columnName: string): Promise<number> {
const headers = await this.locators.tableHeaderCells().allTextContents();
const normalized = headers.map((h) => h.replace(/\s+/g, '').trim());
const target = columnName.replace(/\s+/g, '');
const index = normalized.findIndex((h) => h === target || h.startsWith(target));
expect(index, `未找到列:${columnName}`).toBeGreaterThanOrEqual(0);
return index;
}
/** 排序后:指示器激活或列数据顺序相对 before 发生变化。 */
async expectColumnSortApplied(columnName: string, beforeValues: string[]): Promise<void> {
const afterValues = await this.getColumnTexts(columnName);
const upActive = await this.locators
.columnSorterUp(columnName)
.evaluate((el) => el.classList.contains('active') || el.classList.contains('on'));
const downActive = await this.locators
.columnSorterDown(columnName)
.evaluate((el) => el.classList.contains('active') || el.classList.contains('on'));
const orderChanged = afterValues.join('|') !== beforeValues.join('|');
expect(upActive || downActive || orderChanged).toBeTruthy();
}
async openColumnSettings(): Promise<void> {
const panel = this.locators.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) {
return;
}
const triggerCandidates = [this.locators.columnSettingsTrigger];
let trigger = await findUsableColumnSettingsTrigger(triggerCandidates);
if (!trigger) {
await revealColumnSettingsTriggerByScroll({
scroll: this.tableScrollLayoutControls,
tableWrapper: this.locators.report.locator('.ant-table-wrapper').first(),
triggerCandidates,
horizontalScrollTargetColumn: centerReportSheetData.horizontalScrollTargetColumn,
horizontalScrollDragDelta: centerReportSheetData.horizontalScrollDragDelta,
scrollColumnIntoView: async (column) => {
await this.locators.tableHeader(column).scrollIntoViewIfNeeded().catch(() => undefined);
},
});
trigger = await findUsableColumnSettingsTrigger(triggerCandidates);
}
if (!trigger) {
await this.guardedSkip('列设置齿轮不可见');
}
await test.step('打开列设置', async () => {
await trigger!.click({ force: true });
});
await this.waitVisible(panel, 10_000);
}
async expectColumnSettingsPanel(): Promise<void> {
const panel = this.locators.columnSettingsPanel();
await expect(panel).toBeVisible();
await expect(panel.getByText('列展示')).toBeVisible();
await expect(panel.getByText('字段设置')).toBeVisible();
await expect(panel.getByText('重置')).toBeVisible();
}
async expectTableHeaderVisible(columnName: string, visible: boolean): Promise<void> {
const header = this.locators.columnHeaderCell(columnName);
if (visible) {
await expect(header).toBeVisible({ timeout: 10_000 });
} else {
await expect(header).toBeHidden({ timeout: 10_000 });
}
}
/** 在列设置面板中切换指定列勾选状态。 */
async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise<void> {
await this.setSettingsCheckbox(columnName, visible);
}
async setSettingsCheckbox(label: string, checked: boolean): Promise<void> {
const row = this.locators.columnSettingsCheckboxRow(label);
await expect(row).toBeVisible();
const checkbox = row.locator('input[type="checkbox"]').first();
const isChecked = await checkbox.isChecked();
if (isChecked !== checked) {
await row.click();
}
}
async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise<void> {
const checkbox = this.locators.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first();
if (checked) {
await expect(checkbox).toBeChecked();
} else {
await expect(checkbox).not.toBeChecked();
}
}
async setSerialColumnVisibleInSettings(visible: boolean): Promise<void> {
await this.setSettingsCheckbox(centerReportSheetData.columnSettingsSerialToggleLabel, visible);
}
async expectSerialColumnVisible(visible: boolean): Promise<void> {
await this.expectTableHeaderVisible(centerReportSheetData.columnSettingsSerialHeader, visible);
}
async setMultipleColumnsVisibleInSettings(columnNames: string[], visible: boolean): Promise<void> {
for (const name of columnNames) {
await this.setColumnVisibleInSettings(name, visible);
}
}
async expectMultipleTableHeadersVisible(columnNames: string[], visible: boolean): Promise<void> {
for (const name of columnNames) {
await this.expectTableHeaderVisible(name, visible);
}
}
async resetColumnSettingsInPanel(): Promise<void> {
await this.locators.columnSettingsPanel().getByText('重置', { exact: true }).click();
}
async closeColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls.closePanel();
}
/** 选取当前表头可见、且适合 toggle 的非核心列名。 */
async pickToggleColumnName(): Promise<string> {
for (const name of centerReportSheetData.columnSettingsToggleCandidates) {
if (await this.locators.columnHeaderCell(name).isVisible()) {
return name;
}
}
return centerReportSheetData.columnSettingsToggleCandidates[0];
}
/** 场景:取消勾选非核心列后表头隐藏,再勾选后表头恢复。 */
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) =>
this.expectSettingsCheckboxChecked(name, checked),
});
}
/** 选取多个当前可见的非核心列(用于多列调整场景)。 */
async pickMultipleToggleColumnNames(count = 2): Promise<string[]> {
const picked: string[] = [];
for (const name of centerReportSheetData.columnSettingsToggleCandidates) {
if (await this.locators.columnHeaderCell(name).isVisible()) {
picked.push(name);
}
if (picked.length >= count) {
break;
}
}
expect(picked.length).toBeGreaterThanOrEqual(Math.min(count, 1));
return picked;
}
async expectSummaryVisible(): Promise<void> {
for (const keyword of detailSheetSummaryMatch.metricLabels) {
await expect(this.locators.summaryText(keyword)).toBeVisible();
}
}
/** STAT-IOD-006:按首行条件查询 → 核验首行数据与底部汇总一致。 */
async expectSummaryMatchesSampleRow(
rowIndex = centerReportSheetData.sampleRowIndex,
): Promise<void> {
await this.summaryMatchControls.expectSummaryMatchesSampleRow(rowIndex, { firstRowOnly: true });
}
async expectPaginationTotal(): Promise<void> {
await expect(this.locators.paginationTotal).toHaveText(/共\s+\d+\s+条/);
}
async expectFirstRowContains(keyword: string | RegExp): Promise<void> {
if (typeof keyword === 'string' && /\d{4}[-/]\d{1,2}([-/]\d{1,2})?\s*~\s*\d{4}/.test(keyword)) {
return;
}
const firstRow = this.locators.dataRows.first();
await expect(firstRow).toBeVisible({ timeout: 15_000 });
await expect(firstRow).toContainText(keyword);
}
async exportReport(): Promise<void> {
await executeExportReport({
page: this.page,
clickExport: () => this.click(this.locators.exportBtn, '点击导出'),
hasVisibleErrorNotice: () => this.hasVisibleErrorNotice(),
assertHealthy: () => this.assertPageHealthy('导出后'),
});
}
async expectQueryWithoutError(): Promise<void> {
expect(await this.hasVisibleErrorNotice(), '查询后页面出现错误提示').toBeFalsy();
}
private async hasVisibleErrorNotice(): Promise<boolean> {
const errorLocators = [
this.page.locator('.ant-message-error'),
this.page.locator('.ant-notification-notice-error'),
this.locators.report.locator('.ant-message-error'),
this.locators.report.locator('.ant-notification-notice-error'),
];
for (const locator of errorLocators) {
if ((await locator.count()) > 0 && (await locator.first().isVisible())) {
return true;
}
}
return false;
private async fillOperationDate(raw?: string): Promise<void> {
const value = raw?.trim();
if (!value || value === '--' || value === '-') return;
const day = value.split(/\s+/)[0].replace(/\//g, '-');
const item = this.locators.drawerFormItem('制单日期');
if ((await item.count()) === 0) return;
const picker = item.locator('.ant-picker').first();
if ((await picker.count()) === 0) return;
const isRange = (await picker.locator('input').count()) >= 2;
const typed = await this.tryFillQuickDateByTyping(picker, '制单日期', day, day, isRange);
if (typed) return;
await this.click(picker, '打开制单日期选择器');
const panel = this.locators.report.locator('.ant-picker-dropdown:visible').last();
await panel.waitFor({ state: 'visible', timeout: 10_000 });
await this.pickDateInPanel(panel, day);
await this.pickDateInPanel(panel, day);
await this.page.keyboard.press('Escape');
}
}
export const CENTER_REPORT_SHEET_PATH = centerReportSheetData.reportPath;
......@@ -66,7 +66,9 @@ export type ExportReportOptions = {
prepareRowSelection?: () => Promise<void>;
clickExport: () => Promise<void>;
hasVisibleErrorNotice?: () => Promise<boolean>;
/** 页面/系统健康检查(报错文案、接口失败)。 */
/** 点击导出前:清空接口窗口并确认可交互(与 query 前置一致)。 */
prepareBusinessAction?: () => Promise<void>;
/** 导出完成后:业务接口 + 页面业务报错。 */
assertHealthy?: () => Promise<void>;
};
......@@ -82,6 +84,10 @@ export async function executeExportReport(options: ExportReportOptions): Promise
await options.page.keyboard.press('Escape').catch(() => undefined);
if (options.prepareBusinessAction) {
await options.prepareBusinessAction();
}
const waitMs = Math.max(timeouts.exportResponse, 60_000);
const exportResponsePromise = options.page
.waitForResponse(isExportPostResponse, { timeout: waitMs })
......
/** FineReport iframe 首次打开常超时或 503,加载失败后重新进页再等。 */
/** 仅服务不可用时重试;普通超时立即失败。 */
const RETRYABLE_LOAD =
/503|herpService|SERVICE_UNAVAILABLE|Timeout|iframe\[src|waiting for locator|not attached/i;
const RETRYABLE_LOAD = /503|herpService|SERVICE_UNAVAILABLE/i;
export async function retryReportLoad(
loadOnce: () => Promise<void>,
reload: () => Promise<void>,
wait: (ms: number) => Promise<void>,
maxAttempts = 3,
maxAttempts = 2,
): Promise<void> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
......
......@@ -4,8 +4,8 @@ import { env } from '../../../config/env.config';
import { applyCaseMeta } from '../../../core/case-meta';
import { attachFailureDiagnostics } from '../../../core/diagnostics';
import { attachRuntimeHealthGuard, getRuntimeHealthGuard } from '../../../core/runtime-health.util';
import { slotForParallelIndex, storageStatePathForSlot } from '../../../utils/auth-pool.util';
import { ensureAuthStorageFresh, isPageOnLogin } from '../../../utils/auth-session';
import { AUTH_STATE_PATH } from '../../../utils/path.util';
// 必须与用例侧同一 test 实例,describe 嵌套才生效
import { test } from '../../../fixtures';
......@@ -47,17 +47,21 @@ export function describeWithReuseOpen<T extends ReuseOpenReport>(
let page: Page;
let report: T;
let parallelIndex = 0;
const replaceSession = async (browser: Browser): Promise<void> => {
await page?.context().close().catch(() => undefined);
({ page, report } = await createReusedReportSession(browser, factory, options));
({ page, report } = await createReusedReportSession(browser, factory, options, parallelIndex));
};
test.beforeAll(async ({ browser }) => {
test.beforeAll(async ({ browser }, testInfo) => {
test.setTimeout(120_000);
({ page, report } = await createReusedReportSession(browser, factory, options));
parallelIndex = testInfo.parallelIndex;
({ page, report } = await createReusedReportSession(browser, factory, options, parallelIndex));
});
test.beforeEach(async ({ browser }, testInfo) => {
parallelIndex = testInfo.parallelIndex;
applyCaseMeta(testInfo);
if (!page || page.isClosed() || (await isPageOnLogin(page))) {
await replaceSession(browser);
......@@ -110,11 +114,13 @@ export async function createReusedReportSession<T extends ReuseOpenReport>(
browser: Browser,
factory: (page: Page) => T,
options: ReuseOpenOptions = {},
parallelIndex = 0,
): Promise<{ page: Page; report: T }> {
await ensureAuthStorageFresh(browser, { honorForce: false });
const slot = slotForParallelIndex(parallelIndex);
await ensureAuthStorageFresh(browser, { honorForce: false, slot });
const context = await browser.newContext({
baseURL: env.baseURL,
storageState: AUTH_STATE_PATH,
storageState: storageStatePathForSlot(slot),
});
const page = await context.newPage();
attachRuntimeHealthGuard(page);
......
......@@ -120,7 +120,7 @@ export class ReagEntryPage extends ReconCostReportSharedPage {
return this.listDataRows().count();
}
async waitForTableReady(): Promise<void> {
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.locators.tableBody.waitFor({ state: 'visible', timeout: timeouts.pageReady }).catch(() => undefined);
await this.waitForSearchLoadingDone();
await waitUntilNotLoading(async () => {
......@@ -129,7 +129,8 @@ export class ReagEntryPage extends ReconCostReportSharedPage {
if (await isTableEmptyVisible(this.locators.report)) return 'empty';
return 'idle';
}, timeouts.pageReady);
await this.assertPageHealthy('查询完成后');
const mode = options?.health ?? 'business';
await this.assertAfterTableWait(mode === 'ui' ? '列表就绪' : '查询完成后', mode);
}
async searchAndExtractSample(rowIndex = this.data.sampleRowIndex): Promise<SampleRow> {
......@@ -322,6 +323,7 @@ export class ReagEntryPage extends ReconCostReportSharedPage {
async exportReagEntryReport(): Promise<void> {
await executeExportReport({
page: this.page,
prepareBusinessAction: () => this.assertBeforeBusinessAction('导出'),
clickExport: () => this.click(this.locators.exportBtn, '点击导出'),
hasVisibleErrorNotice: () => this.hasVisibleErrorNotice(),
assertHealthy: () => this.assertPageHealthy('导出后'),
......
......@@ -492,6 +492,7 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
await expect(btn).toBeVisible({ timeout: 10_000 });
await executeExportReport({
page: this.page,
prepareBusinessAction: () => this.assertBeforeBusinessAction('导出条码明细'),
clickExport: () => this.click(btn, '点击导出条码明细'),
hasVisibleErrorNotice: () => this.hasVisibleErrorNotice(),
assertHealthy: () => this.assertPageHealthy('导出条码明细后'),
......
......@@ -47,7 +47,7 @@ export class SurgerySharedPage extends BasePage {
await this.waitSpinGone();
await this.waitSettle(400);
await this.waitContentReady();
await this.assertPageHealthy(`打开 ${this.meta.path}`);
await this.assertUiReady(`打开 ${this.meta.path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
......@@ -65,7 +65,7 @@ export class SurgerySharedPage extends BasePage {
await this.waitSpinGone();
await this.waitSettle(600);
await this.waitContentReady();
await this.assertPageHealthy(`菜单进入${this.meta.menu}`);
await this.assertUiReady(`菜单进入${this.meta.menu}`);
}
/** 等左树或主表挂载,避免菜单切入后尚未渲染。 */
......@@ -74,7 +74,7 @@ export class SurgerySharedPage extends BasePage {
.locator('.ant-tree, .ant-table-thead th, .ant-table-header th, thead th, .ant-empty')
.first();
await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitForTableReady();
await this.waitForTableReady({ health: 'ui' });
}
/** 断言已在目标页,且未误入同组其它页。 */
......@@ -93,6 +93,7 @@ export class SurgerySharedPage extends BasePage {
// ---------- 同组页签 ----------
async switchTab(name: string, expectPathPart?: string): Promise<void> {
await this.assertBeforeBusinessAction(`切换 Tab「${name}」`);
await this.click(this.locators.tab(name), `点击 Tab「${name}」`);
if (expectPathPart) {
await this.page.waitForURL((url) => url.pathname.includes(expectPathPart), { timeout: 20_000 }).catch(() => undefined);
......@@ -100,7 +101,6 @@ export class SurgerySharedPage extends BasePage {
await this.waitSpinGone();
await this.waitSettle(500);
await this.waitContentReady();
await this.assertPageHealthy(`切换 Tab「${name}」`);
}
async expectTabPath(pathPart: string): Promise<void> {
......@@ -224,11 +224,13 @@ export class SurgerySharedPage extends BasePage {
}
async query(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.click(this.locators.button('查 询').first(), '查 询');
await this.waitForTableReady();
}
async reset(): Promise<void> {
await this.assertBeforeBusinessAction('重置');
await this.click(this.locators.button('重 置').first(), '重 置');
await this.waitForTableReady();
}
......@@ -257,10 +259,10 @@ export class SurgerySharedPage extends BasePage {
return match ? Number(match[1]) : 0;
}
async waitForTableReady(): Promise<void> {
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertPageHealthy('列表加载');
await this.assertAfterTableWait('列表加载', options?.health ?? 'business');
}
async headerCells(): Promise<Locator> {
......
......@@ -178,7 +178,10 @@ export class VendorCertDiffPage extends VendorSharedPage {
}
await input.fill(' ');
const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first();
if (await queryBtn.isVisible().catch(() => false)) await queryBtn.click();
if (await queryBtn.isVisible().catch(() => false)) {
await this.assertBeforeBusinessAction('查询');
await queryBtn.click();
}
await this.waitSettle(800);
await this.assertPageHealthy('空格物料名称搜索');
await this.expectListOrEmptyInFrame();
......@@ -195,7 +198,7 @@ export class VendorCertDiffPage extends VendorSharedPage {
}
const text = ((await rows.first().innerText().catch(() => '')) ?? '').replace(/\s/g, '');
expect(text.length, '一致行应有内容').toBeGreaterThan(0);
await this.assertPageHealthy('一致行抽样');
await this.assertUiReady('一致行抽样');
}
/** CSV VENDOR-CDF-011:不一致行至少一侧差异。 */
......@@ -209,6 +212,6 @@ export class VendorCertDiffPage extends VendorSharedPage {
}
const text = ((await rows.first().innerText().catch(() => '')) ?? '').replace(/\s/g, '');
expect(text.length, '不一致行应有内容').toBeGreaterThan(0);
await this.assertPageHealthy('不一致行抽样');
await this.assertUiReady('不一致行抽样');
}
}
......@@ -115,7 +115,7 @@ export class VendorEvalSchemePage extends VendorSharedPage {
const formErr = await this.locators.formError.first().isVisible().catch(() => false);
expect(blocked || formErr || (await layer.first().isVisible()), '超长应被约束或仍停留编辑层').toBeTruthy();
}
await this.assertPageHealthy('方案名称超长');
await this.assertUiReady('方案名称超长');
await this.leaveWithoutCommit();
}
......
......@@ -104,7 +104,7 @@ export class VendorHolidayPage extends VendorSharedPage {
await this.click(submit, '非法日期提交');
await this.expectBlockedAndStay();
} else {
await this.assertPageHealthy('结束早于开始(提交禁用或未点)');
await this.assertUiReady('结束早于开始(提交禁用或未点)');
}
await this.leaveWithoutCommit();
}
......@@ -126,7 +126,7 @@ export class VendorHolidayPage extends VendorSharedPage {
await inputs.nth(0).fill(day);
await inputs.nth(1).fill(day);
await this.page.keyboard.press('Enter').catch(() => undefined);
await this.assertPageHealthy('同日区间');
await this.assertUiReady('同日区间');
await this.leaveWithoutCommit();
}
......@@ -143,6 +143,6 @@ export class VendorHolidayPage extends VendorSharedPage {
}
const name = ((await supplierCell.innerText()) ?? '').trim();
expect(name.length, '进院供应商名称非空').toBeGreaterThan(0);
await this.assertPageHealthy('进院供应商主数据抽样');
await this.assertUiReady('进院供应商主数据抽样');
}
}
......@@ -46,6 +46,7 @@ export class VendorQueryPage extends VendorSharedPage {
}
async searchByName(name: string): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.fill(this.nameSearch(), name, '填写厂商名称');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
......@@ -75,7 +76,6 @@ export class VendorQueryPage extends VendorSharedPage {
async searchSpacesOnly(): Promise<void> {
await this.searchByName(' ');
await this.assertPageHealthy('空格关键字搜索');
}
/** 打开资质管理并用同源关键字检索;返回列表快照供用例断言。 */
......
......@@ -37,7 +37,7 @@ export class VendorSharedPage extends BasePage {
await this.waitSpinGone();
await this.waitSettle(400);
await this.waitContentReady();
await this.assertPageHealthy(`打开 ${this.meta.path}`);
await this.assertUiReady(`打开 ${this.meta.path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
......@@ -56,7 +56,7 @@ export class VendorSharedPage extends BasePage {
await this.waitSpinGone();
await this.waitSettle(600);
await this.waitContentReady();
await this.assertPageHealthy(`菜单进入${this.meta.menu}`);
await this.assertUiReady(`菜单进入${this.meta.menu}`);
}
/** 等主表或关键控件出现,避免菜单切入后表头尚未挂载。 */
......@@ -65,7 +65,7 @@ export class VendorSharedPage extends BasePage {
.locator('.ant-table-thead th, .ant-table-header th, thead th, .ant-empty, .ant-tree, [placeholder="厂商名称"]')
.first();
await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitForTableReady();
await this.waitForTableReady({ health: 'ui' });
}
async expectOnPage(): Promise<void> {
......@@ -90,11 +90,13 @@ export class VendorSharedPage extends BasePage {
}
async query(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询');
await this.waitForTableReady();
}
async reset(): Promise<void> {
await this.assertBeforeBusinessAction('重置');
await this.clickButton('重置');
await this.waitForTableReady();
}
......@@ -137,6 +139,7 @@ export class VendorSharedPage extends BasePage {
async clickTab(name: string): Promise<void> {
const tab = this.tab(name);
await expect(tab, `Tab「${name}」`).toBeVisible({ timeout: 15_000 });
await this.assertBeforeBusinessAction(`切换${name}`);
await this.click(tab, `切换${name}`);
await this.waitForTableReady();
}
......@@ -216,10 +219,10 @@ export class VendorSharedPage extends BasePage {
return match ? Number(match[1]) : 0;
}
async waitForTableReady(): Promise<void> {
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertPageHealthy('列表加载');
await this.assertAfterTableWait('列表加载', options?.health ?? 'business');
}
async cellText(row: Locator, header: string): Promise<string> {
......@@ -322,6 +325,7 @@ export class VendorSharedPage extends BasePage {
}
async openDetailLayer(row: Locator, action = '详情'): Promise<void> {
await this.assertBeforeBusinessAction(action);
await this.click(this.rowAction(row, action), action);
const layer = this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open'));
await expect(layer.first(), `${action}应打开详情层`).toBeVisible({ timeout: 15_000 });
......@@ -367,7 +371,8 @@ export class VendorSharedPage extends BasePage {
await this.click(this.locators.nextPage, '下一页');
}
await this.waitForTableReady();
expect(await this.getDataRowCount(), '第 2 页应有数据或可空').toBeGreaterThanOrEqual(0);
await expect(this.page.locator('.ant-pagination-item-active').first(), '当前页应为第 2 页').toHaveText('2');
expect(await this.getDataRowCount(), '第 2 页应有数据').toBeGreaterThan(0);
}
async closeOverlay(): Promise<void> {
......
......@@ -38,7 +38,7 @@ export class WhFlowSharedPage extends BasePage {
await this.waitSpinGone();
await this.waitSettle(400);
await this.waitContentReady();
await this.assertPageHealthy(`打开 ${this.meta.path}`);
await this.assertUiReady(`打开 ${this.meta.path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
......@@ -61,7 +61,7 @@ export class WhFlowSharedPage extends BasePage {
await this.waitSpinGone();
await this.waitSettle(600);
await this.waitContentReady();
await this.assertPageHealthy(`菜单进入${this.meta.menu}`);
await this.assertUiReady(`菜单进入${this.meta.menu}`);
}
async waitContentReady(): Promise<void> {
......@@ -71,7 +71,7 @@ export class WhFlowSharedPage extends BasePage {
)
.first();
await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitForTableReady();
await this.waitForTableReady({ health: 'ui' });
}
async expectOnPage(): Promise<void> {
......@@ -91,11 +91,13 @@ export class WhFlowSharedPage extends BasePage {
}
async query(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询');
await this.waitForTableReady();
}
async reset(): Promise<void> {
await this.assertBeforeBusinessAction('重置');
await this.clickButton('重置');
await this.waitForTableReady();
}
......@@ -197,10 +199,10 @@ export class WhFlowSharedPage extends BasePage {
return match ? Number(match[1]) : 0;
}
async waitForTableReady(): Promise<void> {
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertPageHealthy('列表加载');
await this.assertAfterTableWait('列表加载', options?.health ?? 'business');
}
async cellText(row: Locator, header: string): Promise<string> {
......
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