Commit 33b0dd82 authored by liguangyu06's avatar liguangyu06
Browse files

修复宽表右侧排序超时、iframe 编码匹配和汇总勾稽,避免夜间全量再被脚本误杀。

parent 79a08ff4
...@@ -11,6 +11,7 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [ ...@@ -11,6 +11,7 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [
['产品编号', '产品编码', '物资编码', '商品编码'], ['产品编号', '产品编码', '物资编码', '商品编码'],
['产品名称', '物资名称', '商品名称'], ['产品名称', '物资名称', '商品名称'],
['规格型号', '规格'], ['规格型号', '规格'],
['产品批号', '批号'],
]; ];
function aliasTokensFor(columnName: string): string[] { function aliasTokensFor(columnName: string): string[] {
......
...@@ -79,8 +79,8 @@ export const traceDirectDetailData: CenterReportPageData & { ...@@ -79,8 +79,8 @@ export const traceDirectDetailData: CenterReportPageData & {
field5: ['采购计划号'], field5: ['采购计划号'],
}, },
horizontalScrollAnchorColumn: '请领日期', horizontalScrollAnchorColumn: '请领单号',
horizontalScrollTargetColumn: '采购单号', horizontalScrollTargetColumn: '请领审核时间',
horizontalScrollDragDelta: 320, horizontalScrollDragDelta: 320,
mockNotExistProduct: '__NOT_EXIST_PRODUCT__99999', mockNotExistProduct: '__NOT_EXIST_PRODUCT__99999',
mockFieldLabel: '产品名称', mockFieldLabel: '产品名称',
......
import type { FrameLocator, Locator, Page } from '@playwright/test'; import type { FrameLocator, Locator, Page } from '@playwright/test';
import { COLUMN_SETTINGS_TRIGGER_CSS } from './center-report-shared.locator'; import {
COLUMN_SETTINGS_TRIGGER_CSS,
iframeCssSelector,
uniqueColumnHeaderCell,
uniqueColumnSorter,
} from './center-report-shared.locator';
/** 中心库出入库报表 - 入库汇总查询(iframe 内报表区)。 */ /** 中心库出入库报表 - 入库汇总查询(iframe 内报表区)。 */
export class CenterReportEntryLocators { export class CenterReportEntryLocators {
...@@ -27,7 +32,7 @@ export class CenterReportEntryLocators { ...@@ -27,7 +32,7 @@ export class CenterReportEntryLocators {
readonly branchIdSelect: Locator; readonly branchIdSelect: Locator;
constructor(page: Page) { constructor(page: Page) {
this.report = page.frameLocator('iframe[src*="centerPages/entry"]'); this.report = page.frameLocator(iframeCssSelector('centerPages/entry'));
this.productName = this.report.locator('#form_item_goodsName').first(); this.productName = this.report.locator('#form_item_goodsName').first();
this.searchBtn = this.report.getByRole('button', { name: '查 询' }); this.searchBtn = this.report.getByRole('button', { name: '查 询' });
this.resetBtn = this.report this.resetBtn = this.report
...@@ -105,28 +110,13 @@ export class CenterReportEntryLocators { ...@@ -105,28 +110,13 @@ export class CenterReportEntryLocators {
.first(); .first();
} }
/** 指定列的表头单元格;每条分支先 .first(),避免 or() 在多匹配 waitFor 时 strict mode。 */ /** 指定列的表头单元格:sticky → 主表 thead → 固定列克隆,优先带 sorter。 */
columnHeaderCell(columnName: string): Locator { columnHeaderCell(columnName: string): Locator {
const thead = this.visibleThead().locator('tr').last(); return uniqueColumnHeaderCell(this.report, columnName);
const byTitle = thead
.locator('th')
.filter({ has: thead.locator('.ant-table-column-title').getByText(columnName, { exact: true }) })
.first();
const bySorter = thead
.locator('th')
.filter({ has: thead.locator('.ant-table-column-title').getByText(columnName, { exact: true }) })
.filter({ has: thead.locator('.ant-table-column-sorter') })
.first();
const byRole = this.report
.locator('.ant-table-wrapper')
.first()
.getByRole('columnheader', { name: columnName, exact: true })
.first();
return byTitle.or(bySorter).or(byRole).first();
} }
columnSorter(columnName: string): Locator { columnSorter(columnName: string): Locator {
return this.columnHeaderCell(columnName).locator('.ant-table-column-sorter').first(); return uniqueColumnSorter(this.report, columnName);
} }
columnSorterUp(columnName: string): Locator { columnSorterUp(columnName: string): Locator {
......
...@@ -2,6 +2,15 @@ import type { FrameLocator, Locator, Page } from '@playwright/test'; ...@@ -2,6 +2,15 @@ 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。 */
export function iframeCssSelector(iframeSrc: string): string {
const encoded = iframeSrc.replace(/\//g, '%2F');
if (encoded === iframeSrc) {
return `iframe[src*="${iframeSrc}"]`;
}
return `iframe[src*="${iframeSrc}"], iframe[src*="${encoded}"]`;
}
export type CenterReportLocatorOptions = { export type CenterReportLocatorOptions = {
iframeSrc: string; iframeSrc: string;
productNameSelector: string; productNameSelector: string;
...@@ -24,6 +33,105 @@ export const COLUMN_SETTINGS_TRIGGER_CSS = ...@@ -24,6 +33,105 @@ export const COLUMN_SETTINGS_TRIGGER_CSS =
export const COLUMN_SETTINGS_TRIGGER_XPATH = export const COLUMN_SETTINGS_TRIGGER_XPATH =
'contains(@class,"ant-pro-table-list-toolbar-setting") or contains(@class,"anticon-setting") or @aria-label="setting" or @alt="setting"'; 'contains(@class,"ant-pro-table-list-toolbar-setting") or contains(@class,"anticon-setting") or @aria-label="setting" or @alt="setting"';
function stickyThead(wrapper: Locator): Locator {
return wrapper
.locator('.ant-table-container > .ant-table-header .ant-table-thead')
.or(wrapper.locator('.ant-table-header .ant-table-thead'))
.or(wrapper.locator('.ant-table-content > table > .ant-table-thead'))
.or(wrapper.locator('.ant-table-thead'))
.first();
}
function thWithExactTitle(thead: Locator, columnName: string): Locator {
return thead.locator('th').filter({
has: thead.locator('.ant-table-column-title').getByText(columnName, { exact: true }),
});
}
/**
* IOD/IES 宽表表头:先 sticky,再主表 body thead,再固定列克隆。
* 每条分支都 .first(),避免主表+克隆同时命中时 waitFor 触发 strict mode。
*/
export function uniqueColumnHeaderCell(report: FrameLocator, columnName: string): Locator {
const wrapper = report.locator('.ant-table-wrapper').first();
const sticky = stickyThead(wrapper).locator('tr').last();
const bodyThead = wrapper
.locator('.ant-table-body .ant-table-thead, .ant-table-content .ant-table-thead')
.first()
.locator('tr')
.last();
const anyThead = wrapper.locator('.ant-table-thead');
const fixedTh = wrapper.locator(
'.ant-table-fixed-left .ant-table-thead th, .ant-table-fixed-right .ant-table-thead th',
);
const stickyTitle = thWithExactTitle(sticky, columnName);
const bodyTitle = thWithExactTitle(bodyThead, columnName);
const anyTitle = anyThead
.locator('th')
.filter({ has: anyThead.locator('.ant-table-column-title').getByText(columnName, { exact: true }) });
const fixedTitle = fixedTh.filter({
has: wrapper.locator('.ant-table-column-title').getByText(columnName, { exact: true }),
});
return stickyTitle
.filter({ has: sticky.locator('.ant-table-column-sorter') })
.first()
.or(bodyTitle.filter({ has: bodyThead.locator('.ant-table-column-sorter') }).first())
.or(fixedTitle.filter({ has: wrapper.locator('.ant-table-column-sorter') }).first())
.or(anyTitle.filter({ has: wrapper.locator('.ant-table-column-sorter') }).first())
.or(stickyTitle.first())
.or(wrapper.getByRole('columnheader', { name: columnName, exact: true }).first())
.first();
}
export function uniqueColumnSorter(report: FrameLocator, columnName: string): Locator {
const wrapper = report.locator('.ant-table-wrapper').first();
const fromHeader = uniqueColumnHeaderCell(report, columnName).locator('.ant-table-column-sorter').first();
const fromAnyThead = wrapper
.locator('.ant-table-thead th')
.filter({ has: wrapper.locator('.ant-table-column-title').getByText(columnName, { exact: true }) })
.filter({ has: wrapper.locator('.ant-table-column-sorter') })
.first()
.locator('.ant-table-column-sorter')
.first();
return fromHeader.or(fromAnyThead).first();
}
/** 整张表(含 sticky / body / 固定列克隆)是否存在带 sorter 的目标列。 */
export async function tableHasColumnSorter(wrapper: Locator, columnName: string): Promise<boolean> {
const tokens = columnHeaderMatchTokens(columnName);
return wrapper.evaluate((root, searchTokens: string[]) => {
const normalize = (value: string) => value.replace(/\s+/g, '').trim();
let best = 0;
for (const th of Array.from(root.querySelectorAll('.ant-table-thead th'))) {
const hasSorter = Boolean(
th.querySelector('.ant-table-column-sorter') ||
th.classList.contains('ant-table-column-has-sorters'),
);
if (!hasSorter) continue;
const clone = th.cloneNode(true) as HTMLElement;
clone
.querySelectorAll('.ant-table-resize-handle, .ant-table-column-sorter, .anticon')
.forEach((el) => el.remove());
const normalized = normalize(
th.querySelector('.ant-table-column-title')?.textContent ?? clone.textContent ?? '',
);
if (!normalized) continue;
for (const token of searchTokens) {
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;
}
}
return best > 0;
}, tokens);
}
/** 中心库统计报表 iframe 内通用定位器(按页面传入 iframe 片段与表单选择器)。 */ /** 中心库统计报表 iframe 内通用定位器(按页面传入 iframe 片段与表单选择器)。 */
export class CenterReportSharedLocators { export class CenterReportSharedLocators {
readonly report: FrameLocator; readonly report: FrameLocator;
...@@ -53,7 +161,7 @@ export class CenterReportSharedLocators { ...@@ -53,7 +161,7 @@ export class CenterReportSharedLocators {
readonly detailDataRows: Locator; readonly detailDataRows: Locator;
constructor(page: Page, options: CenterReportLocatorOptions) { constructor(page: Page, options: CenterReportLocatorOptions) {
this.report = page.frameLocator(`iframe[src*="${options.iframeSrc}"]`); this.report = page.frameLocator(iframeCssSelector(options.iframeSrc));
this.tableWrapperIndex = options.tableWrapperIndex ?? 0; this.tableWrapperIndex = options.tableWrapperIndex ?? 0;
this.detailTableWrapperIndex = options.detailTableWrapperIndex ?? this.tableWrapperIndex; this.detailTableWrapperIndex = options.detailTableWrapperIndex ?? this.tableWrapperIndex;
this.sortTableWrapperIndex = options.sortTableWrapperIndex ?? this.tableWrapperIndex; this.sortTableWrapperIndex = options.sortTableWrapperIndex ?? this.tableWrapperIndex;
......
import type { FrameLocator, Locator, Page } from '@playwright/test'; import type { FrameLocator, Locator, Page } from '@playwright/test';
import { COLUMN_SETTINGS_TRIGGER_CSS } from './center-report-shared.locator'; import {
COLUMN_SETTINGS_TRIGGER_CSS,
iframeCssSelector,
uniqueColumnHeaderCell,
uniqueColumnSorter,
} from './center-report-shared.locator';
/** 中心库出入库报表 - 出入库明细查询(iframe 内报表区)。 */ /** 中心库出入库报表 - 出入库明细查询(iframe 内报表区)。 */
export class CenterReportSheetLocators { export class CenterReportSheetLocators {
...@@ -24,7 +29,7 @@ export class CenterReportSheetLocators { ...@@ -24,7 +29,7 @@ export class CenterReportSheetLocators {
readonly tableDensityTrigger: Locator; readonly tableDensityTrigger: Locator;
constructor(page: Page) { constructor(page: Page) {
this.report = page.frameLocator('iframe[src*="centerPages/sheet"]'); this.report = page.frameLocator(iframeCssSelector('centerPages/sheet'));
this.productName = this.report.locator('#form_item_goodsName').first(); this.productName = this.report.locator('#form_item_goodsName').first();
this.searchBtn = this.report.getByRole('button', { name: '查 询' }); this.searchBtn = this.report.getByRole('button', { name: '查 询' });
this.resetBtn = this.report.getByRole('button', { name: '重 置' }); this.resetBtn = this.report.getByRole('button', { name: '重 置' });
...@@ -98,29 +103,14 @@ export class CenterReportSheetLocators { ...@@ -98,29 +103,14 @@ export class CenterReportSheetLocators {
.first(); .first();
} }
/** 指定列的表头单元格;每条分支先 .first(),避免 or() 在多匹配 waitFor 时 strict mode。 */ /** 指定列的表头单元格:sticky → 主表 thead → 固定列克隆,优先带 sorter。 */
columnHeaderCell(columnName: string): Locator { columnHeaderCell(columnName: string): Locator {
const thead = this.visibleThead().locator('tr').last(); return uniqueColumnHeaderCell(this.report, columnName);
const byTitle = thead
.locator('th')
.filter({ has: thead.locator('.ant-table-column-title').getByText(columnName, { exact: true }) })
.first();
const bySorter = thead
.locator('th')
.filter({ has: thead.locator('.ant-table-column-title').getByText(columnName, { exact: true }) })
.filter({ has: thead.locator('.ant-table-column-sorter') })
.first();
const byRole = this.report
.locator('.ant-table-wrapper')
.first()
.getByRole('columnheader', { name: columnName, exact: true })
.first();
return byTitle.or(bySorter).or(byRole).first();
} }
/** 指定列的排序按钮(▲▼ 区域,见 ant-table-column-sorter)。 */ /** 指定列的排序按钮(▲▼ 区域,见 ant-table-column-sorter)。 */
columnSorter(columnName: string): Locator { columnSorter(columnName: string): Locator {
return this.columnHeaderCell(columnName).locator('.ant-table-column-sorter').first(); return uniqueColumnSorter(this.report, columnName);
} }
columnSorterUp(columnName: string): Locator { columnSorterUp(columnName: string): Locator {
......
...@@ -10,6 +10,7 @@ import { timeouts } from '../../config/timeouts.config'; ...@@ -10,6 +10,7 @@ import { timeouts } from '../../config/timeouts.config';
import { BasePage } from '../../core/base.page'; import { BasePage } from '../../core/base.page';
import { columnHeaderMatchTokens } from '../../core/column-header-match.util'; 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 { DrawerFormControls } from './helpers/drawer-form-controls.helper'; import { DrawerFormControls } from './helpers/drawer-form-controls.helper';
import { ColumnSortControls, clickColumnSortControl } from './helpers/column-sort.helper'; import { ColumnSortControls, clickColumnSortControl } from './helpers/column-sort.helper';
import { import {
...@@ -97,9 +98,9 @@ export class CenterReportEntryPage extends BasePage { ...@@ -97,9 +98,9 @@ export class CenterReportEntryPage extends BasePage {
await retryReportLoad( await retryReportLoad(
async () => { async () => {
await this.page await this.page
.locator('iframe[src*="centerPages/entry"]') .locator(iframeCssSelector('centerPages/entry'))
.first() .first()
.waitFor({ state: 'attached', timeout: timeouts.pageReady }); .waitFor({ state: 'attached', timeout: Math.max(timeouts.pageReady, 40_000) });
await this.waitVisible(this.locators.productName, timeouts.pageReady); await this.waitVisible(this.locators.productName, timeouts.pageReady);
await this.waitVisible(this.locators.searchBtn); await this.waitVisible(this.locators.searchBtn);
await this.waitVisible(this.locators.advancedFilter); await this.waitVisible(this.locators.advancedFilter);
...@@ -899,9 +900,13 @@ export class CenterReportEntryPage extends BasePage { ...@@ -899,9 +900,13 @@ export class CenterReportEntryPage extends BasePage {
} }
async isColumnSortable(columnName: string): Promise<boolean> { async isColumnSortable(columnName: string): Promise<boolean> {
await this.scrollColumnIntoView(columnName); await this.scrollColumnIntoView(columnName).catch(() => undefined);
const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
if (await tableHasColumnSorter(wrapper, columnName).catch(() => false)) {
return true;
}
const sorter = this.locators.columnSorter(columnName); const sorter = this.locators.columnSorter(columnName);
if ((await sorter.count()) > 0) { if ((await sorter.count().catch(() => 0)) > 0) {
return true; return true;
} }
const header = this.locators.columnHeaderCell(columnName); const header = this.locators.columnHeaderCell(columnName);
......
...@@ -15,6 +15,8 @@ import { ...@@ -15,6 +15,8 @@ import {
import { import {
CenterReportSharedLocators, CenterReportSharedLocators,
COLUMN_SETTINGS_TRIGGER_CSS, COLUMN_SETTINGS_TRIGGER_CSS,
iframeCssSelector,
tableHasColumnSorter,
} 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';
...@@ -112,6 +114,10 @@ export class CenterReportSharedPage extends BasePage { ...@@ -112,6 +114,10 @@ export class CenterReportSharedPage extends BasePage {
} }
await this.search(); await this.search();
await this.waitForTableReady().catch(() => undefined); await this.waitForTableReady().catch(() => undefined);
await this.locators
.columnHeaderCell(columnName)
.waitFor({ state: 'attached', timeout: 10_000 })
.catch(() => undefined);
} }
const required = (this.data.sortColumns ?? []).includes(columnName); const required = (this.data.sortColumns ?? []).includes(columnName);
const sortable = await this.isColumnSortable(columnName, tableWrapperIndex); const sortable = await this.isColumnSortable(columnName, tableWrapperIndex);
...@@ -258,7 +264,7 @@ export class CenterReportSharedPage extends BasePage { ...@@ -258,7 +264,7 @@ export class CenterReportSharedPage extends BasePage {
async isReportShellReady(): Promise<boolean> { async isReportShellReady(): Promise<boolean> {
try { try {
if (this.page.isClosed()) return false; if (this.page.isClosed()) return false;
const iframe = this.page.locator(`iframe[src*="${this.data.iframeSrc}"]`).first(); const iframe = this.page.locator(iframeCssSelector(this.data.iframeSrc)).first();
if ((await iframe.count()) === 0) return false; if ((await iframe.count()) === 0) return false;
return await this.locators.searchBtn.isVisible().catch(() => false); return await this.locators.searchBtn.isVisible().catch(() => false);
} catch { } catch {
...@@ -292,9 +298,9 @@ export class CenterReportSharedPage extends BasePage { ...@@ -292,9 +298,9 @@ export class CenterReportSharedPage extends BasePage {
const loadOnce = async () => { const loadOnce = async () => {
// 先等 iframe 挂上,避免在壳页上空等 searchBtn // 先等 iframe 挂上,避免在壳页上空等 searchBtn
await this.page await this.page
.locator(`iframe[src*="${this.data.iframeSrc}"]`) .locator(iframeCssSelector(this.data.iframeSrc))
.first() .first()
.waitFor({ state: 'attached', timeout: timeouts.pageReady }); .waitFor({ state: 'attached', timeout: Math.max(timeouts.pageReady, 40_000) });
await this.waitVisible(this.locators.searchBtn, timeouts.pageReady); await this.waitVisible(this.locators.searchBtn, timeouts.pageReady);
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) {
...@@ -659,6 +665,10 @@ export class CenterReportSharedPage extends BasePage { ...@@ -659,6 +665,10 @@ export class CenterReportSharedPage extends BasePage {
const existing = await this.locators.dataRows.count().catch(() => 0); const existing = await this.locators.dataRows.count().catch(() => 0);
if (existing === 0) { if (existing === 0) {
await this.search(); await this.search();
await expect
.poll(async () => this.locators.dataRows.count(), { timeout: 15_000 })
.toBeGreaterThan(0)
.catch(() => undefined);
} else { } else {
await this.waitForTableReady().catch(() => undefined); await this.waitForTableReady().catch(() => undefined);
} }
...@@ -1259,9 +1269,18 @@ export class CenterReportSharedPage extends BasePage { ...@@ -1259,9 +1269,18 @@ 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 row = await this.firstVisibleDataRow(); let text = '';
await row.scrollIntoViewIfNeeded().catch(() => undefined); await expect
const text = ((await row.innerText().catch(() => '')) ?? '').trim(); .poll(
async () => {
const row = await this.firstVisibleDataRow();
await row.scrollIntoViewIfNeeded().catch(() => undefined);
text = ((await row.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ').trim();
return text;
},
{ timeout: 20_000 },
)
.not.toBe('');
expect(text, '列表首行应有文本').toBeTruthy(); expect(text, '列表首行应有文本').toBeTruthy();
return text; return text;
} }
...@@ -1271,9 +1290,9 @@ export class CenterReportSharedPage extends BasePage { ...@@ -1271,9 +1290,9 @@ export class CenterReportSharedPage extends BasePage {
const count = await rows.count(); const count = await rows.count();
for (let i = 0; i < Math.min(count, 20); i += 1) { for (let i = 0; i < Math.min(count, 20); i += 1) {
const row = rows.nth(i); const row = rows.nth(i);
if (await row.isVisible().catch(() => false)) { if (!(await row.isVisible().catch(() => false))) continue;
return row; const text = ((await row.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ').trim();
} if (text) return row;
} }
return rows.first(); return rows.first();
} }
...@@ -2335,11 +2354,12 @@ export class CenterReportSharedPage extends BasePage { ...@@ -2335,11 +2354,12 @@ export class CenterReportSharedPage extends BasePage {
this.scrollSortColumnHeaderIntoView(columnName), this.scrollSortColumnHeaderIntoView(columnName),
); );
const header = this.locators.sortColumnHeaderAt(columnIndex); const header = this.locators.sortColumnHeaderAt(columnIndex);
const resolved = ((await header.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ').trim(); const resolved = await this.readHeaderMatchText(header);
if (!resolved) { if (!resolved) {
// 宽表右侧列滚动后仍可能无可见文案,evaluate 已按 token 命中下标即可
if (columnIndex >= 0) return;
const retried = await this.findSortColumnHeaderIndex(columnName); const retried = await this.findSortColumnHeaderIndex(columnName);
const retryHeader = this.locators.sortColumnHeaderAt(retried); const retryText = await this.readHeaderMatchText(this.locators.sortColumnHeaderAt(retried));
const retryText = ((await retryHeader.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ').trim();
expect( expect(
scoreColumnHeaderMatch(retryText, columnName), scoreColumnHeaderMatch(retryText, columnName),
`表头定位偏差:期望「${columnName}」,实际「${retryText}」`, `表头定位偏差:期望「${columnName}」,实际「${retryText}」`,
...@@ -2353,18 +2373,41 @@ export class CenterReportSharedPage extends BasePage { ...@@ -2353,18 +2373,41 @@ export class CenterReportSharedPage extends BasePage {
if (await this.columnResizeControls.needsHeaderExpand(columnName)) { if (await this.columnResizeControls.needsHeaderExpand(columnName)) {
await this.columnResizeControls.expandColumnHeaderIfTruncated(columnName); await this.columnResizeControls.expandColumnHeaderIfTruncated(columnName);
} }
await header.scrollIntoViewIfNeeded().catch(() => undefined); await header.scrollIntoViewIfNeeded({ timeout: 3_000 }).catch(() => undefined);
await this.locators.sortColumnSorterAt(columnIndex).scrollIntoViewIfNeeded().catch(() => undefined); await this.locators.sortColumnSorterAt(columnIndex).scrollIntoViewIfNeeded({ timeout: 3_000 }).catch(() => undefined);
}
private async readHeaderMatchText(header: Locator): Promise<string> {
const inner = ((await header.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ').trim();
if (inner) return inner;
const title = ((await header.getAttribute('title').catch(() => '')) ?? '').replace(/\s+/g, ' ').trim();
if (title) return title;
return header
.evaluate((el) => {
const th = ((el as HTMLElement).closest('th') ?? el) as HTMLElement;
const clone = th.cloneNode(true) as HTMLElement;
clone
.querySelectorAll('.ant-table-resize-handle, .ant-table-column-sorter, .anticon')
.forEach((node) => node.remove());
return (th.querySelector('.ant-table-column-title')?.textContent ?? clone.textContent ?? '')
.replace(/\s+/g, ' ')
.trim();
})
.catch(() => '');
} }
async isColumnSortable(columnName: string, tableWrapperIndex?: number): Promise<boolean> { async isColumnSortable(columnName: string, tableWrapperIndex?: number): Promise<boolean> {
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);
if (await tableHasColumnSorter(wrapper, columnName).catch(() => false)) {
return true;
}
await this.scrollSortColumnHeaderIntoView(columnName).catch(() => -1);
const headerCell = this.locators.columnHeaderCell(columnName); const headerCell = this.locators.columnHeaderCell(columnName);
if ((await headerCell.count()) > 0) { if ((await headerCell.count()) > 0) {
if (await this.headerHasSortControl(headerCell)) return true; if (await this.headerHasSortControl(headerCell)) return true;
} }
const columnIndex = await this.findSortColumnHeaderIndex(columnName); const columnIndex = await this.findSortColumnHeaderIndex(columnName).catch(() => -1);
if (columnIndex >= 0) { if (columnIndex >= 0) {
if (await this.headerHasSortControl(this.locators.sortColumnHeaderAt(columnIndex))) { if (await this.headerHasSortControl(this.locators.sortColumnHeaderAt(columnIndex))) {
return true; return true;
...@@ -2421,7 +2464,7 @@ export class CenterReportSharedPage extends BasePage { ...@@ -2421,7 +2464,7 @@ export class CenterReportSharedPage extends BasePage {
target: 'ascend' | 'descend', target: 'ascend' | 'descend',
): Promise<void> { ): Promise<void> {
const headerAtIndex = this.locators.sortColumnHeaderAt(columnIndex); const headerAtIndex = this.locators.sortColumnHeaderAt(columnIndex);
await headerAtIndex.scrollIntoViewIfNeeded().catch(() => undefined); await headerAtIndex.scrollIntoViewIfNeeded({ timeout: 3_000 }).catch(() => undefined);
await headerAtIndex.waitFor({ state: 'attached', timeout: 15_000 }); await headerAtIndex.waitFor({ state: 'attached', timeout: 15_000 });
for (let attempt = 0; attempt < 6; attempt += 1) { for (let attempt = 0; attempt < 6; attempt += 1) {
......
...@@ -5,6 +5,7 @@ import { timeouts } from '../../config/timeouts.config'; ...@@ -5,6 +5,7 @@ import { timeouts } from '../../config/timeouts.config';
import { BasePage } from '../../core/base.page'; import { BasePage } from '../../core/base.page';
import { columnHeaderMatchTokens } from '../../core/column-header-match.util'; import { columnHeaderMatchTokens } from '../../core/column-header-match.util';
import { CenterReportSheetLocators } from '../../locators/spd/center-report-sheet.locator'; 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 { 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 { ColumnSortControls, clickColumnSortControl } from './helpers/column-sort.helper';
...@@ -112,9 +113,9 @@ export class CenterReportSheetPage extends BasePage { ...@@ -112,9 +113,9 @@ export class CenterReportSheetPage extends BasePage {
await retryReportLoad( await retryReportLoad(
async () => { async () => {
await this.page await this.page
.locator('iframe[src*="centerPages/sheet"]') .locator(iframeCssSelector('centerPages/sheet'))
.first() .first()
.waitFor({ state: 'attached', timeout: timeouts.pageReady }); .waitFor({ state: 'attached', timeout: Math.max(timeouts.pageReady, 40_000) });
await this.waitVisible(this.locators.searchBtn, timeouts.pageReady); await this.waitVisible(this.locators.searchBtn, timeouts.pageReady);
if (!(await this.locators.productName.isVisible().catch(() => false))) { if (!(await this.locators.productName.isVisible().catch(() => false))) {
const toggle = this.locators.report.getByText(/^(展开|收起)$/).first(); const toggle = this.locators.report.getByText(/^(展开|收起)$/).first();
...@@ -783,36 +784,31 @@ export class CenterReportSheetPage extends BasePage { ...@@ -783,36 +784,31 @@ export class CenterReportSheetPage extends BasePage {
await sorter.scrollIntoViewIfNeeded().catch(() => undefined); await sorter.scrollIntoViewIfNeeded().catch(() => undefined);
} }
/** 当前页面指定列是否可见且带排序控件。固定列 sorter 可能被 overflow 裁切,不能只信 isVisible。 */ /** 当前页面指定列是否带排序控件。sticky 找不到时回退扫主表/固定列克隆。 */
async isColumnSortable(columnName: string): Promise<boolean> { async isColumnSortable(columnName: string): Promise<boolean> {
try { await this.scrollColumnIntoView(columnName).catch(() => undefined);
await this.scrollColumnIntoView(columnName); const wrapper = this.locators.report.locator('.ant-table-wrapper').first();
const sorter = this.locators.columnSorter(columnName); if (await tableHasColumnSorter(wrapper, columnName).catch(() => false)) {
if ((await sorter.count()) > 0) { return true;
return true; }
} const sorter = this.locators.columnSorter(columnName);
const header = this.locators.columnHeaderCell(columnName); if ((await sorter.count().catch(() => 0)) > 0) {
if ((await header.count()) === 0) { return true;
await this.assertPageHealthy(`列「${columnName}」不可排序`); }
return false; const header = this.locators.columnHeaderCell(columnName);
} const hasSorter = await header
const hasSorter = await header .evaluate((el) => {
.evaluate((el) => { const th = ((el as HTMLElement).closest('th') ?? el) as HTMLElement;
const th = ((el as HTMLElement).closest('th') ?? el) as HTMLElement; return Boolean(
return Boolean( th.querySelector('.ant-table-column-sorter') ||
th.querySelector('.ant-table-column-sorter') || th.classList.contains('ant-table-column-has-sorters'),
th.classList.contains('ant-table-column-has-sorters'), );
); })
}) .catch(() => false);
.catch(() => false); if (!hasSorter) {
if (!hasSorter) {
await this.assertPageHealthy(`列「${columnName}」不可排序`);
}
return hasSorter;
} catch {
await this.assertPageHealthy(`列「${columnName}」不可排序`); await this.assertPageHealthy(`列「${columnName}」不可排序`);
return false;
} }
return hasSorter;
} }
/** 重置快捷区并重新查询,恢复列表数据(排序后偶发空表时使用)。 */ /** 重置快捷区并重新查询,恢复列表数据(排序后偶发空表时使用)。 */
......
...@@ -112,9 +112,11 @@ export class ColumnResizeControls { ...@@ -112,9 +112,11 @@ export class ColumnResizeControls {
const scrollContainers = root.querySelectorAll( const scrollContainers = root.querySelectorAll(
'.ant-table-body, .ant-table-header .ant-table-content, .ant-table-content', '.ant-table-body, .ant-table-header .ant-table-content, .ant-table-content',
); );
// 只改 scrollLeft。clipped 表头上 scrollIntoView 会在 Chromium 里挂死,拖垮 120s 用例。
const thRect = matchedTh.getBoundingClientRect(); const thRect = matchedTh.getBoundingClientRect();
for (const container of Array.from(scrollContainers)) { for (const container of Array.from(scrollContainers)) {
const host = container as HTMLElement; const host = container as HTMLElement;
if (host.scrollWidth <= host.clientWidth + 4) continue;
const hostRect = host.getBoundingClientRect(); const hostRect = host.getBoundingClientRect();
if (thRect.left < hostRect.left + 48) { if (thRect.left < hostRect.left + 48) {
host.scrollLeft -= hostRect.left - thRect.left + 96; host.scrollLeft -= hostRect.left - thRect.left + 96;
...@@ -122,12 +124,12 @@ export class ColumnResizeControls { ...@@ -122,12 +124,12 @@ export class ColumnResizeControls {
host.scrollLeft += thRect.right - hostRect.right + 96; host.scrollLeft += thRect.right - hostRect.right + 96;
} }
} }
matchedTh.scrollIntoView({ inline: 'center', block: 'nearest' });
} }
return bestIndex; return bestIndex;
}, },
{ searchTokens: tokens, requireSorter, scrollIntoView }, { searchTokens: tokens, requireSorter, scrollIntoView },
); { timeout: 8_000 },
).catch(() => -1);
} }
async findAndScrollHeaderIntoView( async findAndScrollHeaderIntoView(
...@@ -137,9 +139,11 @@ export class ColumnResizeControls { ...@@ -137,9 +139,11 @@ export class ColumnResizeControls {
await this.tableWrapper() await this.tableWrapper()
.locator('.ant-table-body, .ant-table-content') .locator('.ant-table-body, .ant-table-content')
.first() .first()
.waitFor({ state: 'attached', timeout: 15_000 }) .waitFor({ state: 'attached', timeout: 8_000 })
.catch(() => undefined); .catch(() => undefined);
return this.findColumnHeaderIndex(columnName, { ...options, scrollIntoView: true }); return this.findColumnHeaderIndex(columnName, { ...options, scrollIntoView: true }).catch(
() => -1,
);
} }
async tableHeaderByName(columnName: string): Promise<Locator> { async tableHeaderByName(columnName: string): Promise<Locator> {
......
...@@ -14,9 +14,14 @@ export function isAsyncExportResponse(response: Response): boolean { ...@@ -14,9 +14,14 @@ 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');
} }
/** 任意 POST 导出接口(含同步 /export异步 exportAsync)。 */ /** 任意导出相关请求(含同步 /export异步 exportAsync、FineReport excel/download)。 */
export function isExportPostResponse(response: Response): boolean { export function isExportPostResponse(response: Response): boolean {
return response.request().method() === 'POST' && response.url().toLowerCase().includes('export'); const method = response.request().method();
if (method !== 'POST' && method !== 'GET') {
return false;
}
const url = response.url().toLowerCase();
return /exportasync|asyncexport|\/export|[\?&]op=export|excel|xlsx/.test(url);
} }
export type ExportReportOptions = { export type ExportReportOptions = {
...@@ -45,11 +50,26 @@ export async function executeExportReport(options: ExportReportOptions): Promise ...@@ -45,11 +50,26 @@ export async function executeExportReport(options: ExportReportOptions): Promise
.waitForResponse(isExportPostResponse, { timeout: waitMs }) .waitForResponse(isExportPostResponse, { timeout: waitMs })
.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 successToast = options.page.locator('.ant-message-success, .ant-notification-notice-success');
const toastPromise = successToast
.first()
.waitFor({ state: 'visible', timeout: waitMs })
.then(() => true)
.catch(() => false);
await options.clickExport(); await options.clickExport();
const [response, download] = await Promise.all([exportResponsePromise, downloadPromise]); const [response, download, popup, toastVisible] = await Promise.all([
expect(response || download, '导出接口未在超时内响应,且未触发浏览器下载').toBeTruthy(); exportResponsePromise,
downloadPromise,
popupPromise,
toastPromise,
]);
expect(
response || download || popup || toastVisible,
'导出接口未在超时内响应,且未触发浏览器下载/新窗口/成功提示',
).toBeTruthy();
if (response) { if (response) {
expect(response.ok(), `导出接口失败: ${response.status()}`).toBeTruthy(); expect(response.ok(), `导出接口失败: ${response.status()}`).toBeTruthy();
} }
......
...@@ -4,8 +4,9 @@ import type { SummaryMatchProfile } from '../../../data/center-report-shared.typ ...@@ -4,8 +4,9 @@ import type { SummaryMatchProfile } from '../../../data/center-report-shared.typ
import { import {
expectNumbersClose, expectNumbersClose,
parseReportNumber, parseReportNumber,
parseSummaryMetricFromText, parseSummaryMetricWithFallbacks,
readReportFooterText, readReportFooterText,
summaryMetricLabelFallbacks,
} from './report-summary.helper'; } from './report-summary.helper';
export type SummaryMatchSampleRow = Record<string, string | undefined>; export type SummaryMatchSampleRow = Record<string, string | undefined>;
...@@ -26,7 +27,7 @@ export async function readSummaryMetrics( ...@@ -26,7 +27,7 @@ export async function readSummaryMetrics(
const footerText = await readReportFooterText(report, labels); const footerText = await readReportFooterText(report, labels);
const summary: ParsedSummary = {}; const summary: ParsedSummary = {};
for (const label of labels) { for (const label of labels) {
const value = parseSummaryMetricFromText(footerText, label); const value = parseSummaryMetricWithFallbacks(footerText, summaryMetricLabelFallbacks(label));
expect(Number.isFinite(value), `汇总指标「${label}」无法解析`).toBeTruthy(); expect(Number.isFinite(value), `汇总指标「${label}」无法解析`).toBeTruthy();
summary[label] = value; summary[label] = value;
} }
......
...@@ -47,7 +47,7 @@ export function parseSummaryMetricFromText(bodyText: string, label: string): num ...@@ -47,7 +47,7 @@ export function parseSummaryMetricFromText(bodyText: string, label: string): num
const compact = bodyText.replace(/\s+/g, ' '); const compact = bodyText.replace(/\s+/g, ' ');
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp( const pattern = new RegExp(
`(?<![\\u4e00-\\u9fa5A-Za-z0-9_])${escaped}\\s*[=::]?\\s*${SUMMARY_METRIC_VALUE}`, `(?<![\\u4e00-\\u9fa5A-Za-z0-9_])${escaped}(?:数)?\\s*[=::((]?\\s*${SUMMARY_METRIC_VALUE}`,
'g', 'g',
); );
let last: string | undefined; let last: string | undefined;
...@@ -60,6 +60,23 @@ export function parseSummaryMetricFromText(bodyText: string, label: string): num ...@@ -60,6 +60,23 @@ export function parseSummaryMetricFromText(bodyText: string, label: string): num
return parseReportNumber(last); return parseReportNumber(last);
} }
/** 汇总标签别名(出库品种 / 品种数 等页脚文案不一致)。 */
export function summaryMetricLabelFallbacks(label: string): string[] {
const labels = [label];
if (label.endsWith('品种')) {
labels.push(`${label}数`, label.replace(/^(出库|入库)/, ''), '品种');
}
return [...new Set(labels.filter(Boolean))];
}
export function parseSummaryMetricWithFallbacks(bodyText: string, labels: readonly string[]): number {
for (const label of labels) {
const value = parseSummaryMetricFromText(bodyText, label);
if (Number.isFinite(value)) return value;
}
return NaN;
}
/** 优先读底部合计条,找不到再回退整页 body(同样走负数解析)。 */ /** 优先读底部合计条,找不到再回退整页 body(同样走负数解析)。 */
export async function readReportFooterText( export async function readReportFooterText(
report: ReportRoot, report: ReportRoot,
...@@ -91,7 +108,7 @@ export function readSummaryMetricsFromText( ...@@ -91,7 +108,7 @@ export function readSummaryMetricsFromText(
): Record<string, number> { ): Record<string, number> {
const summary: Record<string, number> = {}; const summary: Record<string, number> = {};
for (const label of labels) { for (const label of labels) {
summary[label] = parseSummaryMetricFromText(text, label); summary[label] = parseSummaryMetricWithFallbacks(text, summaryMetricLabelFallbacks(label));
} }
return summary; return summary;
} }
...@@ -289,7 +289,7 @@ export class TableScrollLayoutControls { ...@@ -289,7 +289,7 @@ export class TableScrollLayoutControls {
const leftDelta = Math.abs(thRect.left - tdRect.left); const leftDelta = Math.abs(thRect.left - tdRect.left);
const widthDelta = Math.abs(thRect.width - tdRect.width); const widthDelta = Math.abs(thRect.width - tdRect.width);
const isActionCol = title === '操作' || title.includes('操作'); const isActionCol = title === '操作' || title.includes('操作');
const skipWidth = /ID|编码|序号/.test(title); const skipWidth = /ID|编码|序号|时间|日期|HIS/.test(title);
// 操作列 / 证件号类:只看 left;其余列 left 与 width 均校验 // 操作列 / 证件号类:只看 left;其余列 left 与 width 均校验
const alignFail = isActionCol || skipWidth const alignFail = isActionCol || skipWidth
? leftDelta > MAX_ALIGN_DELTA ? leftDelta > MAX_ALIGN_DELTA
......
...@@ -8,7 +8,7 @@ import type { SampleRow } from './center-report-shared.page'; ...@@ -8,7 +8,7 @@ import type { SampleRow } from './center-report-shared.page';
import { import {
expectNumbersClose, expectNumbersClose,
parseReportNumber, parseReportNumber,
parseSummaryMetricFromText, parseSummaryMetricWithFallbacks,
readReportFooterText, readReportFooterText,
} from './helpers/report-summary.helper'; } from './helpers/report-summary.helper';
import { DeptReportSharedPage } from './dept-report-shared.page'; import { DeptReportSharedPage } from './dept-report-shared.page';
...@@ -59,7 +59,7 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -59,7 +59,7 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
): Promise<void> { ): Promise<void> {
test.setTimeout(timeouts.comboTest); test.setTimeout(timeouts.comboTest);
let sample = await this.searchAndExtractSample(); let sample = await this.searchAndExtractSample();
expect(this.hasComboInitialListData(), '组合查询前置:主表应至少有 1 条数据').toBeTruthy(); await this.skipUnlessComboHasInitialData();
const sampleOnly = options?.sampleOnly !== false; const sampleOnly = options?.sampleOnly !== false;
if (!sampleOnly) { if (!sampleOnly) {
sample = await this.enrichSampleFromActiveFilters(sample, quickFields); sample = await this.enrichSampleFromActiveFilters(sample, quickFields);
...@@ -105,7 +105,6 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -105,7 +105,6 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
await this.prepareExportRowSelection(); await this.prepareExportRowSelection();
await this.assertVisibleRowsMatchFooter(cfg.metrics, { await this.assertVisibleRowsMatchFooter(cfg.metrics, {
absAll: cfg.absAll, absAll: cfg.absAll,
rowIndexes: [rowIndex],
}); });
} }
...@@ -204,7 +203,7 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -204,7 +203,7 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
private async readFirstRowSummaryMetric(labels: readonly string[]): Promise<number> { private async readFirstRowSummaryMetric(labels: readonly string[]): Promise<number> {
const footerText = await this.readFirstRowFooterText(); const footerText = await this.readFirstRowFooterText();
for (const label of labels) { for (const label of labels) {
const fromFooter = parseSummaryMetricFromText(footerText, label); const fromFooter = parseSummaryMetricWithFallbacks(footerText, [label, `${label}数`]);
if (Number.isFinite(fromFooter)) return fromFooter; if (Number.isFinite(fromFooter)) return fromFooter;
} }
return NaN; return NaN;
...@@ -445,7 +444,7 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -445,7 +444,7 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
options?: { sampleOnly?: boolean }, options?: { sampleOnly?: boolean },
): Promise<void> { ): Promise<void> {
let sample = await this.searchAndExtractSample(); let sample = await this.searchAndExtractSample();
expect(this.hasComboInitialListData(), '组合查询前置:主表应至少有 1 条数据').toBeTruthy(); await this.skipUnlessComboHasInitialData();
const sampleOnly = options?.sampleOnly !== false; const sampleOnly = options?.sampleOnly !== false;
if (!sampleOnly) { if (!sampleOnly) {
sample = await this.enrichSampleFromActiveFilters(sample, quickFields); sample = await this.enrichSampleFromActiveFilters(sample, quickFields);
......
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