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