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