Commit 0559c658 authored by liguangyu06's avatar liguangyu06
Browse files

修复排序、导出、iframe 和汇总勾稽,便于只重跑上次失败用例。

parent 33b0dd82
...@@ -119,6 +119,7 @@ pipeline { ...@@ -119,6 +119,7 @@ pipeline {
string(name: 'SHARDS', defaultValue: '1', description: '本机分片进程数;夜间建议 2,总浏览器约 SHARDS×WORKERS') string(name: 'SHARDS', defaultValue: '1', description: '本机分片进程数;夜间建议 2,总浏览器约 SHARDS×WORKERS')
string(name: 'RETRIES', defaultValue: '0', description: '整条用例重试次数,默认 0;瞬时加载由操作层重试,勿全局打开') string(name: 'RETRIES', defaultValue: '0', description: '整条用例重试次数,默认 0;瞬时加载由操作层重试,勿全局打开')
booleanParam(name: 'RUN_FULL', defaultValue: false, description: '包含 @full 全列排序(耗时长)') booleanParam(name: 'RUN_FULL', defaultValue: false, description: '包含 @full 全列排序(耗时长)')
string(name: 'GREP', defaultValue: '', description: '只跑标题匹配这些用例(Playwright grep)。空则按 SUITE 全跑')
string( string(
name: 'REPORT_EMAIL_TO', name: 'REPORT_EMAIL_TO',
defaultValue: '', defaultValue: '',
...@@ -134,6 +135,7 @@ pipeline { ...@@ -134,6 +135,7 @@ pipeline {
SHARDS = "${params.SHARDS}" SHARDS = "${params.SHARDS}"
PW_RETRIES = "${params.RETRIES}" PW_RETRIES = "${params.RETRIES}"
RUN_FULL = "${params.RUN_FULL ? '1' : '0'}" RUN_FULL = "${params.RUN_FULL ? '1' : '0'}"
PW_GREP = "${params.GREP ?: ''}"
PLAYWRIGHT_BROWSERS_PATH = "${WORKSPACE}/.playwright-browsers" PLAYWRIGHT_BROWSERS_PATH = "${WORKSPACE}/.playwright-browsers"
SPD_CREDENTIALS_ID = "${env.SPD_CREDENTIALS_ID ?: 'spd-ui-test-account'}" SPD_CREDENTIALS_ID = "${env.SPD_CREDENTIALS_ID ?: 'spd-ui-test-account'}"
JAVA_HOME = "${env.JAVA_HOME ?: 'E:/Program Files/Java/jdk-11'}" JAVA_HOME = "${env.JAVA_HOME ?: 'E:/Program Files/Java/jdk-11'}"
......
...@@ -101,7 +101,16 @@ export class BasePage { ...@@ -101,7 +101,16 @@ export class BasePage {
async (target) => { async (target) => {
await target.waitFor({ state: 'visible', timeout }); await target.waitFor({ state: 'visible', timeout });
await target.scrollIntoViewIfNeeded().catch(() => undefined); await target.scrollIntoViewIfNeeded().catch(() => undefined);
await target.click({ timeout }); try {
await target.click({ timeout: Math.min(timeout, 4_000) });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (!/intercepts pointer/.test(msg)) throw error;
// iframe 内 ant-spin 挡住按钮时,Playwright 命中检测点不到;DOM click 仍能触发查询/导出
await target.evaluate((node) => {
if (node instanceof HTMLElement) node.click();
});
}
}, },
{ {
label: stepName ?? 'click', label: stepName ?? 'click',
......
...@@ -2,13 +2,17 @@ import type { FrameLocator, Locator, Page } from '@playwright/test'; ...@@ -2,13 +2,17 @@ import type { FrameLocator, Locator, Page } from '@playwright/test';
import { columnHeaderMatchTokens } from '../../core/column-header-match.util'; import { columnHeaderMatchTokens } from '../../core/column-header-match.util';
/** FineReport 常把路径斜杠编码成 %2F,两种 src 都要能挂上 iframe。 */ /** FineReport 常把路径斜杠编码成 %2F / %2f,两种 src 都要能挂上 iframe。 */
export function iframeCssSelector(iframeSrc: string): string { export function iframeCssSelector(iframeSrc: string): string {
const encoded = iframeSrc.replace(/\//g, '%2F'); const variants = new Set<string>([iframeSrc]);
if (encoded === iframeSrc) { if (iframeSrc.includes('/')) {
return `iframe[src*="${iframeSrc}"]`; variants.add(iframeSrc.replace(/\//g, '%2F'));
} variants.add(iframeSrc.replace(/\//g, '%2f'));
return `iframe[src*="${iframeSrc}"], iframe[src*="${encoded}"]`; variants.add(iframeSrc.replace(/\//g, '%252F'));
const leaf = iframeSrc.split('/').filter(Boolean).pop() ?? '';
if (leaf.length >= 6) variants.add(leaf);
}
return [...variants].map((src) => `iframe[src*="${src}"]`).join(', ');
} }
export type CenterReportLocatorOptions = { export type CenterReportLocatorOptions = {
...@@ -170,12 +174,13 @@ export class CenterReportSharedLocators { ...@@ -170,12 +174,13 @@ export class CenterReportSharedLocators {
this.goodsCode = options.goodsCodeSelector this.goodsCode = options.goodsCodeSelector
? this.report.locator(options.goodsCodeSelector).first() ? this.report.locator(options.goodsCodeSelector).first()
: null; : null;
const searchLabel = options.searchBtnLabel ?? '查 询';
this.searchBtn = this.report this.searchBtn = this.report
.getByRole('button', { .getByRole('button', {
name: options.searchBtnLabel ?? '查 询', name: searchLabel === '搜 索' ? /搜\s*索/ : /查\s*询/,
}) })
.first(); .first();
this.resetBtn = this.report.getByRole('button', { name: '重 置' }).first(); this.resetBtn = this.report.getByRole('button', { name: /重\s*置/ }).first();
this.advancedFilter = this.report this.advancedFilter = this.report
.locator('.advance-filter') .locator('.advance-filter')
.or(this.report.getByText('高级筛选', { exact: true })) .or(this.report.getByText('高级筛选', { exact: true }))
......
...@@ -12,7 +12,7 @@ import { columnHeaderMatchTokens } from '../../core/column-header-match.util'; ...@@ -12,7 +12,7 @@ import { columnHeaderMatchTokens } from '../../core/column-header-match.util';
import { CenterReportEntryLocators } from '../../locators/spd/center-report-entry.locator'; import { CenterReportEntryLocators } from '../../locators/spd/center-report-entry.locator';
import { iframeCssSelector, tableHasColumnSorter } from '../../locators/spd/center-report-shared.locator'; import { iframeCssSelector, tableHasColumnSorter } from '../../locators/spd/center-report-shared.locator';
import { DrawerFormControls } from './helpers/drawer-form-controls.helper'; import { DrawerFormControls } from './helpers/drawer-form-controls.helper';
import { ColumnSortControls, clickColumnSortControl } from './helpers/column-sort.helper'; import { clickColumnSortControl, expectColumnSortByDom } from './helpers/column-sort.helper';
import { import {
ColumnSettingsControls, ColumnSettingsControls,
executeColumnSettingsToggleRestore, executeColumnSettingsToggleRestore,
...@@ -46,7 +46,6 @@ function usableSampleValue(value?: string): string | undefined { ...@@ -46,7 +46,6 @@ function usableSampleValue(value?: string): string | undefined {
export class CenterReportEntryPage extends BasePage { export class CenterReportEntryPage extends BasePage {
private readonly locators: CenterReportEntryLocators; private readonly locators: CenterReportEntryLocators;
private readonly drawerControls: DrawerFormControls; private readonly drawerControls: DrawerFormControls;
private readonly columnSortControls: ColumnSortControls;
private readonly columnSettingsControls: ColumnSettingsControls; private readonly columnSettingsControls: ColumnSettingsControls;
private readonly tableScrollLayoutControls: TableScrollLayoutControls; private readonly tableScrollLayoutControls: TableScrollLayoutControls;
private readonly downloadCenter: DownloadCenterComponent; private readonly downloadCenter: DownloadCenterComponent;
...@@ -65,17 +64,6 @@ export class CenterReportEntryPage extends BasePage { ...@@ -65,17 +64,6 @@ export class CenterReportEntryPage extends BasePage {
drawerFormItem: (label) => this.locators.drawerFormItem(label), drawerFormItem: (label) => this.locators.drawerFormItem(label),
click: (locator, stepName) => this.click(locator, stepName), click: (locator, stepName) => this.click(locator, stepName),
}); });
this.columnSortControls = new ColumnSortControls({
columnHeaderCell: (columnName) => this.locators.columnHeaderCell(columnName),
columnSorter: (columnName) => this.locators.columnSorter(columnName),
columnSorterUp: (columnName) => this.locators.columnSorterUp(columnName),
columnSorterDown: (columnName) => this.locators.columnSorterDown(columnName),
clickColumnSort: (columnName, order) => this.clickColumnSort(columnName, order),
getFirstRowText: () => this.getFirstRowText(),
getColumnValues: (columnName, maxRows) => this.getColumnValues(columnName, maxRows),
getRowSignatures: (maxRows) => this.getRowSignatures(maxRows),
guardedSkip: (reason) => this.guardedSkip(reason),
});
this.columnSettingsControls = new ColumnSettingsControls({ this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.locators.columnSettingsPanel(), columnSettingsPanel: () => this.locators.columnSettingsPanel(),
columnSettingsTrigger: this.locators.columnSettingsTrigger, columnSettingsTrigger: this.locators.columnSettingsTrigger,
...@@ -900,25 +888,13 @@ export class CenterReportEntryPage extends BasePage { ...@@ -900,25 +888,13 @@ export class CenterReportEntryPage extends BasePage {
} }
async isColumnSortable(columnName: string): Promise<boolean> { async isColumnSortable(columnName: string): Promise<boolean> {
await this.scrollColumnIntoView(columnName).catch(() => undefined);
const wrapper = this.locators.report.locator('.ant-table-wrapper').first(); const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
if (await tableHasColumnSorter(wrapper, columnName).catch(() => false)) { await wrapper
return true; .locator('.ant-table-tbody, .ant-table-body')
} .first()
const sorter = this.locators.columnSorter(columnName); .waitFor({ state: 'attached', timeout: 20_000 })
if ((await sorter.count().catch(() => 0)) > 0) { .catch(() => undefined);
return true; const hasSorter = await tableHasColumnSorter(wrapper, columnName).catch(() => false);
}
const header = this.locators.columnHeaderCell(columnName);
const hasSorter = await header
.evaluate((el) => {
const th = ((el as HTMLElement).closest('th') ?? el) as HTMLElement;
return Boolean(
th.querySelector('.ant-table-column-sorter') ||
th.classList.contains('ant-table-column-has-sorters'),
);
})
.catch(() => false);
if (!hasSorter) { if (!hasSorter) {
await this.assertPageHealthy(`列「${columnName}」不可排序`); await this.assertPageHealthy(`列「${columnName}」不可排序`);
} }
...@@ -953,13 +929,17 @@ export class CenterReportEntryPage extends BasePage { ...@@ -953,13 +929,17 @@ export class CenterReportEntryPage extends BasePage {
} }
async expectColumnSortToggle(columnName: string): Promise<void> { async expectColumnSortToggle(columnName: string): Promise<void> {
await this.scrollColumnIntoView(columnName); const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
try { await expectColumnSortByDom({
await this.columnSortControls.expectColumnSortToggle(columnName); wrapper,
} catch { columnName,
await this.recoverTableData(); waitAfterClick: async () => {
await this.columnSortControls.expectColumnSortToggle(columnName); await this.locators.report
} .locator('.ant-spin-spinning')
.waitFor({ state: 'hidden', timeout: 12_000 })
.catch(() => undefined);
},
});
} }
async expectColumnSortIndicatorActive(columnName: string): Promise<void> { async expectColumnSortIndicatorActive(columnName: string): Promise<void> {
......
...@@ -20,7 +20,7 @@ import { ...@@ -20,7 +20,7 @@ import {
} from '../../locators/spd/center-report-shared.locator'; } from '../../locators/spd/center-report-shared.locator';
import { retryReportLoad } from './helpers/report-load-retry.helper'; import { retryReportLoad } from './helpers/report-load-retry.helper';
import { DrawerFormControls } from './helpers/drawer-form-controls.helper'; import { DrawerFormControls } from './helpers/drawer-form-controls.helper';
import { ColumnSortControls, isColumnValuesSorted, clickColumnSortControl, readColumnSortOrder, type ColumnSortOrder } from './helpers/column-sort.helper'; import { clickColumnSortControl, expectColumnSortByDom } from './helpers/column-sort.helper';
import { import {
ColumnSettingsControls, ColumnSettingsControls,
executeColumnSettingsToggleRestore, executeColumnSettingsToggleRestore,
...@@ -61,6 +61,60 @@ export type SearchWithQuickAndDrawerComboOptions = { ...@@ -61,6 +61,60 @@ export type SearchWithQuickAndDrawerComboOptions = {
skipWhenNoInitialData?: boolean; skipWhenNoInitialData?: boolean;
}; };
/**
* 翻页后主表行 innerText 可能为空:固定列克隆、单元格 title / input 才有字。
* 按 data-row-key 把同一行各表的单元格拼起来。
*/
async function readFirstDataRowText(wrapper: Locator): Promise<string> {
return wrapper
.evaluate((root) => {
const bodies = Array.from(
root.querySelectorAll(
'.ant-table-body tbody.ant-table-tbody, .ant-table-content > table > tbody.ant-table-tbody, .ant-table-scroll .ant-table-body tbody',
),
);
const rows: HTMLTableRowElement[] = [];
for (const body of bodies) {
body.querySelectorAll(':scope > tr.ant-table-row').forEach((tr) => {
if (
tr.classList.contains('ant-table-measure-row') ||
tr.classList.contains('ant-table-expanded-row') ||
tr.classList.contains('ant-table-placeholder')
) {
return;
}
rows.push(tr as HTMLTableRowElement);
});
}
const cellText = (tr: Element): string[] =>
Array.from(tr.querySelectorAll('td'))
.map((td) => {
const input = td.querySelector('input, textarea') as HTMLInputElement | null;
const raw = (td.textContent ?? '') || td.getAttribute('title') || input?.value || '';
return raw.replace(/\s+/g, ' ').trim();
})
.filter((text) => text && text !== '--' && text !== '—' && text !== '选择');
const seen = new Set<string>();
for (const row of rows) {
const key = row.getAttribute('data-row-key') ?? '';
if (key && seen.has(key)) continue;
if (key) seen.add(key);
const parts = cellText(row);
if (key) {
root.querySelectorAll('tr.ant-table-row').forEach((clone) => {
if (clone !== row && clone.getAttribute('data-row-key') === key) {
parts.push(...cellText(clone));
}
});
}
const joined = parts.join(' ').replace(/\s+/g, ' ').trim();
if (joined) return joined;
}
return '';
})
.catch(() => '');
}
/** /**
* 中心库统计报表通用 Page(按 data 配置差异化行为)。 * 中心库统计报表通用 Page(按 data 配置差异化行为)。
* 列设置 / 排序 / 导出 / 抽屉 / 分页等已拆到 `pages/spd/helpers/`; * 列设置 / 排序 / 导出 / 抽屉 / 分页等已拆到 `pages/spd/helpers/`;
...@@ -70,7 +124,6 @@ export class CenterReportSharedPage extends BasePage { ...@@ -70,7 +124,6 @@ export class CenterReportSharedPage extends BasePage {
protected readonly locators: CenterReportSharedLocators; protected readonly locators: CenterReportSharedLocators;
protected readonly data: CenterReportPageData; protected readonly data: CenterReportPageData;
protected readonly drawerControls: DrawerFormControls; protected readonly drawerControls: DrawerFormControls;
protected readonly columnSortControls: ColumnSortControls;
protected readonly columnSettingsControls: ColumnSettingsControls; protected readonly columnSettingsControls: ColumnSettingsControls;
protected readonly tableScrollLayoutControls: TableScrollLayoutControls; protected readonly tableScrollLayoutControls: TableScrollLayoutControls;
protected readonly mainTableScrollLayoutControls: TableScrollLayoutControls; protected readonly mainTableScrollLayoutControls: TableScrollLayoutControls;
...@@ -184,17 +237,6 @@ export class CenterReportSharedPage extends BasePage { ...@@ -184,17 +237,6 @@ export class CenterReportSharedPage extends BasePage {
drawerFormItem: (label) => this.locators.drawerFormItem(label), drawerFormItem: (label) => this.locators.drawerFormItem(label),
click: (locator, stepName) => this.click(locator, stepName), click: (locator, stepName) => this.click(locator, stepName),
}); });
this.columnSortControls = new ColumnSortControls({
columnHeaderCell: (columnName) => this.locators.columnHeaderCell(columnName),
columnSorter: (columnName) => this.locators.columnSorter(columnName),
columnSorterUp: (columnName) => this.locators.columnSorterUp(columnName),
columnSorterDown: (columnName) => this.locators.columnSorterDown(columnName),
clickColumnSort: (columnName, order) => this.clickColumnSort(columnName, order),
getFirstRowText: () => this.getFirstRowText(),
getColumnValues: (columnName, maxRows) => this.getColumnValues(columnName, maxRows),
getRowSignatures: (maxRows) => this.getRowSignatures(maxRows),
guardedSkip: (reason) => this.guardedSkip(reason),
});
this.columnSettingsControls = new ColumnSettingsControls({ this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.locators.columnSettingsPanel(), columnSettingsPanel: () => this.locators.columnSettingsPanel(),
columnSettingsTrigger: this.locators.columnSettingsTrigger, columnSettingsTrigger: this.locators.columnSettingsTrigger,
...@@ -297,11 +339,11 @@ export class CenterReportSharedPage extends BasePage { ...@@ -297,11 +339,11 @@ export class CenterReportSharedPage extends BasePage {
async expectLoaded(): Promise<void> { async expectLoaded(): Promise<void> {
const loadOnce = async () => { const loadOnce = async () => {
// 先等 iframe 挂上,避免在壳页上空等 searchBtn // 先等 iframe 挂上,避免在壳页上空等 searchBtn
await this.page const iframe = this.page.locator(iframeCssSelector(this.data.iframeSrc)).first();
.locator(iframeCssSelector(this.data.iframeSrc)) await iframe.waitFor({ state: 'attached', timeout: Math.max(timeouts.pageReady, 25_000) });
.first() const frame = await iframe.elementHandle().then((handle) => handle?.contentFrame() ?? null);
.waitFor({ state: 'attached', timeout: Math.max(timeouts.pageReady, 40_000) }); await frame?.waitForLoadState('domcontentloaded').catch(() => undefined);
await this.waitVisible(this.locators.searchBtn, timeouts.pageReady); await this.waitVisible(this.locators.searchBtn, Math.max(timeouts.pageReady, 25_000));
await this.waitVisible(this.locators.resetBtn, timeouts.expect * 2); await this.waitVisible(this.locators.resetBtn, timeouts.expect * 2);
if (this.data.hasAdvancedFilter) { if (this.data.hasAdvancedFilter) {
await this.waitVisible(this.locators.advancedFilter, timeouts.expect).catch(() => undefined); await this.waitVisible(this.locators.advancedFilter, timeouts.expect).catch(() => undefined);
...@@ -320,7 +362,10 @@ export class CenterReportSharedPage extends BasePage { ...@@ -320,7 +362,10 @@ export class CenterReportSharedPage extends BasePage {
await retryReportLoad( await retryReportLoad(
loadOnce, loadOnce,
() => this.goto(this.data.reportPath), async () => {
await this.page.reload({ waitUntil: 'domcontentloaded' }).catch(() => undefined);
await this.goto(this.data.reportPath);
},
(ms) => this.page.waitForTimeout(ms), (ms) => this.page.waitForTimeout(ms),
); );
} }
...@@ -328,6 +373,9 @@ export class CenterReportSharedPage extends BasePage { ...@@ -328,6 +373,9 @@ export class CenterReportSharedPage extends BasePage {
async search(): Promise<void> { async search(): Promise<void> {
await this.waitForSearchLoadingDone(); await this.waitForSearchLoadingDone();
await waitAntSpinGone(this.locators.report); await waitAntSpinGone(this.locators.report);
if (!(await this.locators.searchBtn.isVisible().catch(() => false))) {
await this.ensureQuickFilterExpanded().catch(() => undefined);
}
await this.click(this.locators.searchBtn, '点击查询'); await this.click(this.locators.searchBtn, '点击查询');
if (this.data.isChart) { if (this.data.isChart) {
await this.waitForSearchLoadingDone(); await this.waitForSearchLoadingDone();
...@@ -358,17 +406,19 @@ export class CenterReportSharedPage extends BasePage { ...@@ -358,17 +406,19 @@ export class CenterReportSharedPage extends BasePage {
return wrapper.locator('tbody.ant-table-tbody > tr.ant-table-row').count().catch(() => 0); return wrapper.locator('tbody.ant-table-tbody > tr.ant-table-row').count().catch(() => 0);
}; };
const started = Date.now();
await waitUntilNotLoading(async () => { await waitUntilNotLoading(async () => {
if (this.page.isClosed()) return 'ready'; if (this.page.isClosed()) return 'ready';
if (await isSearchButtonLoading(this.locators.searchBtn)) return 'loading'; if (await isSearchButtonLoading(this.locators.searchBtn)) return 'loading';
if (await isAntSpinVisible(this.locators.report.locator('.ant-table-wrapper'))) return 'loading';
if ((await countRows()) > 0) return 'ready'; if ((await countRows()) > 0) return 'ready';
if (await isTableEmptyVisible(this.locators.report)) return 'empty'; if (await isTableEmptyVisible(this.locators.report)) return 'empty';
// spin 已消失且无行:视为就绪(空表/尚未渲染完由上层断言处理),禁止因分页总数永久 loading if (await isAntSpinVisible(this.locators.report.locator('.ant-table-wrapper'))) return 'loading';
const anyRow = await this.locators.tableBody.locator('tr').count().catch(() => 0); const anyRow = await this.locators.tableBody.locator('tr').count().catch(() => 0);
if (anyRow > 0) return 'ready'; if (anyRow > 0) return 'ready';
// 行或空态晚一拍渲染时不要立刻判空;超过 8 秒仍无 spin 再放行,避免无占位符的空表卡满超时
if (Date.now() - started < 8_000) return 'loading';
return 'idle'; return 'idle';
}); }, Math.max(timeouts.tableReady, 35_000));
if (!this.page.isClosed()) { if (!this.page.isClosed()) {
await this.assertPageHealthy('查询完成后'); await this.assertPageHealthy('查询完成后');
} }
...@@ -1269,13 +1319,14 @@ export class CenterReportSharedPage extends BasePage { ...@@ -1269,13 +1319,14 @@ export class CenterReportSharedPage extends BasePage {
await expect await expect
.poll(async () => this.paginationDataRows().count(), { timeout: 20_000 }) .poll(async () => this.paginationDataRows().count(), { timeout: 20_000 })
.toBeGreaterThan(0); .toBeGreaterThan(0);
const wrapper = this.locators.report
.locator('.ant-table-wrapper')
.nth(this.locators.paginationTableWrapperIndex);
let text = ''; let text = '';
await expect await expect
.poll( .poll(
async () => { async () => {
const row = await this.firstVisibleDataRow(); text = await readFirstDataRowText(wrapper);
await row.scrollIntoViewIfNeeded().catch(() => undefined);
text = ((await row.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ').trim();
return text; return text;
}, },
{ timeout: 20_000 }, { timeout: 20_000 },
...@@ -1632,6 +1683,10 @@ export class CenterReportSharedPage extends BasePage { ...@@ -1632,6 +1683,10 @@ export class CenterReportSharedPage extends BasePage {
/** 勾选明细行后工具栏打印才可点:优先展开子表,否则勾选主表行。 */ /** 勾选明细行后工具栏打印才可点:优先展开子表,否则勾选主表行。 */
async checkDetailRowForPrint(rowIndex = 0): Promise<void> { async checkDetailRowForPrint(rowIndex = 0): Promise<void> {
await this.waitForTableReady().catch(() => undefined);
if ((await this.locators.dataRows.count()) === 0) {
await this.skipWhenDataInsufficient('主表无数据,跳过打印');
}
const expand = this.locators.firstExpandIcon; const expand = this.locators.firstExpandIcon;
if (await expand.isVisible().catch(() => false)) { if (await expand.isVisible().catch(() => false)) {
if (!(await this.locators.expandedRow.isVisible().catch(() => false))) { if (!(await this.locators.expandedRow.isVisible().catch(() => false))) {
...@@ -2454,103 +2509,23 @@ export class CenterReportSharedPage extends BasePage { ...@@ -2454,103 +2509,23 @@ export class CenterReportSharedPage extends BasePage {
} }
} }
private async readSortOrderAt(columnIndex: number): Promise<ColumnSortOrder> { /**
return readColumnSortOrder(this.locators.sortColumnHeaderAt(columnIndex)); * 单列排序:在表格 DOM 内点 sorter,指示器或当前页行序任一成立即通过。
} * 不走 sticky thead 定位器,避免宽表克隆把用例拖到 120s。
*/
private async clickSortUntilOrder(
columnIndex: number,
columnName: string,
target: 'ascend' | 'descend',
): Promise<void> {
const headerAtIndex = this.locators.sortColumnHeaderAt(columnIndex);
await headerAtIndex.scrollIntoViewIfNeeded({ timeout: 3_000 }).catch(() => undefined);
await headerAtIndex.waitFor({ state: 'attached', timeout: 15_000 });
for (let attempt = 0; attempt < 6; attempt += 1) {
if ((await this.readSortOrderAt(columnIndex)) === target) {
return;
}
await this.clickColumnSort(columnName, target);
}
expect(await this.readSortOrderAt(columnIndex), `列「${columnName}」未能切换到${target === 'ascend' ? '升序' : '降序'}`).toBe(
target,
);
}
private async assertSortListChanged(
columnName: string,
columnIndex: number,
baselineSignatures: string[],
baselineValues: string[],
order: 'ascend' | 'descend',
): Promise<void> {
const signatures = await this.getRowSignatures(8, { lightweight: true });
const values = await this.getColumnValues(columnName, 8, columnIndex);
const rowsChanged =
signatures.length >= 2 && JSON.stringify(signatures) !== JSON.stringify(baselineSignatures);
const valuesSorted = values.length >= 2 && isColumnValuesSorted(values, order);
const valuesChanged =
values.length >= 2 && JSON.stringify(values) !== JSON.stringify(baselineValues);
const uniformValues = this.isUniformColumnValues(values) || this.isUniformColumnValues(baselineValues);
// 调用方已校验排序指示器;当前页可能已有序/服务端稳定返回时,不因顺序不变硬失败
if (rowsChanged || valuesSorted || valuesChanged || uniformValues) {
return;
}
if (values.length < 2 || signatures.length < 2) {
return;
}
}
private isUniformColumnValues(values: string[]): boolean {
const unique = new Set(
values
.map((value) => value.replace(/\s+/g, '').trim())
.filter((value) => value && value !== '--' && value !== '-'),
);
return values.length >= 1 && unique.size <= 1;
}
/** 单列排序:横向滚动 → 点击升序 → 校验指示器与列表变化 → 点击降序 → 校验。 */
async expectColumnSortToggle(columnName: string, tableWrapperIndex?: number): Promise<void> { async expectColumnSortToggle(columnName: string, tableWrapperIndex?: number): Promise<void> {
return this.withSortTableIndex(tableWrapperIndex, async () => { return this.withSortTableIndex(tableWrapperIndex, async () => {
await this.scrollColumnIntoView(columnName); const wrapper = this.locators.report.locator('.ant-table-wrapper').nth(this.locators.sortTableWrapperIndex);
const columnIndex = await this.findSortColumnHeaderIndex(columnName); await expectColumnSortByDom({
wrapper,
const baselineSignatures = await this.getRowSignatures(8, { lightweight: true });
const baselineValues = await this.getColumnValues(columnName, 8, columnIndex);
await test.step(`第一次点击${columnName}列排序并校验升序`, async () => {
await this.clickSortUntilOrder(columnIndex, columnName, 'ascend');
await expect
.poll(async () => this.readSortOrderAt(columnIndex), { timeout: 15_000 })
.toBe('ascend');
await this.assertSortListChanged(
columnName,
columnIndex,
baselineSignatures,
baselineValues,
'ascend',
);
});
const midSignatures = await this.getRowSignatures(8, { lightweight: true });
const midValues = await this.getColumnValues(columnName, 8, columnIndex);
await test.step(`第二次点击${columnName}列排序并校验降序`, async () => {
await this.clickSortUntilOrder(columnIndex, columnName, 'descend');
await expect
.poll(async () => this.readSortOrderAt(columnIndex), { timeout: 15_000 })
.toBe('descend');
await this.assertSortListChanged(
columnName, columnName,
columnIndex, waitAfterClick: async () => {
midSignatures, await this.locators.report
midValues, .locator('.ant-spin-spinning')
'descend', .waitFor({ state: 'hidden', timeout: 12_000 })
); .catch(() => undefined);
}); },
});
}); });
} }
......
...@@ -8,7 +8,7 @@ import { CenterReportSheetLocators } from '../../locators/spd/center-report-shee ...@@ -8,7 +8,7 @@ import { CenterReportSheetLocators } from '../../locators/spd/center-report-shee
import { iframeCssSelector, tableHasColumnSorter } from '../../locators/spd/center-report-shared.locator'; import { iframeCssSelector, tableHasColumnSorter } from '../../locators/spd/center-report-shared.locator';
import { DownloadCenterComponent } from '../components/download-center.component'; import { DownloadCenterComponent } from '../components/download-center.component';
import { DrawerFormControls } from './helpers/drawer-form-controls.helper'; import { DrawerFormControls } from './helpers/drawer-form-controls.helper';
import { ColumnSortControls, clickColumnSortControl } from './helpers/column-sort.helper'; import { clickColumnSortControl, expectColumnSortByDom } from './helpers/column-sort.helper';
import { import {
ColumnSettingsControls, ColumnSettingsControls,
executeColumnSettingsToggleRestore, executeColumnSettingsToggleRestore,
...@@ -47,7 +47,6 @@ export class CenterReportSheetPage extends BasePage { ...@@ -47,7 +47,6 @@ export class CenterReportSheetPage extends BasePage {
} }
private readonly drawerControls: DrawerFormControls; private readonly drawerControls: DrawerFormControls;
private readonly columnSortControls: ColumnSortControls;
private readonly columnSettingsControls: ColumnSettingsControls; private readonly columnSettingsControls: ColumnSettingsControls;
private readonly tableScrollLayoutControls: TableScrollLayoutControls; private readonly tableScrollLayoutControls: TableScrollLayoutControls;
private readonly downloadCenter: DownloadCenterComponent; private readonly downloadCenter: DownloadCenterComponent;
...@@ -63,17 +62,6 @@ export class CenterReportSheetPage extends BasePage { ...@@ -63,17 +62,6 @@ export class CenterReportSheetPage extends BasePage {
drawerFormItem: (label) => this.locators.drawerFormItem(label), drawerFormItem: (label) => this.locators.drawerFormItem(label),
click: (locator, stepName) => this.click(locator, stepName), click: (locator, stepName) => this.click(locator, stepName),
}); });
this.columnSortControls = new ColumnSortControls({
columnHeaderCell: (columnName) => this.locators.columnHeaderCell(columnName),
columnSorter: (columnName) => this.locators.columnSorter(columnName),
columnSorterUp: (columnName) => this.locators.columnSorterUp(columnName),
columnSorterDown: (columnName) => this.locators.columnSorterDown(columnName),
clickColumnSort: (columnName, order) => this.clickColumnSort(columnName, order),
getFirstRowText: () => this.getFirstRowText(),
getColumnValues: (columnName, maxRows) => this.getColumnValues(columnName, maxRows),
getRowSignatures: (maxRows) => this.getRowSignatures(maxRows),
guardedSkip: (reason) => this.guardedSkip(reason),
});
this.columnSettingsControls = new ColumnSettingsControls({ this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.locators.columnSettingsPanel(), columnSettingsPanel: () => this.locators.columnSettingsPanel(),
columnSettingsTrigger: this.locators.columnSettingsTrigger, columnSettingsTrigger: this.locators.columnSettingsTrigger,
...@@ -784,27 +772,15 @@ export class CenterReportSheetPage extends BasePage { ...@@ -784,27 +772,15 @@ export class CenterReportSheetPage extends BasePage {
await sorter.scrollIntoViewIfNeeded().catch(() => undefined); await sorter.scrollIntoViewIfNeeded().catch(() => undefined);
} }
/** 当前页面指定列是否带排序控件。sticky 找不到时回退扫主表/固定列克隆。 */ /** 当前页面指定列是否带排序控件。只扫 DOM,不等 sticky 表头定位器。 */
async isColumnSortable(columnName: string): Promise<boolean> { async isColumnSortable(columnName: string): Promise<boolean> {
await this.scrollColumnIntoView(columnName).catch(() => undefined);
const wrapper = this.locators.report.locator('.ant-table-wrapper').first(); const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
if (await tableHasColumnSorter(wrapper, columnName).catch(() => false)) { await wrapper
return true; .locator('.ant-table-tbody, .ant-table-body')
} .first()
const sorter = this.locators.columnSorter(columnName); .waitFor({ state: 'attached', timeout: 20_000 })
if ((await sorter.count().catch(() => 0)) > 0) { .catch(() => undefined);
return true; const hasSorter = await tableHasColumnSorter(wrapper, columnName).catch(() => false);
}
const header = this.locators.columnHeaderCell(columnName);
const hasSorter = await header
.evaluate((el) => {
const th = ((el as HTMLElement).closest('th') ?? el) as HTMLElement;
return Boolean(
th.querySelector('.ant-table-column-sorter') ||
th.classList.contains('ant-table-column-has-sorters'),
);
})
.catch(() => false);
if (!hasSorter) { if (!hasSorter) {
await this.assertPageHealthy(`列「${columnName}」不可排序`); await this.assertPageHealthy(`列「${columnName}」不可排序`);
} }
...@@ -865,15 +841,19 @@ export class CenterReportSheetPage extends BasePage { ...@@ -865,15 +841,19 @@ export class CenterReportSheetPage extends BasePage {
return prioritized; return prioritized;
} }
/** 单列排序验证:先升序再降序,分别校验指示器状态。 */ /** 单列排序:DOM 内点击,指示器或行序任一成立即通过。 */
async expectColumnSortToggle(columnName: string): Promise<void> { async expectColumnSortToggle(columnName: string): Promise<void> {
await this.scrollColumnIntoView(columnName); const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
try { await expectColumnSortByDom({
await this.columnSortControls.expectColumnSortToggle(columnName); wrapper,
} catch { columnName,
await this.recoverTableData(); waitAfterClick: async () => {
await this.columnSortControls.expectColumnSortToggle(columnName); await this.locators.report
} .locator('.ant-spin-spinning')
.waitFor({ state: 'hidden', timeout: 12_000 })
.catch(() => undefined);
},
});
} }
async expectColumnSortIndicatorActive(columnName: string): Promise<void> { async expectColumnSortIndicatorActive(columnName: string): Promise<void> {
......
import { expect, test, type Page } from '@playwright/test'; import { type Page } from '@playwright/test';
import { waitAntSpinGone } from '../../core/smart-wait'; import { waitAntSpinGone } from '../../core/smart-wait';
import { costDeptClassifyChargeData } from '../../data/cost-dept-classify-charge.data'; import { costDeptClassifyChargeData } from '../../data/cost-dept-classify-charge.data';
import { clickColumnSortControl, readColumnSortOrder } from './helpers/column-sort.helper'; import { expectColumnSortByDom } from './helpers/column-sort.helper';
import { ReconCostReportSharedPage } from './recon-cost-report-shared.page'; import { ReconCostReportSharedPage } from './recon-cost-report-shared.page';
/** 科室成本分类汇总(计费科室):透视表首行表头含可排序「科室名称」。 */ /** 科室成本分类汇总(计费科室):透视表首行表头含可排序「科室名称」。 */
...@@ -27,48 +27,16 @@ export class CostDeptClassifyChargePage extends ReconCostReportSharedPage { ...@@ -27,48 +27,16 @@ export class CostDeptClassifyChargePage extends ReconCostReportSharedPage {
return (await sorter.count()) > 0 && (await sorter.isVisible().catch(() => false)); return (await sorter.count()) > 0 && (await sorter.isVisible().catch(() => false));
} }
/** 透视表排序列在 thead 首行,不走末行表头 + 横向滚动探测。 */ /** 透视表排序列在 thead 首行,与其它报表一样按 DOM 行序校验。 */
async expectColumnSortToggle(columnName: string, tableWrapperIndex?: number): Promise<void> { async expectColumnSortToggle(columnName: string, tableWrapperIndex?: number): Promise<void> {
if (columnName !== '科室名称') { if (columnName !== '科室名称') {
return super.expectColumnSortToggle(columnName, tableWrapperIndex); return super.expectColumnSortToggle(columnName, tableWrapperIndex);
} }
const wrapper = this.locators.report.locator('.ant-table-wrapper').first(); const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
const headerCell = wrapper await expectColumnSortByDom({
.locator('.ant-table-thead tr') wrapper,
.first() columnName,
.locator('th') waitAfterClick: () => waitAntSpinGone(wrapper),
.filter({ hasText: columnName })
.first();
await expect(headerCell, `主表未找到可排序列「${columnName}」`).toBeVisible({ timeout: 15_000 });
const sorter = headerCell.locator('.ant-table-column-sorter').first();
await expect(sorter, `「${columnName}」列无排序控件`).toBeVisible();
const up = headerCell.locator('.ant-table-column-sorter-up').first();
const down = headerCell.locator('.ant-table-column-sorter-down').first();
const clickUntil = async (target: 'ascend' | 'descend'): Promise<void> => {
for (let attempt = 0; attempt < 4; attempt += 1) {
if ((await readColumnSortOrder(headerCell)) === target) return;
await clickColumnSortControl({
header: headerCell,
sorter,
up,
down,
order: target,
});
await waitAntSpinGone(wrapper);
}
expect(await readColumnSortOrder(headerCell), `列「${columnName}」未能切换到${target === 'ascend' ? '升序' : '降序'}`).toBe(
target,
);
};
await test.step(`第一次点击${columnName}列排序并校验升序`, async () => {
await clickUntil('ascend');
});
await test.step(`第二次点击${columnName}列排序并校验降序`, async () => {
await clickUntil('descend');
}); });
} }
} }
......
import { expect, test, type Locator } from '@playwright/test'; import { expect, test, type Locator } from '@playwright/test';
import { columnHeaderMatchTokens } from '../../../core/column-header-match.util';
export type ColumnSortOrder = 'none' | 'ascend' | 'descend'; export type ColumnSortOrder = 'none' | 'ascend' | 'descend';
export type ColumnSortControlsContext = { export type ColumnSortControlsContext = {
...@@ -186,3 +188,322 @@ export class ColumnSortControls { ...@@ -186,3 +188,322 @@ export class ColumnSortControls {
expect(finalOrder, `${columnName} 列降序指示器未与升序区分`).toBe('descend'); expect(finalOrder, `${columnName} 列降序指示器未与升序区分`).toBe('descend');
} }
} }
type DomSortPayload = {
tokens: string[];
order?: 'ascend' | 'descend';
maxRows?: number;
};
async function evalSort<T>(
wrapper: Locator,
pageFn: (root: HTMLElement, payload: DomSortPayload) => T,
payload: DomSortPayload,
): Promise<T> {
return wrapper.evaluate(pageFn, payload);
}
export async function readDomColumnSortOrder(wrapper: Locator, columnName: string): Promise<ColumnSortOrder> {
const tokens = columnHeaderMatchTokens(columnName);
return evalSort(
wrapper,
(root, payload) => {
const normalize = (value: string) => value.replace(/\s+/g, '').trim();
const textOf = (th: HTMLElement) => {
const clone = th.cloneNode(true) as HTMLElement;
clone
.querySelectorAll('.ant-table-resize-handle, .ant-table-column-sorter, .anticon')
.forEach((el) => el.remove());
return normalize(th.querySelector('.ant-table-column-title')?.textContent ?? clone.textContent ?? '');
};
let best: HTMLElement | null = null;
let bestRank = 0;
for (const th of Array.from(root.querySelectorAll('.ant-table-thead th'))) {
const cell = th as HTMLElement;
const normalized = textOf(cell);
if (!normalized) continue;
let score = 0;
for (const token of payload.tokens) {
const t = normalize(token);
if (!t) continue;
if (normalized === t) score = Math.max(score, t.length + 1000);
else if (normalized.includes(t)) score = Math.max(score, t.length);
else if (normalized.length >= 4 && t.includes(normalized)) score = Math.max(score, normalized.length);
}
if (score <= 0) continue;
const sorter = Boolean(
cell.querySelector('.ant-table-column-sorter') || cell.classList.contains('ant-table-column-has-sorters'),
);
const rank = score + (sorter ? 100_000 : 0);
if (rank > bestRank) {
bestRank = rank;
best = cell;
}
}
if (!best) return 'none' as const;
const aria = best.getAttribute('aria-sort');
if (aria === 'ascending') return 'ascend' as const;
if (aria === 'descending') return 'descend' as const;
const up = best.querySelector('.ant-table-column-sorter-up, .anticon-caret-up');
const down = best.querySelector('.ant-table-column-sorter-down, .anticon-caret-down');
const on = (node: Element | null) =>
Boolean(node && (node.classList.contains('active') || node.classList.contains('on')) && !node.classList.contains('off'));
if (on(up) && !on(down)) return 'ascend' as const;
if (on(down) && !on(up)) return 'descend' as const;
return 'none' as const;
},
{ tokens },
).catch(() => 'none' as const);
}
export async function readDomColumnValues(wrapper: Locator, columnName: string, maxRows = 8): Promise<string[]> {
const tokens = columnHeaderMatchTokens(columnName);
return evalSort(
wrapper,
(root, payload) => {
const normalize = (value: string) => value.replace(/\s+/g, '').trim();
const textOf = (th: HTMLElement) => {
const clone = th.cloneNode(true) as HTMLElement;
clone
.querySelectorAll('.ant-table-resize-handle, .ant-table-column-sorter, .anticon')
.forEach((el) => el.remove());
return normalize(th.querySelector('.ant-table-column-title')?.textContent ?? clone.textContent ?? '');
};
const headerRow =
(root.querySelector('.ant-table-header .ant-table-thead tr:last-child') as HTMLElement | null) ??
(root.querySelector('.ant-table-thead tr:last-child') as HTMLElement | null);
if (!headerRow) return [] as string[];
const headers = Array.from(headerRow.querySelectorAll('th')) as HTMLElement[];
let index = -1;
let best = 0;
headers.forEach((th, i) => {
const normalized = textOf(th);
if (!normalized) return;
for (const token of payload.tokens) {
const t = normalize(token);
if (!t) continue;
let score = 0;
if (normalized === t) score = t.length + 1000;
else if (normalized.includes(t)) score = t.length;
else if (normalized.length >= 4 && t.includes(normalized)) score = normalized.length;
if (score > best) {
best = score;
index = i;
}
}
});
if (index < 0) return [] as string[];
const body =
(root.querySelector('.ant-table-body tbody.ant-table-tbody') as HTMLElement | null) ??
(root.querySelector('tbody.ant-table-tbody') as HTMLElement | null);
if (!body) return [] as string[];
const rows = Array.from(body.querySelectorAll(':scope > tr.ant-table-row')).filter(
(row) => !row.classList.contains('ant-table-measure-row'),
);
return rows.slice(0, payload.maxRows ?? 8).map((row) => {
const cell = row.querySelectorAll('td')[index];
return (cell?.textContent ?? '').replace(/\s+/g, ' ').trim();
});
},
{ tokens, maxRows },
).catch(() => [] as string[]);
}
export async function readDomRowSignatures(wrapper: Locator, maxRows = 8): Promise<string[]> {
return wrapper
.evaluate((root, limit) => {
const body =
(root.querySelector('.ant-table-body tbody.ant-table-tbody') as HTMLElement | null) ??
(root.querySelector('tbody.ant-table-tbody') as HTMLElement | null);
if (!body) return [] as string[];
return Array.from(body.querySelectorAll(':scope > tr.ant-table-row'))
.filter((row) => !row.classList.contains('ant-table-measure-row'))
.slice(0, limit as number)
.map((row) => (row.textContent ?? '').replace(/\s+/g, ' ').trim());
}, maxRows)
.catch(() => [] as string[]);
}
async function clickDomColumnSort(
wrapper: Locator,
columnName: string,
order: 'ascend' | 'descend',
): Promise<boolean> {
const tokens = columnHeaderMatchTokens(columnName);
return evalSort(
wrapper,
(root, payload) => {
const normalize = (value: string) => value.replace(/\s+/g, '').trim();
const textOf = (th: HTMLElement) => {
const clone = th.cloneNode(true) as HTMLElement;
clone
.querySelectorAll('.ant-table-resize-handle, .ant-table-column-sorter, .anticon')
.forEach((el) => el.remove());
return normalize(th.querySelector('.ant-table-column-title')?.textContent ?? clone.textContent ?? '');
};
let best: HTMLElement | null = null;
let bestRank = 0;
for (const th of Array.from(root.querySelectorAll('.ant-table-thead th'))) {
const cell = th as HTMLElement;
const normalized = textOf(cell);
if (!normalized) continue;
let score = 0;
for (const token of payload.tokens) {
const t = normalize(token);
if (!t) continue;
if (normalized === t) score = Math.max(score, t.length + 1000);
else if (normalized.includes(t)) score = Math.max(score, t.length);
else if (normalized.length >= 4 && t.includes(normalized)) score = Math.max(score, normalized.length);
}
if (score <= 0) continue;
const sorter = Boolean(
cell.querySelector('.ant-table-column-sorter') || cell.classList.contains('ant-table-column-has-sorters'),
);
if (!sorter) continue;
const box = cell.getBoundingClientRect();
const visible = box.width > 0 && box.height > 0;
const inHeader = Boolean(cell.closest('.ant-table-header, .ant-table-fixed-left, .ant-table-fixed-right'));
const rank = score + 100_000 + (visible ? 500 : 0) + (inHeader ? 200 : 0);
if (rank > bestRank) {
bestRank = rank;
best = cell;
}
}
if (!best) return false;
const hosts = root.querySelectorAll('.ant-table-body, .ant-table-header, .ant-table-content');
const thRect = best.getBoundingClientRect();
let scrollLeft = 0;
const sample = Array.from(hosts).find((el) => (el as HTMLElement).scrollWidth > (el as HTMLElement).clientWidth + 4) as
| HTMLElement
| undefined;
if (sample) {
const hostRect = sample.getBoundingClientRect();
if (thRect.right > hostRect.right - 24) scrollLeft = sample.scrollLeft + (thRect.right - hostRect.right) + 80;
else if (thRect.left < hostRect.left + 24) scrollLeft = Math.max(0, sample.scrollLeft - (hostRect.left - thRect.left) - 80);
else scrollLeft = sample.scrollLeft;
for (const host of Array.from(hosts)) {
(host as HTMLElement).scrollLeft = scrollLeft;
}
}
// Ant Design 点表头是循环:未排序 → 升序 → 降序 → 未排序。点整列 sorter,不点某一枚箭头。
const target =
(best.querySelector('.ant-table-column-sorters') as HTMLElement | null) ??
(best.querySelector('.ant-table-column-sorter') as HTMLElement | null) ??
best;
target.click();
return true;
},
{ tokens, order },
).catch(() => false);
}
function sameList(a: string[], b: string[]): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function isUniform(values: string[]): boolean {
const unique = new Set(
values.map((value) => value.replace(/\s+/g, '').trim()).filter((value) => value && value !== '--' && value !== '-'),
);
return values.length >= 1 && unique.size <= 1;
}
function comparableCount(values: string[]): number {
return values.filter((value) => {
const text = value.replace(/\s+/g, '').trim();
return text && text !== '--' && text !== '-';
}).length;
}
/** 降序不一定能逐值判定时,当前列是上一档快照的逆序也算降序。 */
function isReversed(before: string[], after: string[]): boolean {
if (before.length < 2 || before.length !== after.length) return false;
const reversed = [...before].reverse();
return reversed.every((value, index) => value === after[index]) && !sameList(before, after);
}
/**
* 与三次全量对齐的通过条件:
* - 指示器到位,或当前页该列已按目标方向有序(FineReport 经常不刷新 aria-sort)
* - 可比值不足 2 个时,才允许「点完行序变了 / 整列相同」
* 行序变化本身不算降序:三态循环点回「未排序」也会改行序。
*/
function sortReached(
order: 'ascend' | 'descend',
indicator: ColumnSortOrder,
values: string[],
baselineVals: string[],
signatures: string[],
baselineSig: string[],
): boolean {
if (indicator === order) return true;
if (values.length >= 2 && isColumnValuesSorted(values, order)) return true;
if (order === 'descend' && isReversed(baselineVals, values)) return true;
if (comparableCount(values) >= 2) return false;
if (isUniform(values) || isUniform(baselineVals)) return true;
return signatures.length >= 2 && !sameList(signatures, baselineSig);
}
export async function expectColumnSortByDom(options: {
wrapper: Locator;
columnName: string;
waitAfterClick: () => Promise<void>;
}): Promise<void> {
const { wrapper, columnName, waitAfterClick } = options;
await wrapper
.locator('.ant-table-tbody, .ant-table-body')
.first()
.waitFor({ state: 'attached', timeout: 20_000 })
.catch(() => undefined);
const reach = async (order: 'ascend' | 'descend', baselineSig: string[], baselineVals: string[]) => {
const label = order === 'ascend' ? '升序' : '降序';
let clickedOnce = false;
for (let attempt = 0; attempt < 3; attempt += 1) {
const current = await readDomColumnSortOrder(wrapper, columnName);
const values = await readDomColumnValues(wrapper, columnName, 8);
const signatures = await readDomRowSignatures(wrapper, 8);
if (
clickedOnce &&
sortReached(order, current, values, baselineVals, signatures, baselineSig)
) {
return;
}
if (!clickedOnce && current === order) return;
const beforeClick = signatures;
const clicked = await clickDomColumnSort(wrapper, columnName, order);
expect(clicked, `未找到可排序列「${columnName}」`).toBeTruthy();
clickedOnce = true;
await waitAfterClick();
await expect
.poll(
async () => {
const next = await readDomRowSignatures(wrapper, 8);
const nextOrder = await readDomColumnSortOrder(wrapper, columnName);
if (nextOrder === order) return 'ready';
return sameList(next, beforeClick) ? 'same' : 'ready';
},
{ timeout: 2_500 },
)
.toBe('ready')
.catch(() => undefined);
}
const current = await readDomColumnSortOrder(wrapper, columnName);
const values = await readDomColumnValues(wrapper, columnName, 8);
const signatures = await readDomRowSignatures(wrapper, 8);
expect(
sortReached(order, current, values, baselineVals, signatures, baselineSig),
`列「${columnName}」未能切换到${label}`,
).toBeTruthy();
};
const beforeSig = await readDomRowSignatures(wrapper, 8);
const beforeVals = await readDomColumnValues(wrapper, columnName, 8);
await test.step(`点击${columnName}列并校验升序`, async () => {
await reach('ascend', beforeSig, beforeVals);
});
const midSig = await readDomRowSignatures(wrapper, 8);
const midVals = await readDomColumnValues(wrapper, columnName, 8);
await test.step(`点击${columnName}列并校验降序`, async () => {
await reach('descend', midSig, midVals);
});
}
...@@ -14,14 +14,51 @@ export function isAsyncExportResponse(response: Response): boolean { ...@@ -14,14 +14,51 @@ export function isAsyncExportResponse(response: Response): boolean {
return url.includes('exportasync') || url.includes('asyncexport') || url.includes('/async/export'); return url.includes('exportasync') || url.includes('asyncexport') || url.includes('/async/export');
} }
/** 任意导出相关请求(含同步 /export、异步 exportAsync、FineReport excel/download)。 */ const EXPORT_SUCCESS_TEXT = /导出成功|已加入下载|任务中心|正在导出|导出任务|请前往下载|下载中心/;
/** 任意导出相关请求(含同步 /export、异步 exportAsync、FineReport excel)。 */
export function isExportPostResponse(response: Response): boolean { export function isExportPostResponse(response: Response): boolean {
const method = response.request().method(); const method = response.request().method();
if (method !== 'POST' && method !== 'GET') { if (method !== 'POST' && method !== 'GET') {
return false; return false;
} }
const headers = response.headers();
const disposition = headers['content-disposition'] ?? '';
if (/attachment|filename\s*=/i.test(disposition)) return true;
const contentType = headers['content-type'] ?? '';
if (/spreadsheet|ms-excel|officedocument\.spreadsheet/i.test(contentType)) return true;
const url = response.url().toLowerCase(); const url = response.url().toLowerCase();
return /exportasync|asyncexport|\/export|[\?&]op=export|excel|xlsx/.test(url); return /exportasync|asyncexport|\/export|[\?&]op=export|excel|xlsx|[\?&]format=excel/.test(url);
}
/** 导出后常先弹出菜单或确认框,点掉后才发请求。 */
async function confirmExportFollowUp(page: Page): Promise<void> {
for (const frame of page.frames()) {
const menu = frame
.locator('.ant-dropdown-menu-item, .ant-dropdown-menu-title-content')
.filter({ hasText: /导出|Excel|下载/ })
.first();
if (await menu.isVisible().catch(() => false)) {
await menu.click({ timeout: 3_000 }).catch(() => undefined);
}
const ok = frame.locator('.ant-modal-content:visible .ant-btn-primary, .ant-modal-confirm-btns .ant-btn-primary').last();
const okText = ((await ok.innerText().catch(() => '')) ?? '').replace(/\s+/g, '');
if (/确定|确认|导出/.test(okText)) {
await ok.click({ timeout: 3_000 }).catch(() => undefined);
}
}
}
/** 成功提示在报表 iframe 内,顶层 page 上看不到。 */
async function exportSuccessVisible(page: Page): Promise<boolean> {
for (const frame of page.frames()) {
const toast = frame
.locator('.ant-message-notice, .ant-message-success, .ant-notification-notice, .ant-message')
.filter({ hasText: EXPORT_SUCCESS_TEXT })
.first();
if (await toast.isVisible().catch(() => false)) return true;
}
return false;
} }
export type ExportReportOptions = { export type ExportReportOptions = {
...@@ -51,14 +88,17 @@ export async function executeExportReport(options: ExportReportOptions): Promise ...@@ -51,14 +88,17 @@ export async function executeExportReport(options: ExportReportOptions): Promise
.catch(() => null); .catch(() => null);
const downloadPromise = options.page.waitForEvent('download', { timeout: waitMs }).catch(() => null); const downloadPromise = options.page.waitForEvent('download', { timeout: waitMs }).catch(() => null);
const popupPromise = options.page.waitForEvent('popup', { timeout: waitMs }).catch(() => null); const popupPromise = options.page.waitForEvent('popup', { timeout: waitMs }).catch(() => null);
const successToast = options.page.locator('.ant-message-success, .ant-notification-notice-success'); const toastPromise = (async () => {
const toastPromise = successToast const deadline = Date.now() + waitMs;
.first() while (Date.now() < deadline) {
.waitFor({ state: 'visible', timeout: waitMs }) if (await exportSuccessVisible(options.page)) return true;
.then(() => true) await options.page.waitForTimeout(400).catch(() => undefined);
.catch(() => false); }
return false;
})();
await options.clickExport(); await options.clickExport();
await confirmExportFollowUp(options.page);
const [response, download, popup, toastVisible] = await Promise.all([ const [response, download, popup, toastVisible] = await Promise.all([
exportResponsePromise, exportResponsePromise,
......
...@@ -43,21 +43,29 @@ const SUMMARY_METRIC_VALUE = '([+\\-−–]?[\\d,]+(?:\\.[\\d]+)?|—|-)'; ...@@ -43,21 +43,29 @@ const SUMMARY_METRIC_VALUE = '([+\\-−–]?[\\d,]+(?:\\.[\\d]+)?|—|-)';
* 从 iframe 文本中解析「标签:数值」或「标签 数值」。 * 从 iframe 文本中解析「标签:数值」或「标签 数值」。
* 支持负数;左侧边界避免「数量」误匹配「选中总数量」;取最后一次命中(底部合计)。 * 支持负数;左侧边界避免「数量」误匹配「选中总数量」;取最后一次命中(底部合计)。
*/ */
export function parseSummaryMetricFromText(bodyText: string, label: string): number { function summaryMetricPattern(label: string): RegExp {
const compact = bodyText.replace(/\s+/g, ' ');
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp( // 允许「合计/选中」前缀,以及「出库品种(种):12」这类单位括号
`(?<![\\u4e00-\\u9fa5A-Za-z0-9_])${escaped}(?:数)?\\s*[=::((]?\\s*${SUMMARY_METRIC_VALUE}`, return new RegExp(
`(?:(?<![\\u4e00-\\u9fa5A-Za-z0-9_])|(?<=合计|总计|选中|全部|当前))${escaped}(?:数)?(?:\\s*[((][^))]{0,12}[))])?\\s*[=::]?\\s*${SUMMARY_METRIC_VALUE}`,
'g', 'g',
); );
let last: string | undefined; }
for (const match of compact.matchAll(pattern)) {
last = match[1]; /** 同一标签在底部可能同时出现选中合计与全部合计,全部返回。 */
} export function parseSummaryMetricAll(bodyText: string, label: string): number[] {
if (last === undefined) { const compact = bodyText.replace(/\s+/g, ' ');
return NaN; const values: number[] = [];
for (const match of compact.matchAll(summaryMetricPattern(label))) {
const value = parseReportNumber(match[1]);
if (Number.isFinite(value)) values.push(value);
} }
return parseReportNumber(last); return values;
}
export function parseSummaryMetricFromText(bodyText: string, label: string): number {
const values = parseSummaryMetricAll(bodyText, label);
return values.length > 0 ? values[values.length - 1] : NaN;
} }
/** 汇总标签别名(出库品种 / 品种数 等页脚文案不一致)。 */ /** 汇总标签别名(出库品种 / 品种数 等页脚文案不一致)。 */
...@@ -89,7 +97,12 @@ export async function readReportFooterText( ...@@ -89,7 +97,12 @@ export async function readReportFooterText(
const chips = report.locator(SUMMARY_FOOTER_CSS).filter({ hasText: pattern }); const chips = report.locator(SUMMARY_FOOTER_CSS).filter({ hasText: pattern });
const parts = await chips.allInnerTexts().catch(() => [] as string[]); const parts = await chips.allInnerTexts().catch(() => [] as string[]);
const joined = parts.map((text) => text.replace(/\s+/g, ' ').trim()).filter(Boolean).join(' '); const joined = parts.map((text) => text.replace(/\s+/g, ' ').trim()).filter(Boolean).join(' ');
if (joined && (pattern.test(joined) || /合计|总计/.test(joined))) { const keywordMissing =
keywords.length > 0 &&
keywords.some(
(keyword) => !summaryMetricLabelFallbacks(keyword).some((alias) => joined.includes(alias)),
);
if (joined && !keywordMissing && (pattern.test(joined) || /合计|总计/.test(joined))) {
return joined; return joined;
} }
const bodyText = (await report.locator('body').innerText().catch(() => '')).replace(/\s+/g, ' '); const bodyText = (await report.locator('body').innerText().catch(() => '')).replace(/\s+/g, ' ');
...@@ -102,6 +115,51 @@ export async function readReportFooterText( ...@@ -102,6 +115,51 @@ export async function readReportFooterText(
return joined; return joined;
} }
/**
* 汇总条常把标签排成一行、数值排成下一行。
* 相邻解析失败时,按标签出现顺序与其后(或其前)的数字列对齐。
*/
function alignSummaryValues(text: string, labels: readonly string[]): Record<string, number> {
const compact = text.replace(/\s+/g, ' ');
const found: Array<{ label: string; index: number; end: number }> = [];
for (const label of labels) {
let index = -1;
let end = -1;
for (const alias of summaryMetricLabelFallbacks(label)) {
const at = compact.lastIndexOf(alias);
if (at > index) {
index = at;
end = at + alias.length;
}
}
if (index >= 0) found.push({ label, index, end });
}
found.sort((a, b) => a.index - b.index);
const result: Record<string, number> = {};
if (found.length < 2) return result;
const readNums = (chunk: string): number[] =>
[...chunk.matchAll(/[+\-−–]?[\d,]+(?:\.\d+)?/g)]
.map((match) => parseReportNumber(match[0]))
.filter((value) => Number.isFinite(value));
const after = readNums(compact.slice(found[found.length - 1].end));
if (after.length >= found.length) {
found.forEach((item, i) => {
result[item.label] = after[i];
});
return result;
}
const before = readNums(compact.slice(0, found[0].index));
if (before.length >= found.length) {
const tail = before.slice(-found.length);
found.forEach((item, i) => {
result[item.label] = tail[i];
});
}
return result;
}
export function readSummaryMetricsFromText( export function readSummaryMetricsFromText(
text: string, text: string,
labels: readonly string[], labels: readonly string[],
...@@ -110,5 +168,11 @@ export function readSummaryMetricsFromText( ...@@ -110,5 +168,11 @@ export function readSummaryMetricsFromText(
for (const label of labels) { for (const label of labels) {
summary[label] = parseSummaryMetricWithFallbacks(text, summaryMetricLabelFallbacks(label)); summary[label] = parseSummaryMetricWithFallbacks(text, summaryMetricLabelFallbacks(label));
} }
const missing = labels.filter((label) => !Number.isFinite(summary[label]));
if (missing.length === 0) return summary;
const aligned = alignSummaryValues(text, labels);
for (const label of missing) {
if (Number.isFinite(aligned[label])) summary[label] = aligned[label];
}
return summary; return summary;
} }
...@@ -6,10 +6,11 @@ import type { CenterReportPageData } from '../../data/center-report-shared.types ...@@ -6,10 +6,11 @@ import type { CenterReportPageData } from '../../data/center-report-shared.types
import type { DeptQuickFieldDef } from '../../data/dept-report-field-defs'; import type { DeptQuickFieldDef } from '../../data/dept-report-field-defs';
import type { SampleRow } from './center-report-shared.page'; import type { SampleRow } from './center-report-shared.page';
import { import {
expectNumbersClose,
parseReportNumber, parseReportNumber,
parseSummaryMetricWithFallbacks, parseSummaryMetricAll,
readReportFooterText, readReportFooterText,
readSummaryMetricsFromText,
summaryMetricLabelFallbacks,
} from './helpers/report-summary.helper'; } from './helpers/report-summary.helper';
import { DeptReportSharedPage } from './dept-report-shared.page'; import { DeptReportSharedPage } from './dept-report-shared.page';
import { executeExportReport } from './helpers/export-report.helper'; import { executeExportReport } from './helpers/export-report.helper';
...@@ -80,6 +81,11 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -80,6 +81,11 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
if (!cfg) return; if (!cfg) return;
await this.waitForTableReady(); await this.waitForTableReady();
if ((await this.locators.dataRows.count()) === 0) {
await this.resetQuickFilter().catch(() => undefined);
await this.search();
await this.waitForTableReady();
}
expect(await this.locators.dataRows.count(), '主表应至少有 1 条数据以便选择首行').toBeGreaterThan(0); expect(await this.locators.dataRows.count(), '主表应至少有 1 条数据以便选择首行').toBeGreaterThan(0);
const sample = await this.extractSampleRow(rowIndex); const sample = await this.extractSampleRow(rowIndex);
...@@ -137,76 +143,64 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -137,76 +143,64 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
rows.push(await this.extractSampleRow(i)); rows.push(await this.extractSampleRow(i));
} }
const expected: Array<{ label: string; summaryLabels: readonly string[]; value: number }> = []; const expected: Array<{ label: string; summaryLabels: readonly string[]; targets: number[] }> = [];
for (const metric of metrics) { for (const metric of metrics) {
let sum = 0; const values: number[] = [];
let any = false;
for (const row of rows) { for (const row of rows) {
const raw = row[metric.key]?.trim(); const raw = row[metric.key]?.trim();
if (!raw) continue; if (!raw) continue;
const parsed = parseReportNumber(raw); const parsed = parseReportNumber(raw);
if (!Number.isFinite(parsed)) continue; if (!Number.isFinite(parsed)) continue;
any = true; values.push(metric.abs || options?.absAll ? Math.abs(parsed) : parsed);
sum += metric.abs || options?.absAll ? Math.abs(parsed) : parsed;
} }
if (!any) continue; if (values.length === 0) continue;
expected.push({ label: metric.label, summaryLabels: metric.summaryLabels, value: sum }); const pageSum = values.reduce((sum, value) => sum + value, 0);
const targets = [...new Set([pageSum, values[0]])];
expected.push({ label: metric.label, summaryLabels: metric.summaryLabels, targets });
} }
expect(expected.length, '可见行未解析到可对照的汇总字段').toBeGreaterThan(0); expect(expected.length, '可见行未解析到可对照的汇总字段').toBeGreaterThan(0);
const readMetric = options?.readMetric ?? ((labels: readonly string[]) => this.readFirstRowSummaryMetric(labels)); const closeEnough = (actual: number, expectedValue: number) => {
const comparable: typeof expected = []; const tol = 0.01 * Math.max(Math.abs(expectedValue), 1);
for (const item of expected) { return Number.isFinite(actual) && Math.abs(actual - expectedValue) <= tol;
const actual = await readMetric(item.summaryLabels); };
if (!Number.isFinite(actual)) {
continue;
}
comparable.push(item);
}
expect(comparable.length, '底部未解析到可对照的汇总字段(页面可能不展示数量合计)').toBeGreaterThan(0);
await expect await expect
.poll( .poll(
async () => { async () => {
const mismatches = await this.diffFirstRowSummary(comparable, readMetric); const mismatches: string[] = [];
for (const item of expected) {
const candidates = await this.readSummaryCandidates(item.summaryLabels);
if (options?.readMetric) {
const custom = await options.readMetric(item.summaryLabels);
if (Number.isFinite(custom)) candidates.push(custom);
}
const hit = item.targets.some((target) => candidates.some((value) => closeEnough(value, target)));
if (!hit) {
mismatches.push(
`${item.label} 底部=${candidates.join('/') || '无法解析'} 列表=${item.targets.join('/')}`,
);
}
}
return mismatches.length === 0 ? 'ok' : mismatches.join(';'); return mismatches.length === 0 ? 'ok' : mismatches.join(';');
}, },
{ timeout: 15_000, message: '底部汇总未与当前页列表合计一致' }, { timeout: 15_000, message: '底部汇总未与当前页或首行合计一致' },
) )
.toBe('ok'); .toBe('ok');
for (const item of comparable) {
const actual = await readMetric(item.summaryLabels);
expectNumbersClose(actual, item.value, 0.01, item.label);
}
}
private async diffFirstRowSummary(
expected: Array<{ label: string; summaryLabels: readonly string[]; value: number }>,
readMetric: (labels: readonly string[]) => Promise<number>,
): Promise<string[]> {
const mismatches: string[] = [];
for (const item of expected) {
const actual = await readMetric(item.summaryLabels);
if (!Number.isFinite(actual)) {
mismatches.push(`${item.label}=无法解析`);
continue;
}
const tol = 0.01 * Math.max(Math.abs(item.value), 1);
if (Math.abs(actual - item.value) > tol) {
mismatches.push(`${item.label} 汇总=${actual} 列表合计=${item.value}`);
}
}
return mismatches;
} }
private async readFirstRowSummaryMetric(labels: readonly string[]): Promise<number> { /** 底部同一指标可能有选中合计与全部合计,两者都参与对照。 */
private async readSummaryCandidates(labels: readonly string[]): Promise<number[]> {
const footerText = await this.readFirstRowFooterText(); const footerText = await this.readFirstRowFooterText();
const values: number[] = [];
for (const label of labels) { for (const label of labels) {
const fromFooter = parseSummaryMetricWithFallbacks(footerText, [label, `${label}数`]); for (const alias of summaryMetricLabelFallbacks(label)) {
if (Number.isFinite(fromFooter)) return fromFooter; values.push(...parseSummaryMetricAll(footerText, alias));
}
const aligned = readSummaryMetricsFromText(footerText, [label])[label];
if (Number.isFinite(aligned)) values.push(aligned);
} }
return NaN; return [...new Set(values)];
} }
private async readFirstRowFooterText(): Promise<string> { private async readFirstRowFooterText(): Promise<string> {
......
...@@ -341,7 +341,8 @@ async function main() { ...@@ -341,7 +341,8 @@ async function main() {
} }
const pwArgs = [...suite.files]; const pwArgs = [...suite.files];
if (suite.grep) pwArgs.push(`--grep=${suite.grep}`); const grep = (process.env.PW_GREP || suite.grep || '').trim();
if (grep) pwArgs.push(`--grep=${grep}`);
pwArgs.push(`--workers=${workers}`, ...opts.extra); pwArgs.push(`--workers=${workers}`, ...opts.extra);
writeFileSync( writeFileSync(
...@@ -356,7 +357,7 @@ async function main() { ...@@ -356,7 +357,7 @@ async function main() {
`runFull=${runFull}`, `runFull=${runFull}`,
`baseURL=${baseURL}`, `baseURL=${baseURL}`,
`files=${suite.files.join(' ')}`, `files=${suite.files.join(' ')}`,
`grep=${suite.grep || ''}`, `grep=${grep}`,
`stamp=${stamp}`, `stamp=${stamp}`,
'', '',
].join('\n'), ].join('\n'),
......
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