Commit 79a08ff4 authored by liguangyu06's avatar liguangyu06
Browse files

修复报表表头 strict mode、列设置关面板超时及汇总/排序定位,避免夜间全量被脚本误杀。

parent 35fe36af
...@@ -6,7 +6,6 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [ ...@@ -6,7 +6,6 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [
['院区名称', '院区'], ['院区名称', '院区'],
['27位医保编码', '医保编码', '医保码'], ['27位医保编码', '医保编码', '医保码'],
['医保单件名称', '单件名称', '医保名称'], ['医保单件名称', '单件名称', '医保名称'],
['数量', '净入数量', '净出数量', '入库数量', '出库数量', '成本数量'],
['HIS编码', 'HIS码', 'His编码'], ['HIS编码', 'HIS码', 'His编码'],
['结算单标签', '标签'], ['结算单标签', '标签'],
['产品编号', '产品编码', '物资编码', '商品编码'], ['产品编号', '产品编码', '物资编码', '商品编码'],
...@@ -18,10 +17,7 @@ function aliasTokensFor(columnName: string): string[] { ...@@ -18,10 +17,7 @@ function aliasTokensFor(columnName: string): string[] {
const normalized = columnName.replace(/\s+/g, '').trim(); const normalized = columnName.replace(/\s+/g, '').trim();
const out: string[] = []; const out: string[] = [];
for (const group of COLUMN_HEADER_ALIAS_GROUPS) { for (const group of COLUMN_HEADER_ALIAS_GROUPS) {
const hit = group.some((alias) => { const hit = group.some((alias) => alias.replace(/\s+/g, '') === normalized);
const a = alias.replace(/\s+/g, '');
return a === normalized || a.includes(normalized) || normalized.includes(a);
});
if (hit) { if (hit) {
for (const alias of group) { for (const alias of group) {
const a = alias.replace(/\s+/g, '').trim(); const a = alias.replace(/\s+/g, '').trim();
......
...@@ -110,6 +110,15 @@ export const anaChgData: CenterReportPageData & { ...@@ -110,6 +110,15 @@ export const anaChgData: CenterReportPageData & {
comboSamplePickKeys: ['field', 'productName', 'spec', 'supplierName', 'field2', 'consumableType'], comboSamplePickKeys: ['field', 'productName', 'spec', 'supplierName', 'field2', 'consumableType'],
comboSampleMaxRowScan: 5, comboSampleMaxRowScan: 5,
drawerFields: anaChgComboDrawerFields, drawerFields: anaChgComboDrawerFields,
firstRowSummaryMatch: {
metrics: [
{ key: 'field8', label: '选中总数量', summaryLabels: ['选中总数量'] },
{ key: 'field9', label: '选中总差额', summaryLabels: ['选中总差额'] },
],
requiredKey: 'productName',
fields: anaChgComboQuickFields,
footerKeywords: ['选中总数量', '选中总差额', '合计'],
},
}; };
export const anaChgSortCases = buildSortColumnCases('STAT-ACH', anaChgSortColumns, 10); export const anaChgSortCases = buildSortColumnCases('STAT-ACH', anaChgSortColumns, 10);
...@@ -24,7 +24,6 @@ export const centerReportNetOutSortColumns = [ ...@@ -24,7 +24,6 @@ export const centerReportNetOutSortColumns = [
'科室名称', '科室名称',
'产品编码', '产品编码',
'产品名称', '产品名称',
'规格型号',
'单位', '单位',
'耗材类型', '耗材类型',
'厂家', '厂家',
......
...@@ -26,6 +26,8 @@ export type CenterReportPageData = { ...@@ -26,6 +26,8 @@ export type CenterReportPageData = {
sortColumns?: readonly string[]; sortColumns?: readonly string[];
sortCasePrefix?: string; sortCasePrefix?: string;
sortCaseStartIndex?: number; sortCaseStartIndex?: number;
/** 列排序前需勾选的维度(如「产品批号」依赖「区分批号」) */
sortColumnPrerequisites?: Readonly<Record<string, readonly string[]>>;
mockNotExistProduct: string; mockNotExistProduct: string;
mockNotExistCode?: string; mockNotExistCode?: string;
mockNotExistMaterial?: string; mockNotExistMaterial?: string;
......
...@@ -44,6 +44,7 @@ export const costFinInData: CenterReportPageData = { ...@@ -44,6 +44,7 @@ export const costFinInData: CenterReportPageData = {
hasExpandRow: false, hasExpandRow: false,
hasBatchPrint: false, hasBatchPrint: false,
hasToolbarPrint: true, hasToolbarPrint: true,
printRequiresDetailSelection: true,
hasFilterToggle: false, hasFilterToggle: false,
exportTaskName: '财务入库单', exportTaskName: '财务入库单',
exportDirectDownload: false, exportDirectDownload: false,
......
...@@ -66,7 +66,6 @@ export const costFinancePsiSortColumns = [ ...@@ -66,7 +66,6 @@ export const costFinancePsiSortColumns = [
'产品名称', '产品名称',
'规格型号', '规格型号',
'产品编号', '产品编号',
'生产厂家',
'耗材类型', '耗材类型',
'单位', '单位',
'期初数量', '期初数量',
......
...@@ -37,11 +37,11 @@ export const costProductRankDetailHeaders = ['科室名称', '成本数量', ' ...@@ -37,11 +37,11 @@ export const costProductRankDetailHeaders = ['科室名称', '成本数量', '
export const costProductRankComboQuickFields: readonly DeptQuickFieldDef[] = [ export const costProductRankComboQuickFields: readonly DeptQuickFieldDef[] = [
{ key: 'statMonth', label: '统计月份', type: 'date' }, { key: 'statMonth', label: '统计月份', type: 'date' },
{ key: 'campus', label: '院区', type: 'select' }, { key: 'campus', label: '院区', type: 'select' },
{ key: 'consumableType', label: '耗材类型', type: 'select' },
{ key: 'consumableCategory', label: '耗材分类', type: 'select' },
]; ];
export const costProductRankComboDrawerFields: readonly CenterReportDrawerField[] = [ export const costProductRankComboDrawerFields: readonly CenterReportDrawerField[] = [
{ key: 'consumableType', type: 'select', label: '耗材类型' },
{ key: 'consumableCategory', type: 'select', label: '耗材分类' },
{ key: 'isKeySupervised', type: 'select', label: '是否重点监管' }, { key: 'isKeySupervised', type: 'select', label: '是否重点监管' },
]; ];
......
...@@ -51,7 +51,7 @@ export const deptReportOutData: CenterReportPageData = { ...@@ -51,7 +51,7 @@ export const deptReportOutData: CenterReportPageData = {
exportDirectDownload: false, exportDirectDownload: false,
exportSelectFirstRow: false, exportSelectFirstRow: false,
exportQueryFromSample: true, exportQueryFromSample: true,
comboSampleScrollColumns: ['结算方式', '单据形态', '批号', '科室名称', '单号', '进院供应商名称'], comboSampleScrollColumns: ['产品编号', '产品名称', '结算方式', '单据形态', '批号', '科室名称', '单号', '进院供应商名称'],
comboSamplePickKeys: ['productName', 'accountDate', 'billType', 'deptName', 'dept', 'orderNo', 'settlement', 'billMode', 'batchNo'], comboSamplePickKeys: ['productName', 'accountDate', 'billType', 'deptName', 'dept', 'orderNo', 'settlement', 'billMode', 'batchNo'],
comboSampleFieldAliases: { dept: 'deptName' }, comboSampleFieldAliases: { dept: 'deptName' },
comboSampleMaxRowScan: 5, comboSampleMaxRowScan: 5,
......
...@@ -38,6 +38,9 @@ export const deptReportTurnoverData: CenterReportPageData = { ...@@ -38,6 +38,9 @@ export const deptReportTurnoverData: CenterReportPageData = {
sortCasePrefix: 'STAT-DTR', sortCasePrefix: 'STAT-DTR',
sortCaseStartIndex: 6, sortCaseStartIndex: 6,
mockNotExistProduct: '__NOT_EXIST_PRODUCT__99999', mockNotExistProduct: '__NOT_EXIST_PRODUCT__99999',
horizontalScrollAnchorColumn: '产品编号',
horizontalScrollTargetColumn: '库存周转率',
horizontalScrollDragDelta: 320,
paginationPageSizeFrom: '50 条/页', paginationPageSizeFrom: '50 条/页',
paginationPageSizeTo: '100 条/页', paginationPageSizeTo: '100 条/页',
sampleRowIndex: 0, sampleRowIndex: 0,
......
...@@ -33,7 +33,7 @@ export const invoiceSettleComboAssertKeys = ['supplierName', 'field4'] as const; ...@@ -33,7 +33,7 @@ export const invoiceSettleComboAssertKeys = ['supplierName', 'field4'] as const;
export const invoiceSettleDomainQuickFields: readonly DeptQuickFieldDef[] = [ export const invoiceSettleDomainQuickFields: readonly DeptQuickFieldDef[] = [
{ key: 'field', label: '结算月份', type: 'date' }, { key: 'field', label: '结算月份', type: 'date' },
{ key: 'supplierName', label: '进院供应商名称', type: 'select' }, { key: 'supplierName', label: '进院供应商', type: 'select' },
]; ];
export const invoiceSettleDomainAssertKeys = ['supplierName', 'field4'] as const; export const invoiceSettleDomainAssertKeys = ['supplierName', 'field4'] as const;
...@@ -84,8 +84,8 @@ export const invoiceSettleData: CenterReportPageData & { ...@@ -84,8 +84,8 @@ export const invoiceSettleData: CenterReportPageData & {
exportQueryFromSample: true, exportQueryFromSample: true,
columnSettingsToggleCandidates: ['线上线下'], columnSettingsToggleCandidates: ['线上线下'],
comboSampleScrollColumns: ['院区名称'], comboSampleScrollColumns: ['结算月份', '进院供应商', '院区名称'],
comboSamplePickKeys: ['supplierName', 'field4', 'isVolume', 'field2'], comboSamplePickKeys: ['field', 'supplierName', 'field4', 'isVolume', 'field2'],
drawerFields: invoiceSettleComboDrawerFields, drawerFields: invoiceSettleComboDrawerFields,
}; };
......
...@@ -103,7 +103,7 @@ export const polConsumerData: CenterReportPageData & { ...@@ -103,7 +103,7 @@ export const polConsumerData: CenterReportPageData & {
}, },
horizontalScrollAnchorColumn: '消耗日期', horizontalScrollAnchorColumn: '消耗日期',
horizontalScrollTargetColumn: '带量项目合同号', horizontalScrollTargetColumn: '完成数量',
horizontalScrollDragDelta: 320, horizontalScrollDragDelta: 320,
mockNotExistProduct: '__NOT_EXIST_PRODUCT__99999', mockNotExistProduct: '__NOT_EXIST_PRODUCT__99999',
mockFieldLabel: '带量项目', mockFieldLabel: '带量项目',
......
...@@ -55,6 +55,9 @@ export const reconPurchaseEntryData: CenterReportPageData = { ...@@ -55,6 +55,9 @@ export const reconPurchaseEntryData: CenterReportPageData = {
sortColumns: reconPurchaseEntrySortColumns, sortColumns: reconPurchaseEntrySortColumns,
sortCasePrefix: 'STAT-RPE', sortCasePrefix: 'STAT-RPE',
sortCaseStartIndex: 16, sortCaseStartIndex: 16,
sortColumnPrerequisites: {
产品批号: ['区分批号'],
},
hasAdvancedFilter: true, hasAdvancedFilter: true,
hasExport: true, hasExport: true,
hasExpandRow: false, hasExpandRow: false,
...@@ -69,7 +72,8 @@ export const reconPurchaseEntryData: CenterReportPageData = { ...@@ -69,7 +72,8 @@ export const reconPurchaseEntryData: CenterReportPageData = {
horizontalScrollAnchorColumn: '产品名称', horizontalScrollAnchorColumn: '产品名称',
horizontalScrollTargetColumn: '产品类型', horizontalScrollTargetColumn: '产品类型',
horizontalScrollDragDelta: 320, horizontalScrollDragDelta: 320,
comboSamplePickKeys: ['productName', 'statDate', 'supplierName', 'subSupplierName'], comboSamplePickKeys: ['productName', 'statDate', 'supplierName', 'subSupplierName', 'inboundQty'],
comboSampleScrollColumns: ['采购入库数量', '产品类型'],
drawerFields: reconPurchaseEntryComboDrawerFields, drawerFields: reconPurchaseEntryComboDrawerFields,
firstRowSummaryMatch: { firstRowSummaryMatch: {
metrics: [ metrics: [
......
...@@ -72,7 +72,7 @@ export const warnDetainData: CenterReportPageData & { ...@@ -72,7 +72,7 @@ export const warnDetainData: CenterReportPageData & {
}, },
horizontalScrollAnchorColumn: '院区名称', horizontalScrollAnchorColumn: '院区名称',
horizontalScrollTargetColumn: '院区名称', horizontalScrollTargetColumn: '待发数量',
horizontalScrollDragDelta: 320, horizontalScrollDragDelta: 320,
mockNotExistProduct: '__NOT_EXIST_PRODUCT__99999', mockNotExistProduct: '__NOT_EXIST_PRODUCT__99999',
mockFieldLabel: '产品名称', mockFieldLabel: '产品名称',
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
| 任务 | 套件 | 建议 cron | 说明 | | 任务 | 套件 | 建议 cron | 说明 |
|------|------|-----------|------| |------|------|-----------|------|
| `spd-ui-smoke` | `smoke` | `H 8-18 * * 1-5` | 工作时段冒烟:全部报表模块已打 `@smoke` 的用例 | | `spd-ui-smoke` | `smoke` | `H 8-18 * * 1-5` | 工作时段冒烟:全部报表模块已打 `@smoke` 的用例 |
| `spd-ui-nightly` | `nightly` | `H 2 * * *` | 夜间主 spec 回归,不含 `@full`、不含调试 `*.stat-*.spec.ts` | | `spd-ui-nightly` / `SPD` | `nightly` / `full` | `H 23 * * 1,3,5` | 周一、三、五 **23 点** 回归(Jenkins 任务 `SPD` 当前按此时段跑 full) |
| `spd-ui-full` | `full` | `H 3 * * 0` | 周末含全列排序,最慢 | | `spd-ui-full` | `full` | `H 3 * * 0` | 周末含全列排序,最慢 |
同一套 `Jenkinsfile` 用参数 `SUITE` 区分即可;定时任务在 Jenkins 里「Build periodically」分别写 cron。 同一套 `Jenkinsfile` 用参数 `SUITE` 区分即可;定时任务在 Jenkins 里「Build periodically」分别写 cron。
...@@ -84,7 +84,7 @@ npm run test:spd:shard ...@@ -84,7 +84,7 @@ npm run test:spd:shard
| Jenkins 任务名 | 默认 `SUITE` | Build periodically | 建议参数 | | Jenkins 任务名 | 默认 `SUITE` | Build periodically | 建议参数 |
|----------------|--------------|--------------------|----------| |----------------|--------------|--------------------|----------|
| `spd-ui-smoke` | `smoke` | `H 8-18 * * 1-5` | `WORKERS=3` `SHARDS=1` | | `spd-ui-smoke` | `smoke` | `H 8-18 * * 1-5` | `WORKERS=3` `SHARDS=1` |
| `spd-ui-nightly` | `nightly` | `H 2 * * *` | `WORKERS=2` `SHARDS=2` | | `spd-ui-nightly` / `SPD` | `nightly` / `full` | `H 23 * * 1,3,5` | 周一、三、五 23 点;`WORKERS=2` `SHARDS=2` |
| `spd-ui-full` | `full` | `H 3 * * 0` | `WORKERS=2` `SHARDS=2`,勾选 `RUN_FULL` | | `spd-ui-full` | `full` | `H 3 * * 0` | `WORKERS=2` `SHARDS=2`,勾选 `RUN_FULL` |
每个任务: 每个任务:
......
...@@ -88,19 +88,41 @@ export class CenterReportEntryLocators { ...@@ -88,19 +88,41 @@ export class CenterReportEntryLocators {
} }
tableHeaderCells(): Locator { tableHeaderCells(): Locator {
return this.report.locator('.ant-table-wrapper').first().locator('.ant-table-thead tr').last().locator('th'); return this.visibleThead().locator('tr').last().locator('th');
}
/**
* 可见 sticky 表头。宽表会在 body / fixed 区再克隆 thead,
* `.ant-table-thead tr`.last() 会命中测宽空行或克隆列,waitFor 触发 strict mode。
*/
visibleThead(): Locator {
const wrapper = this.report.locator('.ant-table-wrapper').first();
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();
} }
/** 指定列的表头单元格;每条分支先 .first(),避免 or() 在多匹配 waitFor 时 strict mode。 */
columnHeaderCell(columnName: string): Locator { columnHeaderCell(columnName: string): Locator {
const thead = this.report.locator('.ant-table-wrapper').first().locator('.ant-table-thead tr').last(); const thead = this.visibleThead().locator('tr').last();
const byTitle = thead const byTitle = thead
.locator('th:visible') .locator('th')
.filter({ .filter({ has: thead.locator('.ant-table-column-title').getByText(columnName, { exact: true }) })
has: this.report.locator('.ant-table-column-title').getByText(columnName, { exact: true }), .first();
}) const bySorter = thead
.filter({ has: this.report.locator('.ant-table-column-sorter') }); .locator('th')
const byRole = this.report.getByRole('columnheader', { name: columnName, exact: true }); .filter({ has: thead.locator('.ant-table-column-title').getByText(columnName, { exact: true }) })
return byTitle.or(byRole).first(); .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 {
...@@ -146,14 +168,21 @@ export class CenterReportEntryLocators { ...@@ -146,14 +168,21 @@ export class CenterReportEntryLocators {
} }
subTableRow(rowIndex = 0): Locator { subTableRow(rowIndex = 0): Locator {
return this.expandedRow.locator('.ant-table tbody tr').nth(rowIndex); return this.expandedRow
.locator('.ant-table tbody tr.ant-table-row:not(.ant-table-measure-row)')
.nth(rowIndex);
} }
subTableViewButton(rowIndex = 0): Locator { subTableViewButton(rowIndex = 0): Locator {
const row = this.subTableRow(rowIndex); const row = this.subTableRow(rowIndex);
return row const fixedRightRow = this.expandedRow
.getByRole('button', { name: '查看' }) .locator('.ant-table-fixed-right tbody tr.ant-table-row:not(.ant-table-measure-row)')
.or(row.getByRole('link', { name: '查看' })) .nth(rowIndex);
.first(); const inRow = (host: Locator) =>
host
.getByRole('button', { name: /查\s*看/ })
.or(host.getByRole('link', { name: /查\s*看/ }))
.or(host.locator('a, button, span, .ant-btn').filter({ hasText: /查\s*看/ }));
return inRow(fixedRightRow).or(inRow(row)).first();
} }
} }
...@@ -39,7 +39,6 @@ export class CenterReportSharedLocators { ...@@ -39,7 +39,6 @@ export class CenterReportSharedLocators {
readonly tableBody: Locator; readonly tableBody: Locator;
readonly dataRows: Locator; readonly dataRows: Locator;
sortDataRows: Locator; sortDataRows: Locator;
readonly paginationTotal: Locator;
readonly drawerTitle: Locator; readonly drawerTitle: Locator;
readonly drawerFilterBtn: Locator; readonly drawerFilterBtn: Locator;
readonly drawerResetBtn: Locator; readonly drawerResetBtn: Locator;
...@@ -50,6 +49,7 @@ export class CenterReportSharedLocators { ...@@ -50,6 +49,7 @@ export class CenterReportSharedLocators {
readonly tableWrapperIndex: number; readonly tableWrapperIndex: number;
readonly detailTableWrapperIndex: number; readonly detailTableWrapperIndex: number;
sortTableWrapperIndex: number; sortTableWrapperIndex: number;
paginationTableWrapperIndex: number;
readonly detailDataRows: Locator; readonly detailDataRows: Locator;
constructor(page: Page, options: CenterReportLocatorOptions) { constructor(page: Page, options: CenterReportLocatorOptions) {
...@@ -57,6 +57,7 @@ export class CenterReportSharedLocators { ...@@ -57,6 +57,7 @@ export class CenterReportSharedLocators {
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;
this.paginationTableWrapperIndex = this.tableWrapperIndex;
this.productName = this.report.locator(options.productNameSelector).first(); this.productName = this.report.locator(options.productNameSelector).first();
this.goodsCode = options.goodsCodeSelector this.goodsCode = options.goodsCodeSelector
? this.report.locator(options.goodsCodeSelector).first() ? this.report.locator(options.goodsCodeSelector).first()
...@@ -112,7 +113,6 @@ export class CenterReportSharedLocators { ...@@ -112,7 +113,6 @@ export class CenterReportSharedLocators {
'.ant-table-scroll .ant-table-body table tbody > tr.ant-table-row', '.ant-table-scroll .ant-table-body table tbody > tr.ant-table-row',
].join(', '), ].join(', '),
); );
this.paginationTotal = this.report.locator('.ant-pagination-total-text').first();
this.drawerTitle = this.report.locator('.ant-drawer-open .ant-drawer-title'); this.drawerTitle = this.report.locator('.ant-drawer-open .ant-drawer-title');
this.drawerFilterBtn = this.report.getByRole('button', { name: '筛 选' }); this.drawerFilterBtn = this.report.getByRole('button', { name: '筛 选' });
this.drawerResetBtn = this.report.locator('.ant-drawer-footer').getByRole('button', { name: '重 置' }); this.drawerResetBtn = this.report.locator('.ant-drawer-footer').getByRole('button', { name: '重 置' });
...@@ -241,6 +241,24 @@ export class CenterReportSharedLocators { ...@@ -241,6 +241,24 @@ export class CenterReportSharedLocators {
); );
} }
bindPaginationTable(index: number): void {
this.paginationTableWrapperIndex = index;
}
/** 按 tableWrapper 定位对应 ProTable 底部分页,避免左右双表互相抢页码。 */
paginationHost(): Locator {
const wrapper = this.report.locator('.ant-table-wrapper').nth(this.paginationTableWrapperIndex);
return wrapper
.locator('xpath=ancestor::*[contains(@class,"ant-pro-table")][1]//ul[contains(@class,"ant-pagination")]')
.first()
.or(wrapper.locator('xpath=following::ul[contains(@class,"ant-pagination")][1]'))
.or(this.report.locator('.ant-pagination').nth(this.paginationTableWrapperIndex));
}
get paginationTotal(): Locator {
return this.paginationHost().locator('.ant-pagination-total-text').first();
}
private sortTableWrapper(): Locator { private sortTableWrapper(): Locator {
return this.report.locator('.ant-table-wrapper').nth(this.sortTableWrapperIndex); return this.report.locator('.ant-table-wrapper').nth(this.sortTableWrapperIndex);
} }
...@@ -257,17 +275,16 @@ export class CenterReportSharedLocators { ...@@ -257,17 +275,16 @@ export class CenterReportSharedLocators {
let chain: Locator | undefined; let chain: Locator | undefined;
const append = (loc: Locator) => { const append = (loc: Locator) => {
chain = chain ? chain.or(loc) : loc; const unique = loc.first();
chain = chain ? chain.or(unique) : unique;
}; };
append(thead.locator('th:visible').filter({ has: thead.getByText(columnName, { exact: true }) })); append(thead.locator('th').filter({ has: thead.getByText(columnName, { exact: true }) }));
append(withSorter(thead.locator('th:visible').filter({ has: thead.getByText(columnName, { exact: true }) }))); append(withSorter(thead.locator('th').filter({ has: thead.getByText(columnName, { exact: true }) })));
append( append(
withSorter( withSorter(
thead.locator('th:visible').filter({ thead.locator('th').filter({
has: this.report has: thead.locator('.ant-table-column-title').getByText(columnName, { exact: true }),
.locator('.ant-table-column-title')
.getByText(columnName, { exact: true }),
}), }),
), ),
); );
...@@ -283,11 +300,11 @@ export class CenterReportSharedLocators { ...@@ -283,11 +300,11 @@ export class CenterReportSharedLocators {
for (const token of tokens) { for (const token of tokens) {
const normalizedFull = columnName.replace(/\s+/g, ''); const normalizedFull = columnName.replace(/\s+/g, '');
if (token === normalizedFull) continue; if (token === normalizedFull) continue;
append(withSorter(thead.locator('th:visible').filter({ hasText: token }))); append(withSorter(thead.locator('th').filter({ hasText: token })));
append( append(
withSorter( withSorter(
thead.locator('th:visible').filter({ thead.locator('th').filter({
has: this.report.locator('.ant-table-column-title').filter({ hasText: token }), has: thead.locator('.ant-table-column-title').filter({ hasText: token }),
}), }),
), ),
); );
...@@ -347,11 +364,11 @@ export class CenterReportSharedLocators { ...@@ -347,11 +364,11 @@ export class CenterReportSharedLocators {
} }
paginationItem(pageNum: number): Locator { paginationItem(pageNum: number): Locator {
return this.report.locator(`.ant-pagination-item-${pageNum}`); return this.paginationHost().locator(`.ant-pagination-item-${pageNum}`);
} }
paginationActiveItem(): Locator { paginationActiveItem(): Locator {
return this.report.locator('.ant-pagination-item-active'); return this.paginationHost().locator('.ant-pagination-item-active').first();
} }
expandedSubTableHeader(name: string): Locator { expandedSubTableHeader(name: string): Locator {
...@@ -359,7 +376,9 @@ export class CenterReportSharedLocators { ...@@ -359,7 +376,9 @@ export class CenterReportSharedLocators {
} }
subTableRow(rowIndex = 0): Locator { subTableRow(rowIndex = 0): Locator {
return this.expandedRow.locator('.ant-table tbody tr').nth(rowIndex); return this.expandedRow
.locator('.ant-table tbody tr.ant-table-row:not(.ant-table-measure-row)')
.nth(rowIndex);
} }
subTableRowCheckbox(rowIndex = 0): Locator { subTableRowCheckbox(rowIndex = 0): Locator {
...@@ -368,10 +387,14 @@ export class CenterReportSharedLocators { ...@@ -368,10 +387,14 @@ export class CenterReportSharedLocators {
subTableViewButton(rowIndex = 0): Locator { subTableViewButton(rowIndex = 0): Locator {
const row = this.subTableRow(rowIndex); const row = this.subTableRow(rowIndex);
return row const fixedRightRow = this.expandedRow
.getByRole('button', { name: '查看' }) .locator('.ant-table-fixed-right tbody tr.ant-table-row:not(.ant-table-measure-row)')
.or(row.getByRole('link', { name: '查看' })) .nth(rowIndex);
.or(row.locator('a, span, .ant-btn').filter({ hasText: /^查看$/ })) const inRow = (host: Locator) =>
.first(); host
.getByRole('button', { name: /查\s*看/ })
.or(host.getByRole('link', { name: /查\s*看/ }))
.or(host.locator('a, button, span, .ant-btn').filter({ hasText: /查\s*看/ }));
return inRow(fixedRightRow).or(inRow(row)).first();
} }
} }
...@@ -81,18 +81,41 @@ export class CenterReportSheetLocators { ...@@ -81,18 +81,41 @@ export class CenterReportSheetLocators {
} }
tableHeaderCells(): Locator { tableHeaderCells(): Locator {
return this.report.locator('.ant-table-wrapper').first().locator('.ant-table-thead tr').last().locator('th'); return this.visibleThead().locator('tr').last().locator('th');
}
/**
* 可见 sticky 表头。宽表会在 body / fixed 区再克隆 thead,
* `.ant-table-thead tr`.last() 会命中测宽空行或克隆列,waitFor 触发 strict mode。
*/
visibleThead(): Locator {
const wrapper = this.report.locator('.ant-table-wrapper').first();
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();
} }
/** 指定列的表头单元格(可见列 + 含排序控件,规避固定列重复表头)。 */ /** 指定列的表头单元格;每条分支先 .first(),避免 or() 在多匹配 waitFor 时 strict mode。 */
columnHeaderCell(columnName: string): Locator { columnHeaderCell(columnName: string): Locator {
const thead = this.report.locator('.ant-table-wrapper').first().locator('.ant-table-thead tr').last(); const thead = this.visibleThead().locator('tr').last();
const byTitle = thead const byTitle = thead
.locator('th:visible') .locator('th')
.filter({ has: this.report.locator('.ant-table-column-title').getByText(columnName, { exact: true }) }) .filter({ has: thead.locator('.ant-table-column-title').getByText(columnName, { exact: true }) })
.filter({ has: this.report.locator('.ant-table-column-sorter') }); .first();
const byRole = this.report.getByRole('columnheader', { name: columnName, exact: true }); const bySorter = thead
return byTitle.or(byRole).first(); .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)。 */
......
import { expect, type Page } from '@playwright/test'; import { expect, type Page } from '@playwright/test';
import { anaChgData } from '../../data/ana-chg.data'; import { anaChgData } from '../../data/ana-chg.data';
import {
expectNumbersClose,
parseReportNumber,
} from './helpers/report-summary.helper';
import { readSummaryMetrics } from './helpers/report-summary-match.helper';
import { ReconCostReportSharedPage } from './recon-cost-report-shared.page'; import { ReconCostReportSharedPage } from './recon-cost-report-shared.page';
export class AnaChgPage extends ReconCostReportSharedPage { export class AnaChgPage extends ReconCostReportSharedPage {
...@@ -17,35 +12,15 @@ export class AnaChgPage extends ReconCostReportSharedPage { ...@@ -17,35 +12,15 @@ export class AnaChgPage extends ReconCostReportSharedPage {
* STAT-ACH-007:勾选首行后,明细「数量/总差额」与底部「选中总数量/选中总差额」一致。 * STAT-ACH-007:勾选首行后,明细「数量/总差额」与底部「选中总数量/选中总差额」一致。
*/ */
async expectSelectedSummaryMatchesFirstRow(rowIndex = this.data.sampleRowIndex): Promise<void> { async expectSelectedSummaryMatchesFirstRow(rowIndex = this.data.sampleRowIndex): Promise<void> {
const cfg = this.data.firstRowSummaryMatch;
expect(cfg, '未配置 firstRowSummaryMatch,无法做选中汇总勾稽').toBeTruthy();
if (!cfg) return;
await this.waitForTableReady(); await this.waitForTableReady();
const rowCount = await this.locators.dataRows.count(); expect(await this.locators.dataRows.count(), '主表应至少有 1 条数据以便勾选').toBeGreaterThan(0);
expect(rowCount, '主表应至少有 1 条数据以便勾选').toBeGreaterThan(0);
await this.checkMainTableRow(rowIndex); await this.checkMainTableRow(rowIndex);
const sample = await this.extractSampleRow(rowIndex); await this.assertVisibleRowsMatchFooter(cfg.metrics, { rowIndexes: [rowIndex] });
const expectedQty = parseReportNumber(this.getSampleValue(sample, 'field8'));
const expectedDiff = parseReportNumber(this.getSampleValue(sample, 'field9'));
await expect
.poll(
async () => {
const summary = await readSummaryMetrics(this.locators.report.locator('body'), [
'选中总数量',
'选中总差额',
]);
return Math.abs(summary['选中总数量'] - expectedQty) <= 0.01 * Math.max(Math.abs(expectedQty), 1)
&& Math.abs(summary['选中总差额'] - expectedDiff) <= 0.01 * Math.max(Math.abs(expectedDiff), 1);
},
{ timeout: 15_000, message: '勾选后底部选中汇总未与明细数量/总差额一致' },
)
.toBe(true);
const summary = await readSummaryMetrics(this.locators.report.locator('body'), [
'选中总数量',
'选中总差额',
]);
expectNumbersClose(summary['选中总数量'], expectedQty, 0.01, '选中总数量');
expectNumbersClose(summary['选中总差额'], expectedDiff, 0.01, '选中总差额');
} }
} }
......
...@@ -11,9 +11,10 @@ import { BasePage } from '../../core/base.page'; ...@@ -11,9 +11,10 @@ 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 { DrawerFormControls } from './helpers/drawer-form-controls.helper'; import { DrawerFormControls } from './helpers/drawer-form-controls.helper';
import { ColumnSortControls } from './helpers/column-sort.helper'; import { ColumnSortControls, clickColumnSortControl } from './helpers/column-sort.helper';
import { import {
ColumnSettingsControls, ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger, findUsableColumnSettingsTrigger,
revealColumnSettingsTriggerByScroll, revealColumnSettingsTriggerByScroll,
} from './helpers/column-settings.helper'; } from './helpers/column-settings.helper';
...@@ -68,7 +69,7 @@ export class CenterReportEntryPage extends BasePage { ...@@ -68,7 +69,7 @@ export class CenterReportEntryPage extends BasePage {
columnSorter: (columnName) => this.locators.columnSorter(columnName), columnSorter: (columnName) => this.locators.columnSorter(columnName),
columnSorterUp: (columnName) => this.locators.columnSorterUp(columnName), columnSorterUp: (columnName) => this.locators.columnSorterUp(columnName),
columnSorterDown: (columnName) => this.locators.columnSorterDown(columnName), columnSorterDown: (columnName) => this.locators.columnSorterDown(columnName),
clickColumnSort: (columnName) => this.clickColumnSort(columnName), clickColumnSort: (columnName, order) => this.clickColumnSort(columnName, order),
getFirstRowText: () => this.getFirstRowText(), getFirstRowText: () => this.getFirstRowText(),
getColumnValues: (columnName, maxRows) => this.getColumnValues(columnName, maxRows), getColumnValues: (columnName, maxRows) => this.getColumnValues(columnName, maxRows),
getRowSignatures: (maxRows) => this.getRowSignatures(maxRows), getRowSignatures: (maxRows) => this.getRowSignatures(maxRows),
...@@ -668,8 +669,20 @@ export class CenterReportEntryPage extends BasePage { ...@@ -668,8 +669,20 @@ export class CenterReportEntryPage extends BasePage {
async clickSubTableViewDetail(rowIndex = 0): Promise<string> { async clickSubTableViewDetail(rowIndex = 0): Promise<string> {
const orderNo = await this.getSubTableInboundOrderNo(rowIndex); const orderNo = await this.getSubTableInboundOrderNo(rowIndex);
const expanded = this.locators.expandedRow;
const scrollHosts = expanded.locator('.ant-table-body, .ant-table-content, .ant-table-scroll');
const hostCount = await scrollHosts.count();
for (let i = 0; i < hostCount; i += 1) {
await scrollHosts
.nth(i)
.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
})
.catch(() => undefined);
}
const viewBtn = this.locators.subTableViewButton(rowIndex); const viewBtn = this.locators.subTableViewButton(rowIndex);
await expect(viewBtn).toBeVisible(); await viewBtn.scrollIntoViewIfNeeded().catch(() => undefined);
await expect(viewBtn, '子表「查看」按钮应可见').toBeVisible({ timeout: 10_000 });
await this.click(viewBtn, '点击子表查看'); await this.click(viewBtn, '点击子表查看');
return orderNo; return orderNo;
} }
...@@ -888,19 +901,43 @@ export class CenterReportEntryPage extends BasePage { ...@@ -888,19 +901,43 @@ 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);
const sorter = this.locators.columnSorter(columnName); const sorter = this.locators.columnSorter(columnName);
const sortable = (await sorter.count()) > 0 && (await sorter.isVisible()); if ((await sorter.count()) > 0) {
if (!sortable) { 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}」不可排序`); await this.assertPageHealthy(`列「${columnName}」不可排序`);
} }
return sortable; return hasSorter;
} }
async clickColumnSort(columnName: string): Promise<void> { async clickColumnSort(columnName: string, order?: 'ascend' | 'descend'): Promise<void> {
await this.scrollColumnIntoView(columnName); await this.scrollColumnIntoView(columnName);
const header = this.locators.columnHeaderCell(columnName);
const sorter = this.locators.columnSorter(columnName); const sorter = this.locators.columnSorter(columnName);
await this.waitVisible(sorter, 15_000); if ((await sorter.count()) > 0) {
await test.step(`点击${columnName}列排序`, async () => { await sorter.waitFor({ state: 'attached', timeout: 15_000 }).catch(() => undefined);
await sorter.click({ force: true }); } else {
await this.waitVisible(header, 15_000);
}
const label = order === 'descend' ? '降序' : order === 'ascend' ? '升序' : '排序';
await test.step(`点击${columnName}${label}`, async () => {
await clickColumnSortControl({
header,
sorter,
up: this.locators.columnSorterUp(columnName),
down: this.locators.columnSorterDown(columnName),
order,
});
}); });
await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined); await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined);
} }
...@@ -940,26 +977,32 @@ export class CenterReportEntryPage extends BasePage { ...@@ -940,26 +977,32 @@ export class CenterReportEntryPage extends BasePage {
} }
async openColumnSettings(): Promise<void> { async openColumnSettings(): Promise<void> {
await this.closeColumnSettingsPanel(); const panel = this.locators.columnSettingsPanel();
await this.locators.columnSettingsPanel().waitFor({ state: 'hidden', timeout: 3_000 }).catch(() => undefined); if (await panel.isVisible().catch(() => false)) {
await revealColumnSettingsTriggerByScroll({ return;
scroll: this.tableScrollLayoutControls, }
tableWrapper: this.locators.report.locator('.ant-table-wrapper').first(), const triggerCandidates = [this.locators.columnSettingsTrigger];
triggerCandidates: [this.locators.columnSettingsTrigger], let trigger = await findUsableColumnSettingsTrigger(triggerCandidates);
horizontalScrollTargetColumn: centerReportEntryData.horizontalScrollTargetColumn, if (!trigger) {
horizontalScrollDragDelta: centerReportEntryData.horizontalScrollDragDelta, await revealColumnSettingsTriggerByScroll({
scrollColumnIntoView: async (column) => { scroll: this.tableScrollLayoutControls,
await this.locators.tableHeader(column).scrollIntoViewIfNeeded().catch(() => undefined); tableWrapper: this.locators.report.locator('.ant-table-wrapper').first(),
}, triggerCandidates,
}); horizontalScrollTargetColumn: centerReportEntryData.horizontalScrollTargetColumn,
if (!(await findUsableColumnSettingsTrigger([this.locators.columnSettingsTrigger]))) { horizontalScrollDragDelta: centerReportEntryData.horizontalScrollDragDelta,
scrollColumnIntoView: async (column) => {
await this.locators.tableHeader(column).scrollIntoViewIfNeeded().catch(() => undefined);
},
});
trigger = await findUsableColumnSettingsTrigger(triggerCandidates);
}
if (!trigger) {
await this.guardedSkip('列设置齿轮不可见'); await this.guardedSkip('列设置齿轮不可见');
} }
await this.waitVisible(this.locators.columnSettingsTrigger, 15_000);
await test.step('打开列设置', async () => { await test.step('打开列设置', async () => {
await this.locators.columnSettingsTrigger.click({ force: true }); await trigger!.click({ force: true });
}); });
await this.waitVisible(this.locators.columnSettingsPanel(), 10_000); await this.waitVisible(panel, 10_000);
} }
async expectColumnSettingsPanel(): Promise<void> { async expectColumnSettingsPanel(): Promise<void> {
...@@ -1010,6 +1053,19 @@ export class CenterReportEntryPage extends BasePage { ...@@ -1010,6 +1053,19 @@ export class CenterReportEntryPage extends BasePage {
return centerReportEntryData.columnSettingsToggleCandidates[0]; return centerReportEntryData.columnSettingsToggleCandidates[0];
} }
/** 场景:取消勾选非核心列后表头隐藏,再勾选后表头恢复。 */
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) =>
this.expectSettingsCheckboxChecked(name, checked),
});
}
async expectPaginationTotal(): Promise<void> { async expectPaginationTotal(): Promise<void> {
await expect(this.locators.paginationTotal).toHaveText(/共\s+\d+\s+条/); await expect(this.locators.paginationTotal).toHaveText(/共\s+\d+\s+条/);
} }
......
This diff is collapsed.
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