Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
test
autotest-playwright-web-spd
Commits
33b0dd82
Commit
33b0dd82
authored
Sep 20, 2026
by
liguangyu06
Browse files
修复宽表右侧排序超时、iframe 编码匹配和汇总勾稽,避免夜间全量再被脚本误杀。
parent
79a08ff4
Changes
14
Hide whitespace changes
Inline
Side-by-side
core/column-header-match.util.ts
View file @
33b0dd82
...
...
@@ -11,6 +11,7 @@ const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [
[
'
产品编号
'
,
'
产品编码
'
,
'
物资编码
'
,
'
商品编码
'
],
[
'
产品名称
'
,
'
物资名称
'
,
'
商品名称
'
],
[
'
规格型号
'
,
'
规格
'
],
[
'
产品批号
'
,
'
批号
'
],
];
function
aliasTokensFor
(
columnName
:
string
):
string
[]
{
...
...
data/trace-direct-detail.data.ts
View file @
33b0dd82
...
...
@@ -79,8 +79,8 @@ export const traceDirectDetailData: CenterReportPageData & {
field5
:
[
'
采购计划号
'
],
},
horizontalScrollAnchorColumn
:
'
请领
日期
'
,
horizontalScrollTargetColumn
:
'
采购单号
'
,
horizontalScrollAnchorColumn
:
'
请领
单号
'
,
horizontalScrollTargetColumn
:
'
请领审核时间
'
,
horizontalScrollDragDelta
:
320
,
mockNotExistProduct
:
'
__NOT_EXIST_PRODUCT__99999
'
,
mockFieldLabel
:
'
产品名称
'
,
...
...
locators/spd/center-report-entry.locator.ts
View file @
33b0dd82
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
(
iframe
CssSelector
(
'
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
{
...
...
locators/spd/center-report-shared.locator.ts
View file @
33b0dd82
...
...
@@ -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
(
iframe
CssSelector
(
options
.
iframeSrc
)
);
this
.
tableWrapperIndex
=
options
.
tableWrapperIndex
??
0
;
this
.
detailTableWrapperIndex
=
options
.
detailTableWrapperIndex
??
this
.
tableWrapperIndex
;
this
.
sortTableWrapperIndex
=
options
.
sortTableWrapperIndex
??
this
.
tableWrapperIndex
;
...
...
locators/spd/center-report-sheet.locator.ts
View file @
33b0dd82
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
(
iframe
CssSelector
(
'
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
{
...
...
pages/spd/center-report-entry.page.ts
View file @
33b0dd82
...
...
@@ -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
(
iframe
CssSelector
(
'
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
);
...
...
pages/spd/center-report-shared.page.ts
View file @
33b0dd82
...
...
@@ -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
(
iframe
CssSelector
(
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
(
iframe
CssSelector
(
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
)
{
...
...
pages/spd/center-report-sheet.page.ts
View file @
33b0dd82
...
...
@@ -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
(
iframe
CssSelector
(
'
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
;
}
/** 重置快捷区并重新查询,恢复列表数据(排序后偶发空表时使用)。 */
...
...
pages/spd/helpers/column-resize.helper.ts
View file @
33b0dd82
...
...
@@ -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
>
{
...
...
pages/spd/helpers/export-report.helper.ts
View file @
33b0dd82
...
...
@@ -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
();
}
...
...
pages/spd/helpers/report-summary-match.helper.ts
View file @
33b0dd82
...
...
@@ -4,8 +4,9 @@ import type { SummaryMatchProfile } from '../../../data/center-report-shared.typ
import
{
expectNumbersClose
,
parseReportNumber
,
parseSummaryMetric
FromText
,
parseSummaryMetric
WithFallbacks
,
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
=
parseSummaryMetric
FromText
(
footerText
,
label
);
const
value
=
parseSummaryMetric
WithFallbacks
(
footerText
,
summaryMetricLabelFallbacks
(
label
)
)
;
expect
(
Number
.
isFinite
(
value
),
`汇总指标「
${
label
}
」无法解析`
).
toBeTruthy
();
summary
[
label
]
=
value
;
}
...
...
pages/spd/helpers/report-summary.helper.ts
View file @
33b0dd82
...
...
@@ -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
]
=
parseSummaryMetric
FromText
(
text
,
label
);
summary
[
label
]
=
parseSummaryMetric
WithFallbacks
(
text
,
summaryMetricLabelFallbacks
(
label
)
)
;
}
return
summary
;
}
pages/spd/helpers/table-scroll-layout.helper.ts
View file @
33b0dd82
...
...
@@ -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
...
...
pages/spd/recon-cost-report-shared.page.ts
View file @
33b0dd82
...
...
@@ -8,7 +8,7 @@ import type { SampleRow } from './center-report-shared.page';
import
{
expectNumbersClose
,
parseReportNumber
,
parseSummaryMetric
FromText
,
parseSummaryMetric
WithFallbacks
,
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
.
ha
sComboInitial
ListData
(),
'
组合查询前置:主表应至少有 1 条数据
'
).
toBeTruthy
();
await
this
.
skipUnles
sCombo
Has
Initial
Data
();
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
=
parseSummaryMetric
FromText
(
footerText
,
label
);
const
fromFooter
=
parseSummaryMetric
WithFallbacks
(
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
.
ha
sComboInitial
ListData
(),
'
组合查询前置:主表应至少有 1 条数据
'
).
toBeTruthy
();
await
this
.
skipUnles
sCombo
Has
Initial
Data
();
const
sampleOnly
=
options
?.
sampleOnly
!==
false
;
if
(
!
sampleOnly
)
{
sample
=
await
this
.
enrichSampleFromActiveFilters
(
sample
,
quickFields
);
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment