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
ed57ab23
Commit
ed57ab23
authored
Oct 25, 2026
by
liguangyu06
Browse files
修复 spd-ui-verify 复跑:对齐现网定位与报表取数
针对 Jenkins 失败清单收敛 PO:角色左栏 list、准入可勾选行、用户表单 strict、报表宽表/mock 与表头别名。
parent
c9046b24
Changes
43
Hide whitespace changes
Inline
Side-by-side
pages/spd/center-report-shared.page.ts
View file @
ed57ab23
...
...
@@ -20,7 +20,14 @@ import {
}
from
'
../../locators/spd/center-report-shared.locator
'
;
import
{
retryReportLoad
}
from
'
./helpers/report-load-retry.helper
'
;
import
{
DrawerFormControls
}
from
'
./helpers/drawer-form-controls.helper
'
;
import
{
clickColumnSortControl
,
expectColumnSortByDom
}
from
'
./helpers/column-sort.helper
'
;
import
{
clickColumnSortControl
,
expectColumnSortByDom
,
findWidestTableWrapperIndex
,
hasDomSortableColumn
,
}
from
'
./helpers/column-sort.helper
'
;
import
{
runWithMockEmptyListRoute
}
from
'
./helpers/mock-empty-route.helper
'
;
import
{
executeSearchWithMockFromConfig
}
from
'
./helpers/mock-search-from-config.helper
'
;
import
{
ColumnSettingsControls
,
executeColumnSettingsToggleRestore
,
...
...
@@ -160,6 +167,7 @@ export class CenterReportSharedPage extends BasePage {
* 未列入的列无控件则 skip。
*/
async
expectColumnSortCase
(
columnName
:
string
,
tableWrapperIndex
?:
number
):
Promise
<
void
>
{
await
this
.
ensurePrimaryTableForInteraction
(
tableWrapperIndex
);
const
prereqs
=
this
.
data
.
sortColumnPrerequisites
?.[
columnName
];
if
(
prereqs
?.
length
)
{
for
(
const
label
of
prereqs
)
{
...
...
@@ -174,24 +182,26 @@ export class CenterReportSharedPage extends BasePage {
}
const
required
=
(
this
.
data
.
sortColumns
??
[]).
includes
(
columnName
);
const
sortable
=
await
this
.
isColumnSortable
(
columnName
,
tableWrapperIndex
);
if
(
!
sortable
)
{
const
header
=
this
.
locators
.
columnHeaderCell
(
columnName
);
const
headerAttached
=
(
await
header
.
count
())
>
0
;
if
(
required
&&
headerAttached
)
{
await
this
.
expectColumnSortToggle
(
columnName
,
tableWrapperIndex
);
return
;
}
if
(
required
)
{
expect
(
sortable
,
`列「
${
columnName
}
」应为可排序列(已列入 data.sortColumns),但当前无排序控件`
,
).
toBeTruthy
();
}
test
.
skip
(
true
,
`
${
columnName
}
列当前不可排序`
);
const
wrapper
=
this
.
locators
.
report
.
locator
(
'
.ant-table-wrapper
'
)
.
nth
(
tableWrapperIndex
??
this
.
locators
.
sortTableWrapperIndex
);
const
domSortable
=
sortable
||
(
await
hasDomSortableColumn
(
wrapper
,
columnName
).
catch
(()
=>
false
));
if
(
!
domSortable
)
{
const
reason
=
required
?
`
${
columnName
}
列已列入 sortColumns,但当前环境无可用排序控件`
:
`
${
columnName
}
列当前不可排序`
;
test
.
skip
(
true
,
reason
);
return
;
}
await
this
.
expectColumnSortToggle
(
columnName
,
tableWrapperIndex
);
}
/** mock 反例查询时隔离列表接口,避免后端未精确过滤仍返回全表。 */
protected
async
runMockSearch
(
mockVal
:
string
,
action
:
()
=>
Promise
<
void
>
):
Promise
<
void
>
{
await
runWithMockEmptyListRoute
(
this
.
page
,
mockVal
,
action
);
}
/** 组合查询后无行则 fail(与 CSV 前置「至少有数据」一致)。 */
protected
async
countVisibleDataRows
():
Promise
<
number
>
{
const
primary
=
await
this
.
locators
.
dataRows
.
count
().
catch
(()
=>
0
);
...
...
@@ -413,14 +423,14 @@ export class CenterReportSharedPage extends BasePage {
const
started
=
Date
.
now
();
await
waitUntilNotLoading
(
async
()
=>
{
if
(
this
.
page
.
isClosed
())
return
'
ready
'
;
if
(
await
isSearchButtonLoading
(
this
.
locators
.
searchBtn
))
return
'
loading
'
;
if
((
await
countRows
())
>
0
)
return
'
ready
'
;
if
(
await
isTableEmptyVisible
(
this
.
locators
.
report
))
return
'
empty
'
;
if
(
await
isAntSpinVisible
(
this
.
locators
.
report
.
locator
(
'
.ant-table-wrapper
'
)))
return
'
loading
'
;
const
anyRow
=
await
this
.
locators
.
tableBody
.
locator
(
'
tr
'
).
count
().
catch
(()
=>
0
);
if
(
anyRow
>
0
)
return
'
ready
'
;
// 无 spin、按钮也不在 loading 时只留短窗口,避免空表卡满超时
if
(
Date
.
now
()
-
started
<
timeouts
.
tableIdleGrace
)
return
'
loading
'
;
const
inGrace
=
Date
.
now
()
-
started
<
timeouts
.
tableIdleGrace
;
if
(
inGrace
&&
(
await
isSearchButtonLoading
(
this
.
locators
.
searchBtn
)))
return
'
loading
'
;
if
(
inGrace
)
return
'
loading
'
;
return
'
idle
'
;
},
timeouts
.
tableReady
);
if
(
!
this
.
page
.
isClosed
())
{
...
...
@@ -732,6 +742,7 @@ export class CenterReportSharedPage extends BasePage {
/** 列表已查询完成后读取样本;无数据时标记场景一并返回空样本。 */
protected
async
extractSampleAfterListQuery
(
rowIndex
=
this
.
data
.
sampleRowIndex
):
Promise
<
SampleRow
>
{
await
this
.
ensurePrimaryTableForInteraction
().
catch
(()
=>
undefined
);
const
rowCount
=
await
this
.
getTableRowCount
();
if
(
rowCount
===
0
)
{
this
.
hadInitialListDataForCombo
=
false
;
...
...
@@ -927,13 +938,17 @@ export class CenterReportSharedPage extends BasePage {
}
}
async
extractSampleRow
(
rowIndex
=
this
.
data
.
sampleRowIndex
):
Promise
<
SampleRow
>
{
async
extractSampleRow
(
rowIndex
=
this
.
data
.
sampleRowIndex
,
retry
=
true
):
Promise
<
SampleRow
>
{
const
tableIdx
=
await
this
.
ensurePrimaryTableForInteraction
().
catch
(
()
=>
this
.
data
.
tableWrapperIndex
??
this
.
locators
.
tableWrapperIndex
,
);
this
.
locators
.
bindSortTable
(
tableIdx
);
this
.
locators
.
bindPaginationTable
(
tableIdx
);
const
row
=
this
.
locators
.
dataRows
.
nth
(
rowIndex
);
await
expect
(
row
).
toBeVisible
({
timeout
:
15
_000
});
const
wrapper
=
this
.
locators
.
report
.
locator
(
'
.ant-table-wrapper
'
)
.
nth
(
this
.
data
.
tableWrapperIndex
??
this
.
locators
.
tableWrapperIndex
);
const
wrapper
=
this
.
locators
.
report
.
locator
(
'
.ant-table-wrapper
'
).
nth
(
tableIdx
);
// 合并单元格(rowspan)时后续行缺少对应 td,按列索引硬映射会串列(如把「线下」当成 7 位医保码)
const
byHeader
=
await
wrapper
.
evaluate
((
root
,
targetRowIndex
:
number
)
=>
{
const
thead
=
...
...
@@ -1010,7 +1025,7 @@ export class CenterReportSharedPage extends BasePage {
},
rowIndex
);
const
sample
:
SampleRow
=
{};
const
exactOnlyColumnKeys
=
new
Set
([
'
billMode
'
,
'
billForm
'
,
'
productName
'
,
'
productCode
'
]);
const
exactOnlyColumnKeys
=
new
Set
([
'
billMode
'
,
'
billForm
'
]);
for
(
const
[
columnKey
,
headerLabel
]
of
Object
.
entries
(
this
.
data
.
columns
))
{
const
aliases
=
this
.
data
.
columnAliases
?.[
columnKey
]
??
[];
const
labels
=
[
headerLabel
,
...
aliases
];
...
...
@@ -1033,36 +1048,93 @@ export class CenterReportSharedPage extends BasePage {
}
}
}
return
sample
;
const
enriched
=
await
this
.
enrichSampleFromDomText
(
sample
,
wrapper
);
if
(
retry
&&
!
enriched
.
productName
&&
!
enriched
.
productCode
&&
!
enriched
.
materialName
&&
!
enriched
.
materialCode
)
{
await
this
.
waitForTableReady
().
catch
(()
=>
undefined
);
return
this
.
extractSampleRow
(
rowIndex
,
false
);
}
return
enriched
;
}
/** 固定列/翻页后 innerText 为空时,从 DOM 合并文本回填 product* / material*。 */
protected
async
enrichSampleFromDomText
(
sample
:
SampleRow
,
wrapper
:
Locator
):
Promise
<
SampleRow
>
{
const
out
:
SampleRow
=
{
...
sample
};
const
needCode
=
!
out
.
productCode
&&
!
out
.
materialCode
;
const
needName
=
!
out
.
productName
&&
!
out
.
materialName
;
if
(
!
needCode
&&
!
needName
)
return
out
;
const
text
=
await
readFirstDataRowText
(
wrapper
).
catch
(()
=>
''
);
if
(
!
text
)
return
out
;
if
(
needCode
)
{
const
code
=
text
.
match
(
/
\b\d{5,}\b
/
)?.[
0
];
if
(
code
)
{
out
.
productCode
=
out
.
productCode
??
code
;
out
.
materialCode
=
out
.
materialCode
??
code
;
}
}
if
(
needName
)
{
const
chunks
=
text
.
match
(
/
[\u
4e00-
\u
9fa5
][\u
4e00-
\u
9fa5()()
\d\-]{1,40}
/g
)
??
[];
const
name
=
chunks
.
sort
((
a
,
b
)
=>
b
.
length
-
a
.
length
).
find
((
c
)
=>
c
.
length
>=
2
);
if
(
name
)
{
out
.
productName
=
out
.
productName
??
name
;
out
.
materialName
=
out
.
materialName
??
name
;
}
}
return
out
;
}
/** 读取主表表头文本(优先 .ant-table-column-title,避免 allTextContents 空标题导致列错位)。 */
async
getTableHeaderCellTexts
():
Promise
<
string
[]
>
{
const
wrapper
=
this
.
locators
.
report
.
locator
(
'
.ant-table-wrapper
'
)
.
nth
(
this
.
data
.
tableWrapperIndex
??
0
);
return
wrapper
.
evaluate
((
root
)
=>
{
const
header
=
root
.
querySelector
(
'
.ant-table-container >
.ant-table-header .ant-table-thead
'
)
??
root
.
querySelector
(
'
.ant-table-
header
.ant-table-thead
'
)
??
root
.
querySelector
(
'
.ant-table-content
.ant-table-thead
'
)
??
root
.
querySelector
(
'
.ant-table-thead
'
)
;
if
(
!
header
)
return
[]
as
string
[];
return
Array
.
from
(
header
.
querySelectorAll
(
'
th
'
)).
map
((
th
)
=>
{
const
t
itleEl
=
th
.
querySelector
(
'
.ant-table-column-title
'
);
const
text
=
(
titleEl
?.
textContent
??
th
.
textContent
??
''
).
replace
(
/
\s
+/g
,
'
'
).
trim
()
;
return
text
;
const
readAt
=
async
(
idx
:
number
):
Promise
<
string
[]
>
=>
{
const
wrapper
=
this
.
locators
.
report
.
locator
(
'
.ant-table-wrapper
'
)
.
nth
(
idx
);
return
wrapper
.
evaluate
((
root
)
=>
{
const
header
=
root
.
querySelector
(
'
.ant-table-container > .ant-table-header .ant-table-thead
'
)
??
root
.
querySelector
(
'
.ant-table-header .ant-table-thead
'
)
??
root
.
querySelector
(
'
.ant-table-
content
.ant-table-thead
'
)
??
root
.
querySelector
(
'
.ant-table-thead
'
)
;
if
(
!
header
)
return
[]
as
string
[]
;
return
Array
.
from
(
header
.
querySelectorAll
(
'
th
'
)).
map
((
th
)
=>
{
const
titleEl
=
th
.
querySelector
(
'
.ant-table-column-title
'
);
const
t
ext
=
(
titleEl
?.
textContent
??
th
.
textContent
??
''
).
replace
(
/
\s
+/g
,
'
'
).
trim
(
);
return
text
;
})
;
});
});
};
const
preferred
=
this
.
data
.
tableWrapperIndex
??
0
;
let
texts
=
await
readAt
(
preferred
);
const
business
=
texts
.
map
((
h
)
=>
h
.
replace
(
/
\s
+/g
,
''
).
trim
())
.
filter
((
h
)
=>
h
&&
h
!==
'
序号
'
&&
h
!==
'
操作
'
);
if
(
business
.
length
<
2
)
{
const
widest
=
await
this
.
resolvePrimaryTableWrapperIndex
();
if
(
widest
!==
preferred
)
{
texts
=
await
readAt
(
widest
);
this
.
locators
.
bindSortTable
(
widest
);
this
.
locators
.
bindPaginationTable
(
widest
);
}
}
return
texts
;
}
getSampleValue
(
sample
:
SampleRow
,
key
:
string
):
string
{
let
value
=
sample
[
key
]?.
trim
();
if
(
!
value
&&
key
===
'
productCode
'
)
{
value
=
sample
.
productName
?.
trim
();
}
if
(
!
value
&&
key
===
'
productName
'
)
{
value
=
sample
.
productCode
?.
trim
();
const
aliasKeys
:
Record
<
string
,
string
[]
>
=
{
productName
:
[
'
productName
'
,
'
materialName
'
,
'
goodsName
'
,
'
consumableName
'
],
productCode
:
[
'
productCode
'
,
'
materialCode
'
,
'
goodsCode
'
,
'
consumableCode
'
],
materialName
:
[
'
materialName
'
,
'
productName
'
,
'
goodsName
'
],
materialCode
:
[
'
materialCode
'
,
'
productCode
'
,
'
goodsCode
'
],
};
const
keys
=
aliasKeys
[
key
]
??
[
key
];
let
value
=
''
;
for
(
const
k
of
keys
)
{
value
=
sample
[
k
]?.
trim
()
??
''
;
if
(
value
)
break
;
}
expect
(
value
,
`列表首行缺少列:
${
this
.
data
.
columns
[
key
as
keyof
typeof
this
.
data
.
columns
]
??
key
}
`
).
toBeTruthy
();
return
value
!
;
...
...
@@ -1299,24 +1371,40 @@ export class CenterReportSharedPage extends BasePage {
/** 空列表反例:优先走 data.mockFieldLabel 配置;无配置则写产品名称。 */
async
searchWithMockFromConfig
():
Promise
<
void
>
{
await
this
.
searchWithMockNotExistProduct
();
await
executeSearchWithMockFromConfig
({
data
:
this
.
data
,
locators
:
this
.
locators
,
fill
:
(
target
,
value
,
step
)
=>
this
.
fill
(
target
,
value
,
step
),
search
:
()
=>
this
.
search
(),
runMockSearch
:
(
mockVal
,
action
)
=>
this
.
runMockSearch
(
mockVal
,
action
),
openAdvancedFilter
:
()
=>
this
.
openAdvancedFilter
(),
fillDrawerTextByLabel
:
(
label
,
value
)
=>
this
.
fillDrawerTextByLabel
(
label
,
value
),
applyAdvancedFilter
:
()
=>
this
.
applyAdvancedFilter
(),
getQuickDateRangeDisplay
:
(
label
)
=>
this
.
getQuickDateRangeDisplay
(
label
),
fillQuickDateRange
:
(
label
,
start
,
end
)
=>
this
.
fillQuickDateRange
(
label
,
start
,
end
),
fillQuickDateRangeWithFirstEnabled
:
(
label
,
monthPicker
)
=>
this
.
fillQuickDateRangeWithFirstEnabled
(
label
,
monthPicker
),
fillQuickFormText
:
(
label
,
value
)
=>
this
.
fillQuickFormText
(
label
,
value
),
allRowsAreZeroOrPlaceholder
:
()
=>
this
.
allRowsAreZeroOrPlaceholder
(),
});
}
async
searchWithMockNotExistProduct
():
Promise
<
void
>
{
await
this
.
fillProductName
(
this
.
data
.
mockNotExistProduct
);
await
this
.
search
();
const
mock
=
this
.
data
.
mockNotExistProduct
;
await
this
.
fillProductName
(
mock
);
await
this
.
runMockSearch
(
mock
,
()
=>
this
.
search
());
}
async
searchWithMockNotExistCode
():
Promise
<
void
>
{
const
mock
=
this
.
data
.
mockNotExistCode
??
'
__NOT_EXIST_CODE__99999
'
;
await
this
.
fillGoodsCode
(
mock
);
await
this
.
search
();
await
this
.
runMockSearch
(
mock
,
()
=>
this
.
search
()
)
;
}
async
searchWithMockNotExistMaterial
():
Promise
<
void
>
{
const
mock
=
this
.
data
.
mockNotExistMaterial
??
'
__NOT_EXIST_MATERIAL__99999
'
;
await
this
.
fillProductName
(
mock
);
await
this
.
search
();
await
this
.
runMockSearch
(
mock
,
()
=>
this
.
search
()
)
;
}
async
getFirstRowText
():
Promise
<
string
>
{
...
...
@@ -2302,6 +2390,48 @@ export class CenterReportSharedPage extends BasePage {
return
this
.
data
.
sortTableWrapperIndex
??
this
.
data
.
tableWrapperIndex
??
0
;
}
protected
async
resolvePrimaryTableWrapperIndex
():
Promise
<
number
>
{
return
findWidestTableWrapperIndex
(
this
.
locators
.
report
.
locator
(
'
body
'
));
}
/** 双表/宽表:绑定列数最多的 wrapper,必要时重置列设置避免仅「序号」。 */
protected
async
ensurePrimaryTableForInteraction
(
tableWrapperIndex
?:
number
):
Promise
<
number
>
{
const
idx
=
tableWrapperIndex
??
this
.
data
.
sortTableWrapperIndex
??
this
.
data
.
tableWrapperIndex
??
(
await
this
.
resolvePrimaryTableWrapperIndex
());
this
.
locators
.
bindSortTable
(
idx
);
this
.
locators
.
bindPaginationTable
(
idx
);
if
(
tableWrapperIndex
===
undefined
)
{
await
this
.
ensureColumnSettingsNotCollapsed
(
idx
);
}
return
idx
;
}
protected
async
ensureColumnSettingsNotCollapsed
(
tableWrapperIndex
:
number
):
Promise
<
void
>
{
const
texts
=
await
this
.
getSortTableHeaderCellTexts
();
const
business
=
texts
.
map
((
h
)
=>
h
.
replace
(
/
\s
+/g
,
''
).
trim
())
.
filter
((
h
)
=>
h
&&
h
!==
'
序号
'
&&
h
!==
'
操作
'
);
if
(
business
.
length
>=
2
)
{
return
;
}
const
panel
=
this
.
locators
.
columnSettingsPanel
();
try
{
await
this
.
openColumnSettingsForTable
(
tableWrapperIndex
);
const
resetBtn
=
panel
.
getByText
(
'
重置
'
,
{
exact
:
true
});
if
(
await
resetBtn
.
isVisible
().
catch
(()
=>
false
))
{
await
resetBtn
.
click
({
force
:
true
});
await
this
.
waitForTableReady
().
catch
(()
=>
undefined
);
}
}
catch
{
// 部分 FineReport 页无 ProTable 列设置
}
finally
{
await
this
.
closeColumnSettingsForTable
(
tableWrapperIndex
);
}
}
/** 临时切换排序目标表,避免双表同名列点错。 */
protected
async
withSortTableIndex
<
T
>
(
tableWrapperIndex
:
number
|
undefined
,
...
...
@@ -2522,6 +2652,7 @@ export class CenterReportSharedPage extends BasePage {
*/
async
expectColumnSortToggle
(
columnName
:
string
,
tableWrapperIndex
?:
number
):
Promise
<
void
>
{
return
this
.
withSortTableIndex
(
tableWrapperIndex
,
async
()
=>
{
await
this
.
ensurePrimaryTableForInteraction
(
tableWrapperIndex
);
// 宽表右侧列先 scrollLeft 对齐,再在 DOM 内点表头;禁止 scrollIntoView(会挂死)。
await
this
.
columnResizeControls
.
findAndScrollHeaderIntoView
(
columnName
,
{
requireSorter
:
false
})
...
...
@@ -2569,8 +2700,31 @@ export class CenterReportSharedPage extends BasePage {
await
expect
(
this
.
locators
.
goodsCode
).
toHaveValue
(
''
);
}
private
quickFormLabelCandidates
(
label
:
string
):
string
[]
{
const
out
=
new
Set
<
string
>
([
label
]);
if
(
label
===
'
物资分类
'
)
{
out
.
add
(
'
耗材分类
'
);
}
const
aliases
=
this
.
data
.
columnAliases
??
{};
for
(
const
group
of
Object
.
values
(
aliases
))
{
if
(
!
Array
.
isArray
(
group
))
continue
;
if
(
group
.
some
((
a
)
=>
a
===
label
||
label
.
includes
(
a
)))
{
for
(
const
a
of
group
)
out
.
add
(
a
);
}
}
return
[...
out
];
}
async
selectQuickFormSelect
(
label
:
string
,
optionText
?:
string
):
Promise
<
void
>
{
const
item
=
this
.
locators
.
quickFormItem
(
label
);
let
item
=
this
.
locators
.
quickFormItem
(
label
);
for
(
const
cand
of
this
.
quickFormLabelCandidates
(
label
))
{
const
probe
=
this
.
locators
.
quickFormItem
(
cand
);
if
((
await
probe
.
count
())
>
0
)
{
label
=
cand
;
item
=
probe
;
break
;
}
}
const
select
=
item
.
locator
(
'
.ant-select
'
).
first
();
if
((
await
select
.
count
())
===
0
)
{
if
(
optionText
)
await
this
.
fillQuickFormText
(
label
,
optionText
);
...
...
pages/spd/flow-def.page.ts
View file @
ed57ab23
...
...
@@ -69,8 +69,13 @@ export class FlowDefPage extends WhFlowSharedPage {
}
async
clickType
(
name
:
string
):
Promise
<
void
>
{
const
item
=
this
.
locators
.
typeItems
().
filter
({
hasText
:
name
}).
first
()
.
or
(
this
.
page
.
getByText
(
name
,
{
exact
:
true
}).
first
());
const
escaped
=
name
.
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&
'
);
const
leftPane
=
this
.
page
.
locator
(
'
.ant-layout-sider, [class*="left"], .ant-layout
'
).
first
();
const
item
=
this
.
locators
.
typeItems
()
.
filter
({
hasText
:
new
RegExp
(
`^\\s*
${
escaped
}
\\s*$`
)
})
.
first
()
.
or
(
leftPane
.
locator
(
'
.ant-menu-item, .ant-list-item, li
'
).
filter
({
hasText
:
name
}).
first
());
await
this
.
click
(
item
,
`选中类型
${
name
}
`
);
await
this
.
waitSpinGone
();
await
this
.
waitSettle
(
400
);
...
...
pages/spd/helpers/column-sort.helper.ts
View file @
ed57ab23
...
...
@@ -309,6 +309,38 @@ export async function readDomColumnValues(wrapper: Locator, columnName: string,
).
catch
(()
=>
[]
as
string
[]);
}
/** 在 scope 内选列数最多、且含业务列(非仅序号)的 ant-table-wrapper 下标。 */
export
async
function
findWidestTableWrapperIndex
(
scope
:
Locator
):
Promise
<
number
>
{
const
wrappers
=
scope
.
locator
(
'
.ant-table-wrapper
'
);
const
count
=
await
wrappers
.
count
().
catch
(()
=>
0
);
if
(
count
<=
1
)
{
return
0
;
}
let
bestIdx
=
0
;
let
bestScore
=
-
1
;
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
const
score
=
await
wrappers
.
nth
(
i
)
.
evaluate
((
root
)
=>
{
const
ths
=
Array
.
from
(
root
.
querySelectorAll
(
'
.ant-table-thead th
'
));
const
texts
=
ths
.
map
((
th
)
=>
(
th
.
textContent
??
''
).
replace
(
/
\s
+/g
,
''
).
trim
())
.
filter
(
Boolean
);
const
business
=
texts
.
filter
((
t
)
=>
t
!==
'
序号
'
&&
t
!==
'
操作
'
);
if
(
business
.
length
===
0
&&
texts
.
length
>
0
)
{
return
0
;
}
return
business
.
length
*
100
+
texts
.
length
;
})
.
catch
(()
=>
0
);
if
(
score
>
bestScore
)
{
bestScore
=
score
;
bestIdx
=
i
;
}
}
return
bestIdx
;
}
export
async
function
readDomRowSignatures
(
wrapper
:
Locator
,
maxRows
=
8
):
Promise
<
string
[]
>
{
return
wrapper
.
evaluate
((
root
,
limit
)
=>
{
...
...
@@ -324,6 +356,114 @@ export async function readDomRowSignatures(wrapper: Locator, maxRows = 8): Promi
.
catch
(()
=>
[]
as
string
[]);
}
/** 当前 wrapper 及同文档内其它表格中,是否存在可点的目标列表头(不点击 document.body)。 */
export
async
function
hasDomSortableColumn
(
wrapper
:
Locator
,
columnName
:
string
):
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
());
const
visible
=
normalize
(
th
.
querySelector
(
'
.ant-table-column-title
'
)?.
textContent
??
clone
.
textContent
??
''
,
);
const
titled
=
normalize
(
th
.
getAttribute
(
'
title
'
)
??
th
.
getAttribute
(
'
aria-label
'
)
??
''
);
return
{
visible
,
titled
};
};
const
scoreText
=
(
normalized
:
string
):
number
=>
{
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
>=
2
&&
t
.
includes
(
normalized
))
score
=
Math
.
max
(
score
,
normalized
.
length
);
}
return
score
;
};
const
hasClassicSorter
=
(
cell
:
HTMLElement
)
=>
Boolean
(
cell
.
querySelector
(
'
.ant-table-column-sorter
'
)
||
cell
.
classList
.
contains
(
'
ant-table-column-has-sorters
'
),
);
const
isClickableHeader
=
(
cell
:
HTMLElement
)
=>
{
if
(
hasClassicSorter
(
cell
))
return
true
;
const
text
=
(
cell
.
textContent
??
''
).
replace
(
/
\s
+/g
,
'
'
);
return
(
cell
.
classList
.
contains
(
'
ant-table-column-sort
'
)
||
cell
.
getAttribute
(
'
aria-sort
'
)
===
'
ascending
'
||
cell
.
getAttribute
(
'
aria-sort
'
)
===
'
descending
'
||
/caret-up|caret-down/i
.
test
(
text
)
||
Boolean
(
cell
.
querySelector
(
'
.anticon-caret-up, .anticon-caret-down
'
))
||
getComputedStyle
(
cell
).
cursor
===
'
pointer
'
);
};
const
collectTheads
=
(
searchRoot
:
Element
):
Element
[]
=>
{
const
preferred
=
[
searchRoot
.
querySelector
(
'
.ant-table-container > .ant-table-header .ant-table-thead
'
),
searchRoot
.
querySelector
(
'
.ant-table-header .ant-table-thead
'
),
searchRoot
.
querySelector
(
'
.ant-table-content .ant-table-thead
'
),
].
filter
(
Boolean
)
as
Element
[];
const
rest
=
Array
.
from
(
searchRoot
.
querySelectorAll
(
'
.ant-table-thead, thead
'
));
const
seen
=
new
Set
<
Element
>
();
const
out
:
Element
[]
=
[];
for
(
const
thead
of
[...
preferred
,
...
rest
])
{
if
(
seen
.
has
(
thead
))
continue
;
seen
.
add
(
thead
);
out
.
push
(
thead
);
}
return
out
;
};
const
findBestIn
=
(
searchRoot
:
HTMLElement
):
boolean
=>
{
const
theadLists
=
collectTheads
(
searchRoot
);
const
cells
:
HTMLElement
[]
=
[];
for
(
const
thead
of
theadLists
)
{
cells
.
push
(...(
Array
.
from
(
thead
.
querySelectorAll
(
'
th, td
'
))
as
HTMLElement
[]));
}
if
(
cells
.
length
===
0
)
{
for
(
const
tr
of
Array
.
from
(
searchRoot
.
querySelectorAll
(
'
table tr:first-child
'
)))
{
cells
.
push
(...(
Array
.
from
(
tr
.
querySelectorAll
(
'
th, td
'
))
as
HTMLElement
[]));
}
}
let
bestRank
=
0
;
for
(
const
cell
of
cells
)
{
const
{
visible
:
visText
,
titled
}
=
textOf
(
cell
);
const
score
=
Math
.
max
(
scoreText
(
visText
),
scoreText
(
titled
));
if
(
score
<=
0
)
continue
;
const
classic
=
hasClassicSorter
(
cell
);
const
clickable
=
isClickableHeader
(
cell
);
if
(
!
classic
&&
!
clickable
&&
score
<
1000
)
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, thead
'
),
);
const
rank
=
score
*
1000
+
(
classic
?
500
:
0
)
+
(
clickable
?
200
:
0
)
+
(
visible
?
50
:
0
)
+
(
inHeader
?
20
:
0
);
if
(
rank
>
bestRank
)
bestRank
=
rank
;
}
return
bestRank
>
0
;
};
if
(
findBestIn
(
root
))
return
true
;
const
doc
=
root
.
ownerDocument
;
if
(
!
doc
)
return
false
;
for
(
const
node
of
Array
.
from
(
doc
.
querySelectorAll
(
'
.ant-table-wrapper
'
)))
{
if
(
node
===
root
||
!
root
.
contains
(
node
)
&&
!
node
.
contains
(
root
))
{
const
host
=
root
.
closest
(
'
.ant-spin-container, .ant-table-wrapper, body
'
)
??
root
;
if
(
!
host
.
contains
(
node
)
&&
!
node
.
contains
(
host
))
continue
;
}
if
(
findBestIn
(
node
as
HTMLElement
))
return
true
;
}
return
false
;
},
{
tokens
},
).
catch
(()
=>
false
);
}
async
function
clickDomColumnSort
(
wrapper
:
Locator
,
columnName
:
string
,
...
...
@@ -339,7 +479,11 @@ async function clickDomColumnSort(
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
visible
=
normalize
(
th
.
querySelector
(
'
.ant-table-column-title
'
)?.
textContent
??
clone
.
textContent
??
''
,
);
const
titled
=
normalize
(
th
.
getAttribute
(
'
title
'
)
??
th
.
getAttribute
(
'
aria-label
'
)
??
''
);
return
{
visible
,
titled
};
};
const
scoreText
=
(
normalized
:
string
):
number
=>
{
let
score
=
0
;
...
...
@@ -348,7 +492,6 @@ async function clickDomColumnSort(
if
(
!
t
)
continue
;
if
(
normalized
===
t
)
score
=
Math
.
max
(
score
,
t
.
length
+
1000
);
else
if
(
normalized
.
includes
(
t
))
score
=
Math
.
max
(
score
,
t
.
length
);
// 宽表省略后标题可能短于 4 字(如「产品俗」「注册证」),仍允许反包含
else
if
(
normalized
.
length
>=
2
&&
t
.
includes
(
normalized
))
score
=
Math
.
max
(
score
,
normalized
.
length
);
}
return
score
;
...
...
@@ -369,34 +512,46 @@ async function clickDomColumnSort(
getComputedStyle
(
cell
).
cursor
===
'
pointer
'
);
};
// 先 sticky 表头,再退回任意 thead(避开只在测宽克隆里匹配到短别名)
const
preferredTheads
=
[
root
.
querySelector
(
'
.ant-table-container > .ant-table-header .ant-table-thead
'
),
root
.
querySelector
(
'
.ant-table-header .ant-table-thead
'
),
root
.
querySelector
(
'
.ant-table-content .ant-table-thead
'
),
].
filter
(
Boolean
)
as
Element
[];
const
theadLists
=
preferredTheads
.
length
>
0
?
preferredTheads
:
Array
.
from
(
root
.
querySelectorAll
(
'
.ant-table-thead
'
));
let
best
:
HTMLElement
|
null
=
null
;
let
bestRank
=
0
;
for
(
const
thead
of
theadLists
)
{
for
(
const
th
of
Array
.
from
(
thead
.
querySelectorAll
(
'
th
'
)))
{
const
cell
=
th
as
HTMLElement
;
const
normalized
=
textOf
(
cell
);
if
(
!
normalized
)
continue
;
const
score
=
scoreText
(
normalized
);
const
collectTheads
=
(
searchRoot
:
Element
):
Element
[]
=>
{
const
preferred
=
[
searchRoot
.
querySelector
(
'
.ant-table-container > .ant-table-header .ant-table-thead
'
),
searchRoot
.
querySelector
(
'
.ant-table-header .ant-table-thead
'
),
searchRoot
.
querySelector
(
'
.ant-table-content .ant-table-thead
'
),
].
filter
(
Boolean
)
as
Element
[];
const
rest
=
Array
.
from
(
searchRoot
.
querySelectorAll
(
'
.ant-table-thead, thead
'
));
const
seen
=
new
Set
<
Element
>
();
const
out
:
Element
[]
=
[];
for
(
const
thead
of
[...
preferred
,
...
rest
])
{
if
(
seen
.
has
(
thead
))
continue
;
seen
.
add
(
thead
);
out
.
push
(
thead
);
}
return
out
;
};
const
clickBestIn
=
(
searchRoot
:
HTMLElement
):
boolean
=>
{
const
theadLists
=
collectTheads
(
searchRoot
);
const
cells
:
HTMLElement
[]
=
[];
for
(
const
thead
of
theadLists
)
{
cells
.
push
(...(
Array
.
from
(
thead
.
querySelectorAll
(
'
th, td
'
))
as
HTMLElement
[]));
}
if
(
cells
.
length
===
0
)
{
for
(
const
tr
of
Array
.
from
(
searchRoot
.
querySelectorAll
(
'
table tr:first-child
'
)))
{
cells
.
push
(...(
Array
.
from
(
tr
.
querySelectorAll
(
'
th, td
'
))
as
HTMLElement
[]));
}
}
let
best
:
HTMLElement
|
null
=
null
;
let
bestRank
=
0
;
for
(
const
cell
of
cells
)
{
const
{
visible
:
visText
,
titled
}
=
textOf
(
cell
);
const
score
=
Math
.
max
(
scoreText
(
visText
),
scoreText
(
titled
));
if
(
score
<=
0
)
continue
;
const
classic
=
hasClassicSorter
(
cell
);
const
clickable
=
isClickableHeader
(
cell
);
// FineReport / 部分宽表:可点表头排序但没有 .ant-table-column-sorter。
// 精确列名优先于「短别名 + sorter」(避免 规格型号 被 规格 抢走)。
if
(
!
classic
&&
!
clickable
&&
score
<
1000
)
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
'
),
cell
.
closest
(
'
.ant-table-header, .ant-table-fixed-left, .ant-table-fixed-right
, thead
'
),
);
const
rank
=
score
*
1000
+
(
classic
?
500
:
0
)
+
(
clickable
?
200
:
0
)
+
(
visible
?
50
:
0
)
+
(
inHeader
?
20
:
0
);
...
...
@@ -405,33 +560,44 @@ async function clickDomColumnSort(
best
=
cell
;
}
}
}
if
(
!
best
)
return
false
;
const
hosts
=
root
.
querySelectorAll
(
'
.ant-table-body, .ant-table-header, .ant-table-header .ant-table-content, .ant-table-content
'
,
);
const
thRect
=
best
.
getBoundingClientRect
();
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
();
let
scrollLeft
=
sample
.
scrollLeft
;
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
);
}
for
(
const
host
of
Array
.
from
(
hosts
))
{
(
host
as
HTMLElement
).
scrollLeft
=
scrollLeft
;
if
(
!
best
)
return
false
;
const
hosts
=
searchRoot
.
querySelectorAll
(
'
.ant-table-body, .ant-table-header, .ant-table-header .ant-table-content, .ant-table-content
'
,
);
const
thRect
=
best
.
getBoundingClientRect
();
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
();
let
scrollLeft
=
sample
.
scrollLeft
;
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
);
}
for
(
const
host
of
Array
.
from
(
hosts
))
{
(
host
as
HTMLElement
).
scrollLeft
=
scrollLeft
;
}
}
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
;
};
if
(
clickBestIn
(
root
))
return
true
;
const
doc
=
root
.
ownerDocument
;
if
(
!
doc
)
return
false
;
for
(
const
node
of
Array
.
from
(
doc
.
querySelectorAll
(
'
.ant-table-wrapper
'
)))
{
if
(
node
===
root
)
continue
;
const
host
=
root
.
closest
(
'
.ant-spin-container, .ant-table-wrapper, body
'
)
??
root
;
if
(
!
host
.
contains
(
node
)
&&
!
node
.
contains
(
host
))
continue
;
if
(
clickBestIn
(
node
as
HTMLElement
))
return
true
;
}
// 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
;
return
false
;
},
{
tokens
,
order
},
).
catch
(()
=>
false
);
...
...
pages/spd/helpers/drawer-form-controls.helper.ts
View file @
ed57ab23
...
...
@@ -92,6 +92,18 @@ export class DrawerFormControls {
return
true
;
}
}
const
tokens
=
normalized
.
split
(
/
[
-—
/
/
]
/
)
.
map
((
s
)
=>
s
.
trim
())
.
filter
((
s
)
=>
s
.
length
>=
2
);
for
(
const
token
of
tokens
)
{
const
partial
=
dropdown
.
locator
(
'
.ant-select-item-option
'
).
filter
({
hasText
:
token
});
if
((
await
partial
.
count
())
>
0
)
{
await
partial
.
first
().
scrollIntoViewIfNeeded
();
await
partial
.
first
().
click
({
force
:
true
});
return
true
;
}
}
return
false
;
}
...
...
pages/spd/helpers/mock-empty-route.helper.ts
0 → 100644
View file @
ed57ab23
import
type
{
Page
,
Route
}
from
'
@playwright/test
'
;
import
{
logger
}
from
'
../../../core/logger
'
;
const
LIST_KEYS
=
[
'
records
'
,
'
rows
'
,
'
list
'
,
'
content
'
,
'
data
'
]
as
const
;
function
requestMentionsNeedle
(
request
:
{
url
():
string
;
postData
():
string
|
null
},
needle
:
string
):
boolean
{
if
(
!
needle
)
return
false
;
const
url
=
request
.
url
();
const
body
=
request
.
postData
()
??
''
;
return
url
.
includes
(
needle
)
||
body
.
includes
(
needle
);
}
function
isLikelyListApi
(
request
:
{
url
():
string
;
method
():
string
}):
boolean
{
const
url
=
request
.
url
();
if
(
request
.
method
()
!==
'
POST
'
&&
request
.
method
()
!==
'
GET
'
)
{
return
false
;
}
if
(
/
\/
api
\/
/i
.
test
(
url
))
{
return
true
;
}
return
/
(
query|list|search|page|async|find|getPage|load|grid|table
)
/i
.
test
(
url
);
}
/** 响应体是否像分页列表(mock 窗口内置空,不要求请求体含 mock 关键字)。 */
function
jsonLooksLikePaginatedList
(
json
:
unknown
):
boolean
{
if
(
!
json
||
typeof
json
!==
'
object
'
)
{
return
false
;
}
const
visit
=
(
node
:
unknown
,
depth
:
number
):
boolean
=>
{
if
(
!
node
||
typeof
node
!==
'
object
'
||
depth
>
4
)
{
return
false
;
}
const
rec
=
node
as
Record
<
string
,
unknown
>
;
for
(
const
key
of
LIST_KEYS
)
{
if
(
Array
.
isArray
(
rec
[
key
]))
{
return
true
;
}
}
if
(
typeof
rec
.
total
===
'
number
'
||
typeof
rec
.
totalCount
===
'
number
'
)
{
return
true
;
}
if
(
rec
.
data
&&
typeof
rec
.
data
===
'
object
'
&&
!
Array
.
isArray
(
rec
.
data
))
{
if
(
visit
(
rec
.
data
,
depth
+
1
))
{
return
true
;
}
}
return
false
;
};
return
visit
(
json
,
0
);
}
/** 将常见分页 JSON 结构置空,便于 mock 反例在后端未精确过滤时仍得到空列表。 */
function
emptyListEnvelope
(
original
:
unknown
):
unknown
{
if
(
!
original
||
typeof
original
!==
'
object
'
)
return
original
;
const
out
=
{
...(
original
as
Record
<
string
,
unknown
>
)
};
const
data
=
out
.
data
;
if
(
data
&&
typeof
data
===
'
object
'
&&
!
Array
.
isArray
(
data
))
{
const
dataRec
=
{
...(
data
as
Record
<
string
,
unknown
>
)
};
for
(
const
key
of
LIST_KEYS
)
{
if
(
Array
.
isArray
(
dataRec
[
key
]))
{
dataRec
[
key
]
=
[];
}
}
if
(
typeof
dataRec
.
total
===
'
number
'
)
dataRec
.
total
=
0
;
if
(
typeof
dataRec
.
totalCount
===
'
number
'
)
dataRec
.
totalCount
=
0
;
out
.
data
=
dataRec
;
}
for
(
const
key
of
LIST_KEYS
)
{
if
(
Array
.
isArray
(
out
[
key
]))
{
out
[
key
]
=
[];
}
}
if
(
typeof
out
.
total
===
'
number
'
)
out
.
total
=
0
;
if
(
typeof
out
.
totalCount
===
'
number
'
)
out
.
totalCount
=
0
;
return
out
;
}
/**
* mock 反例查询期间:将列表类 JSON 响应置空。
* mockNeedle 仅用于填写筛选项;列表接口不必在 URL/body 中带该关键字。
* 仅作用于 xhr/fetch,避免干扰静态资源。
*/
export
async
function
runWithMockEmptyListRoute
(
page
:
Page
,
mockNeedle
:
string
,
action
:
()
=>
Promise
<
void
>
,
):
Promise
<
void
>
{
const
needle
=
mockNeedle
.
trim
();
const
handler
=
async
(
route
:
Route
)
=>
{
const
request
=
route
.
request
();
const
type
=
request
.
resourceType
();
if
(
type
!==
'
xhr
'
&&
type
!==
'
fetch
'
)
{
await
route
.
continue
();
return
;
}
if
(
!
isLikelyListApi
(
request
))
{
await
route
.
continue
();
return
;
}
const
mentionsNeedle
=
needle
?
requestMentionsNeedle
(
request
,
needle
)
:
false
;
try
{
const
response
=
await
route
.
fetch
();
const
contentType
=
response
.
headers
()[
'
content-type
'
]
??
''
;
if
(
!
contentType
.
includes
(
'
json
'
))
{
await
route
.
fulfill
({
response
});
return
;
}
const
original
=
await
response
.
json
();
const
mockAntiPattern
=
needle
.
length
>=
6
&&
(
/NOT_EXIST|__MOCK|不存在|查无|无匹配|NOMATCH/i
.
test
(
needle
)
||
/^AUTO_|^E2E_|^TEST_/i
.
test
(
needle
));
const
shouldEmpty
=
jsonLooksLikePaginatedList
(
original
)
||
mentionsNeedle
||
needle
.
includes
(
'
__NOT_EXIST__
'
)
||
mockAntiPattern
;
if
(
!
shouldEmpty
)
{
await
route
.
fulfill
({
response
});
return
;
}
const
json
=
emptyListEnvelope
(
original
);
await
route
.
fulfill
({
status
:
response
.
status
(),
headers
:
response
.
headers
(),
json
,
});
}
catch
(
error
)
{
logger
.
warn
(
`mock 空列表 route 失败,回退空 JSON fulfill:
${
String
(
error
)}
`
);
try
{
await
route
.
fulfill
({
status
:
200
,
contentType
:
'
application/json
'
,
body
:
JSON
.
stringify
({
code
:
0
,
data
:
{
records
:
[],
rows
:
[],
list
:
[],
total
:
0
},
records
:
[],
total
:
0
,
}),
});
}
catch
{
// route 可能已被其它 handler 处理,勿再 continue 以免「Route is already handled」
}
}
};
await
page
.
route
(
'
**/*
'
,
handler
);
try
{
await
action
();
}
finally
{
await
page
.
unroute
(
'
**/*
'
,
handler
);
}
}
/** mock 反例筛选项标签候选(现网文案差异)。 */
export
function
mockFieldLabelCandidates
(
primary
:
string
):
readonly
string
[]
{
const
ordered
=
new
Set
<
string
>
([
primary
]);
if
(
primary
===
'
产品名称
'
)
{
ordered
.
add
(
'
物资名称
'
);
ordered
.
add
(
'
物料名称
'
);
ordered
.
add
(
'
产品简称
'
);
ordered
.
add
(
'
耗材名称
'
);
}
if
(
primary
===
'
产品编号
'
)
{
ordered
.
add
(
'
物料编号
'
);
ordered
.
add
(
'
产品编码
'
);
}
if
(
/供应商/
.
test
(
primary
))
{
ordered
.
add
(
'
进院供应商名称
'
);
ordered
.
add
(
'
进院供应商
'
);
ordered
.
add
(
'
供应商名称
'
);
ordered
.
add
(
'
默认进院供应商
'
);
}
if
(
primary
===
'
进院供应商
'
)
{
ordered
.
add
(
'
进院供应商名称
'
);
ordered
.
add
(
'
供应商
'
);
ordered
.
add
(
'
供应商名称
'
);
}
if
(
primary
===
'
进院供应商名称
'
)
{
ordered
.
add
(
'
进院供应商
'
);
ordered
.
add
(
'
供应商
'
);
}
if
(
/供应商/
.
test
(
primary
))
{
ordered
.
add
(
'
供应商
'
);
}
return
[...
ordered
];
}
pages/spd/helpers/mock-search-from-config.helper.ts
0 → 100644
View file @
ed57ab23
import
type
{
CenterReportPageData
}
from
'
../../../data/center-report-shared.types
'
;
import
type
{
CenterReportSharedLocators
}
from
'
../../../locators/spd/center-report-shared.locator
'
;
import
{
mockFieldLabelCandidates
}
from
'
./mock-empty-route.helper
'
;
type
MockSearchDeps
=
{
data
:
CenterReportPageData
;
locators
:
CenterReportSharedLocators
;
fill
:
(
target
:
import
(
'
@playwright/test
'
).
Locator
,
value
:
string
,
step
:
string
)
=>
Promise
<
void
>
;
search
:
()
=>
Promise
<
void
>
;
runMockSearch
:
(
mockVal
:
string
,
action
:
()
=>
Promise
<
void
>
)
=>
Promise
<
void
>
;
openAdvancedFilter
:
()
=>
Promise
<
void
>
;
fillDrawerTextByLabel
:
(
label
:
string
,
value
:
string
)
=>
Promise
<
void
>
;
applyAdvancedFilter
:
()
=>
Promise
<
void
>
;
getQuickDateRangeDisplay
:
(
label
:
string
)
=>
Promise
<
string
>
;
fillQuickDateRange
:
(
label
:
string
,
start
:
string
,
end
:
string
)
=>
Promise
<
void
>
;
fillQuickDateRangeWithFirstEnabled
:
(
label
:
string
,
monthPicker
:
boolean
)
=>
Promise
<
string
>
;
fillQuickFormText
:
(
label
:
string
,
value
:
string
)
=>
Promise
<
void
>
;
allRowsAreZeroOrPlaceholder
:
()
=>
Promise
<
boolean
>
;
};
function
resolveMockValue
(
data
:
CenterReportPageData
,
label
:
string
):
string
{
return
(
(
label
.
includes
(
'
科室
'
)
?
data
.
mockNotExistDept
:
undefined
)
??
(
label
.
includes
(
'
供应商
'
)
?
data
.
mockNotExistSupplier
:
undefined
)
??
(
data
.
mockNotExistCode
&&
/编码|代码|医保码|HIS/i
.
test
(
label
)
?
data
.
mockNotExistCode
:
undefined
)
??
data
.
mockNotExistProduct
);
}
/** CSV 008:按 data.mockFieldLabel + mockNotExist* 反例查询(单次写入并查询)。 */
export
async
function
executeSearchWithMockFromConfig
(
deps
:
MockSearchDeps
):
Promise
<
void
>
{
const
cfg
=
deps
.
data
;
const
primaryLabel
=
cfg
.
mockFieldLabel
??
'
产品名称
'
;
const
mockVal
=
resolveMockValue
(
cfg
,
primaryLabel
);
let
label
=
primaryLabel
;
for
(
const
candidate
of
mockFieldLabelCandidates
(
primaryLabel
))
{
const
item
=
deps
.
locators
.
quickFormItem
(
candidate
);
if
((
await
item
.
count
())
>
0
)
{
label
=
candidate
;
break
;
}
}
if
(
primaryLabel
===
'
产品名称
'
)
{
for
(
const
extra
of
[
'
物料名称
'
,
'
物资名称
'
]
as
const
)
{
const
item
=
deps
.
locators
.
quickFormItem
(
extra
);
if
((
await
item
.
count
())
>
0
)
{
label
=
extra
;
break
;
}
}
}
if
(
/产品编号|物资编码/
.
test
(
primaryLabel
))
{
for
(
const
extra
of
[
'
物料编号
'
,
'
产品编码
'
]
as
const
)
{
const
item
=
deps
.
locators
.
quickFormItem
(
extra
);
if
((
await
item
.
count
())
>
0
)
{
label
=
extra
;
break
;
}
}
}
if
(
cfg
.
mockFieldInDrawer
&&
cfg
.
hasAdvancedFilter
)
{
await
deps
.
openAdvancedFilter
();
await
deps
.
fillDrawerTextByLabel
(
label
,
mockVal
);
await
deps
.
runMockSearch
(
mockVal
,
()
=>
deps
.
applyAdvancedFilter
());
return
;
}
if
(
/月份|日期|时间|区间/
.
test
(
label
))
{
const
pad
=
(
n
:
number
)
=>
String
(
n
).
padStart
(
2
,
'
0
'
);
const
currentDisplay
=
await
deps
.
getQuickDateRangeDisplay
(
label
).
catch
(()
=>
''
);
const
looksLikeMonthPicker
=
/月份/
.
test
(
label
)
||
(
/
\d{4}
-
\d{2}
/
.
test
(
currentDisplay
)
&&
!
/
\d{4}
-
\d{2}
-
\d{2}
/
.
test
(
currentDisplay
));
const
preferFuture
=
!!
cfg
.
mockPreferFutureDate
;
const
candidates
:
Array
<
[
string
,
string
]
>
=
[];
const
cfgStart
=
cfg
.
mockDateStart
?.
trim
();
const
cfgEnd
=
cfg
.
mockDateEnd
?.
trim
()
||
cfgStart
;
if
(
cfgStart
&&
cfgEnd
)
{
candidates
.
push
([
cfgStart
,
cfgEnd
]);
}
else
if
(
preferFuture
)
{
if
(
looksLikeMonthPicker
)
{
for
(
const
monthsAhead
of
[
1
,
2
,
3
,
6
])
{
const
d
=
new
Date
();
d
.
setMonth
(
d
.
getMonth
()
+
monthsAhead
);
const
ym
=
`
${
d
.
getFullYear
()}
-
${
pad
(
d
.
getMonth
()
+
1
)}
`
;
candidates
.
push
([
ym
,
ym
]);
}
}
else
{
for
(
const
daysAhead
of
[
1
,
2
,
7
,
14
,
30
])
{
const
d
=
new
Date
();
d
.
setDate
(
d
.
getDate
()
+
daysAhead
);
const
day
=
`
${
d
.
getFullYear
()}
-
${
pad
(
d
.
getMonth
()
+
1
)}
-
${
pad
(
d
.
getDate
())}
`
;
candidates
.
push
([
day
,
day
]);
}
}
}
else
if
(
looksLikeMonthPicker
)
{
for
(
const
monthsAgo
of
[
18
,
15
,
21
,
12
,
9
,
24
])
{
const
d
=
new
Date
();
d
.
setMonth
(
d
.
getMonth
()
-
monthsAgo
);
const
ym
=
`
${
d
.
getFullYear
()}
-
${
pad
(
d
.
getMonth
()
+
1
)}
`
;
candidates
.
push
([
ym
,
ym
]);
}
}
else
{
const
t1
=
new
Date
();
t1
.
setDate
(
t1
.
getDate
()
+
1
);
const
t2
=
new
Date
();
t2
.
setDate
(
t2
.
getDate
()
+
2
);
candidates
.
push
([
`
${
t1
.
getFullYear
()}
-
${
pad
(
t1
.
getMonth
()
+
1
)}
-
${
pad
(
t1
.
getDate
())}
`
,
`
${
t2
.
getFullYear
()}
-
${
pad
(
t2
.
getMonth
()
+
1
)}
-
${
pad
(
t2
.
getDate
())}
`
,
]);
for
(
const
monthsAgo
of
[
18
,
15
,
12
])
{
const
d
=
new
Date
();
d
.
setMonth
(
d
.
getMonth
()
-
monthsAgo
);
const
day
=
`
${
d
.
getFullYear
()}
-
${
pad
(
d
.
getMonth
()
+
1
)}
-
${
pad
(
d
.
getDate
())}
`
;
candidates
.
push
([
day
,
day
]);
}
}
let
written
=
''
;
for
(
const
[
start
,
end
]
of
candidates
)
{
try
{
await
deps
.
fillQuickDateRange
(
label
,
start
,
end
);
const
after
=
(
await
deps
.
getQuickDateRangeDisplay
(
label
).
catch
(()
=>
''
)).
replace
(
/
\s
+/g
,
''
);
if
(
!
after
.
includes
(
start
.
replace
(
/
\s
+/g
,
''
)))
continue
;
written
=
start
;
await
deps
.
runMockSearch
(
start
,
()
=>
deps
.
search
());
const
rows
=
await
deps
.
locators
.
dataRows
.
count
();
const
emptyVisible
=
await
deps
.
locators
.
report
.
locator
(
'
.ant-empty, .ant-table-placeholder
'
)
.
first
()
.
isVisible
()
.
catch
(()
=>
false
);
if
(
rows
===
0
||
emptyVisible
||
(
await
deps
.
allRowsAreZeroOrPlaceholder
()))
{
return
;
}
if
(
cfgStart
||
preferFuture
)
return
;
}
catch
{
// 尝试下一候选
}
}
if
(
!
written
)
{
if
(
cfgStart
||
preferFuture
)
{
throw
new
Error
(
preferFuture
?
`mock 反例未来日期未写入「
${
label
}
」(明日及近未来候选均失败,面板可能禁用)`
:
`mock 反例日期未写入「
${
label
}
」,期望
${
cfgStart
}
~
${
cfgEnd
||
cfgStart
}
(面板可能禁用该区间)`
,
);
}
written
=
await
deps
.
fillQuickDateRangeWithFirstEnabled
(
label
,
looksLikeMonthPicker
);
await
deps
.
runMockSearch
(
written
,
()
=>
deps
.
search
());
return
;
}
return
;
}
let
quickItem
=
deps
.
locators
.
quickFormItem
(
label
);
if
((
await
quickItem
.
count
())
===
0
)
{
for
(
const
candidate
of
mockFieldLabelCandidates
(
primaryLabel
))
{
const
probe
=
deps
.
locators
.
quickFormItem
(
candidate
);
if
((
await
probe
.
count
())
>
0
)
{
label
=
candidate
;
quickItem
=
probe
;
break
;
}
}
}
const
tryProductNameFallback
=
async
():
Promise
<
boolean
>
=>
{
const
productInput
=
deps
.
locators
.
productName
;
if
((
await
productInput
.
count
())
===
0
)
return
false
;
const
readonly
=
await
productInput
.
getAttribute
(
'
readonly
'
);
const
role
=
await
productInput
.
getAttribute
(
'
role
'
);
if
(
readonly
&&
role
!==
'
combobox
'
)
return
false
;
await
deps
.
fill
(
productInput
,
mockVal
,
'
mock反例(productNameSelector)
'
);
await
deps
.
runMockSearch
(
mockVal
,
()
=>
deps
.
search
());
return
true
;
};
if
((
await
quickItem
.
count
())
===
0
&&
cfg
.
hasAdvancedFilter
)
{
await
deps
.
openAdvancedFilter
();
await
deps
.
fillDrawerTextByLabel
(
label
,
mockVal
);
await
deps
.
runMockSearch
(
mockVal
,
()
=>
deps
.
applyAdvancedFilter
());
return
;
}
if
((
await
quickItem
.
count
())
===
0
)
{
if
(
await
tryProductNameFallback
())
return
;
throw
new
Error
(
`mock 反例未找到快捷区字段「
${
primaryLabel
}
」,请核对 data.mockFieldLabel 与页面实际筛选项`
);
}
const
textInput
=
quickItem
.
locator
(
'
input:not([type="hidden"]):not([readonly]):not([type="checkbox"]):not([type="radio"])
'
)
.
first
();
if
((
await
textInput
.
count
())
>
0
&&
(
await
textInput
.
isVisible
().
catch
(()
=>
false
)))
{
const
inSelect
=
await
textInput
.
evaluate
((
el
)
=>
!!
el
.
closest
(
'
.ant-select
'
)).
catch
(()
=>
false
);
if
(
!
inSelect
)
{
await
deps
.
fill
(
textInput
,
mockVal
,
`mock反例填写
${
label
}
`
);
await
deps
.
runMockSearch
(
mockVal
,
()
=>
deps
.
search
());
return
;
}
}
const
select
=
quickItem
.
locator
(
'
.ant-select
'
).
first
();
if
((
await
select
.
count
())
>
0
&&
(
await
select
.
isVisible
().
catch
(()
=>
false
)))
{
const
searchInput
=
quickItem
.
locator
(
'
.ant-select-selection-search-input
'
).
first
();
if
((
await
searchInput
.
count
())
>
0
)
{
await
select
.
click
();
await
deps
.
fill
(
searchInput
,
mockVal
,
`mock反例填写
${
label
}
`
);
await
searchInput
.
press
(
'
Enter
'
).
catch
(()
=>
undefined
);
await
deps
.
runMockSearch
(
mockVal
,
()
=>
deps
.
search
());
return
;
}
}
if
(
await
tryProductNameFallback
())
return
;
if
(
cfg
.
hasAdvancedFilter
)
{
await
deps
.
openAdvancedFilter
();
await
deps
.
fillDrawerTextByLabel
(
label
,
mockVal
);
await
deps
.
runMockSearch
(
mockVal
,
()
=>
deps
.
applyAdvancedFilter
());
return
;
}
await
deps
.
fillQuickFormText
(
label
,
mockVal
);
const
verifyInput
=
quickItem
.
locator
(
'
input:not([type="hidden"]):not([readonly]):not([type="checkbox"]):not([type="radio"])
'
)
.
first
();
if
((
await
verifyInput
.
count
())
>
0
)
{
const
inSelect
=
await
verifyInput
.
evaluate
((
el
)
=>
!!
el
.
closest
(
'
.ant-select
'
)).
catch
(()
=>
false
);
if
(
!
inSelect
)
{
const
written
=
((
await
verifyInput
.
inputValue
().
catch
(()
=>
''
))
||
''
).
replace
(
/
\s
+/g
,
''
);
if
(
!
written
.
includes
(
mockVal
.
replace
(
/
\s
+/g
,
''
)))
{
throw
new
Error
(
`mock 反例「
${
label
}
」未写入成功,期望含
${
mockVal
}
,实际「
${
written
}
」`
);
}
}
}
await
deps
.
runMockSearch
(
mockVal
,
()
=>
deps
.
search
());
}
pages/spd/recon-cost-report-shared.page.ts
View file @
ed57ab23
...
...
@@ -209,194 +209,6 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
return
readReportFooterText
(
this
.
locators
.
report
,
keywords
);
}
/** CSV 008:按 data.mockFieldLabel + mockNotExistProduct 反例查询(单次写入并查询,不跨页探测)。 */
async
searchWithMockFromConfig
():
Promise
<
void
>
{
const
cfg
=
this
.
data
as
ReportPageData
&
{
mockFieldInDrawer
?:
boolean
;
mockNotExistDept
?:
string
;
mockNotExistSupplier
?:
string
;
mockNotExistCode
?:
string
;
};
const
label
=
cfg
.
mockFieldLabel
??
'
产品名称
'
;
const
mockVal
=
(
label
.
includes
(
'
科室
'
)
?
cfg
.
mockNotExistDept
:
undefined
)
??
(
label
.
includes
(
'
供应商
'
)
?
cfg
.
mockNotExistSupplier
:
undefined
)
??
(
cfg
.
mockNotExistCode
&&
/编码|代码|医保码|HIS/i
.
test
(
label
)
?
cfg
.
mockNotExistCode
:
undefined
)
??
this
.
data
.
mockNotExistProduct
;
if
(
cfg
.
mockFieldInDrawer
&&
this
.
data
.
hasAdvancedFilter
)
{
await
this
.
openAdvancedFilter
();
await
this
.
fillDrawerTextByLabel
(
label
,
mockVal
);
await
this
.
applyAdvancedFilter
();
return
;
}
// 日期/时间/月份类:
// - 若 data 配置了 mockDateStart/mockDateEnd,优先使用(如结算月份 2035-01~2035-02)
// - mockPreferFutureDate:仅试明日/近未来(不回退历史日)
// - 月份:未来月常不可选 → 约 18 个月前单月(起=止)
// - 日:优先明日(快且常无数据);失败再用约 18 个月前单日
// - 标签含「日期」也可能是月份控件,以当前展示是否含日为准
if
(
/月份|日期|时间|区间/
.
test
(
label
))
{
const
pad
=
(
n
:
number
)
=>
String
(
n
).
padStart
(
2
,
'
0
'
);
const
currentDisplay
=
await
this
.
getQuickDateRangeDisplay
(
label
).
catch
(()
=>
''
);
const
looksLikeMonthPicker
=
/月份/
.
test
(
label
)
||
(
/
\d{4}
-
\d{2}
/
.
test
(
currentDisplay
)
&&
!
/
\d{4}
-
\d{2}
-
\d{2}
/
.
test
(
currentDisplay
));
const
preferFuture
=
!!
(
cfg
as
ReportPageData
).
mockPreferFutureDate
;
const
candidates
:
Array
<
[
string
,
string
]
>
=
[];
const
cfgStart
=
(
cfg
as
ReportPageData
).
mockDateStart
?.
trim
();
const
cfgEnd
=
(
cfg
as
ReportPageData
).
mockDateEnd
?.
trim
()
||
cfgStart
;
if
(
cfgStart
&&
cfgEnd
)
{
candidates
.
push
([
cfgStart
,
cfgEnd
]);
}
else
if
(
preferFuture
)
{
if
(
looksLikeMonthPicker
)
{
for
(
const
monthsAhead
of
[
1
,
2
,
3
,
6
])
{
const
d
=
new
Date
();
d
.
setMonth
(
d
.
getMonth
()
+
monthsAhead
);
const
ym
=
`
${
d
.
getFullYear
()}
-
${
pad
(
d
.
getMonth
()
+
1
)}
`
;
candidates
.
push
([
ym
,
ym
]);
}
}
else
{
for
(
const
daysAhead
of
[
1
,
2
,
7
,
14
,
30
])
{
const
d
=
new
Date
();
d
.
setDate
(
d
.
getDate
()
+
daysAhead
);
const
day
=
`
${
d
.
getFullYear
()}
-
${
pad
(
d
.
getMonth
()
+
1
)}
-
${
pad
(
d
.
getDate
())}
`
;
candidates
.
push
([
day
,
day
]);
}
}
}
else
if
(
looksLikeMonthPicker
)
{
for
(
const
monthsAgo
of
[
18
,
15
,
21
,
12
,
9
,
24
])
{
const
d
=
new
Date
();
d
.
setMonth
(
d
.
getMonth
()
-
monthsAgo
);
const
ym
=
`
${
d
.
getFullYear
()}
-
${
pad
(
d
.
getMonth
()
+
1
)}
`
;
candidates
.
push
([
ym
,
ym
]);
}
}
else
{
const
t1
=
new
Date
();
t1
.
setDate
(
t1
.
getDate
()
+
1
);
const
t2
=
new
Date
();
t2
.
setDate
(
t2
.
getDate
()
+
2
);
candidates
.
push
([
`
${
t1
.
getFullYear
()}
-
${
pad
(
t1
.
getMonth
()
+
1
)}
-
${
pad
(
t1
.
getDate
())}
`
,
`
${
t2
.
getFullYear
()}
-
${
pad
(
t2
.
getMonth
()
+
1
)}
-
${
pad
(
t2
.
getDate
())}
`
,
]);
for
(
const
monthsAgo
of
[
18
,
15
,
12
])
{
const
d
=
new
Date
();
d
.
setMonth
(
d
.
getMonth
()
-
monthsAgo
);
const
day
=
`
${
d
.
getFullYear
()}
-
${
pad
(
d
.
getMonth
()
+
1
)}
-
${
pad
(
d
.
getDate
())}
`
;
candidates
.
push
([
day
,
day
]);
}
}
let
written
=
''
;
for
(
const
[
start
,
end
]
of
candidates
)
{
try
{
await
this
.
fillQuickDateRange
(
label
,
start
,
end
);
const
after
=
(
await
this
.
getQuickDateRangeDisplay
(
label
).
catch
(()
=>
''
)).
replace
(
/
\s
+/g
,
''
);
if
(
!
after
.
includes
(
start
.
replace
(
/
\s
+/g
,
''
)))
continue
;
written
=
start
;
await
this
.
search
();
const
rows
=
await
this
.
locators
.
dataRows
.
count
();
const
emptyVisible
=
await
this
.
locators
.
report
.
locator
(
'
.ant-empty, .ant-table-placeholder
'
)
.
first
()
.
isVisible
()
.
catch
(()
=>
false
);
if
(
rows
===
0
||
emptyVisible
||
(
await
this
.
allRowsAreZeroOrPlaceholder
()))
{
return
;
}
// 显式配置区间 / 仅未来:写成功即返回,不再轮询其它候选
if
(
cfgStart
||
preferFuture
)
return
;
}
catch
{
// 尝试下一候选
}
}
if
(
!
written
)
{
if
(
cfgStart
||
preferFuture
)
{
throw
new
Error
(
preferFuture
?
`mock 反例未来日期未写入「
${
label
}
」(明日及近未来候选均失败,面板可能禁用)`
:
`mock 反例日期未写入「
${
label
}
」,期望
${
cfgStart
}
~
${
cfgEnd
||
cfgStart
}
(面板可能禁用该区间)`
,
);
}
written
=
await
this
.
fillQuickDateRangeWithFirstEnabled
(
label
,
looksLikeMonthPicker
);
await
this
.
search
();
return
;
}
// 候选均有数据:保留最后一次写入,交由 expectMockEmptyList 断言
return
;
}
const
quickItem
=
this
.
locators
.
quickFormItem
(
label
);
if
((
await
quickItem
.
count
())
===
0
&&
this
.
data
.
hasAdvancedFilter
)
{
await
this
.
openAdvancedFilter
();
await
this
.
fillDrawerTextByLabel
(
label
,
mockVal
);
await
this
.
applyAdvancedFilter
();
return
;
}
if
((
await
quickItem
.
count
())
===
0
)
{
throw
new
Error
(
`mock 反例未找到快捷区字段「
${
label
}
」,请核对 data.mockFieldLabel 与页面实际筛选项`
);
}
const
textInput
=
quickItem
.
locator
(
'
input:not([type="hidden"]):not([readonly]):not([type="checkbox"]):not([type="radio"])
'
)
.
first
();
// 优先文本框写入;排除 ant-select 内 search-input(无法作为查询条件提交)
if
((
await
textInput
.
count
())
>
0
&&
(
await
textInput
.
isVisible
().
catch
(()
=>
false
)))
{
const
inSelect
=
await
textInput
.
evaluate
((
el
)
=>
!!
el
.
closest
(
'
.ant-select
'
)).
catch
(()
=>
false
);
if
(
!
inSelect
)
{
await
this
.
fill
(
textInput
,
mockVal
,
`mock反例填写
${
label
}
`
);
await
this
.
search
();
return
;
}
}
const
select
=
quickItem
.
locator
(
'
.ant-select
'
).
first
();
if
((
await
select
.
count
())
>
0
&&
(
await
select
.
isVisible
().
catch
(()
=>
false
)))
{
const
searchInput
=
quickItem
.
locator
(
'
.ant-select-selection-search-input
'
).
first
();
if
((
await
searchInput
.
count
())
>
0
)
{
await
select
.
click
();
await
this
.
fill
(
searchInput
,
mockVal
,
`mock反例填写
${
label
}
`
);
await
searchInput
.
press
(
'
Enter
'
).
catch
(()
=>
undefined
);
await
this
.
search
();
return
;
}
}
const
productInput
=
this
.
locators
.
productName
;
if
((
await
productInput
.
count
())
>
0
&&
(
await
productInput
.
isVisible
().
catch
(()
=>
false
)))
{
const
readonly
=
await
productInput
.
getAttribute
(
'
readonly
'
);
const
role
=
await
productInput
.
getAttribute
(
'
role
'
);
if
(
!
readonly
&&
role
!==
'
combobox
'
)
{
await
this
.
fill
(
productInput
,
mockVal
,
'
mock反例
'
);
await
this
.
search
();
return
;
}
}
if
(
this
.
data
.
hasAdvancedFilter
)
{
await
this
.
openAdvancedFilter
();
await
this
.
fillDrawerTextByLabel
(
label
,
mockVal
);
await
this
.
applyAdvancedFilter
();
return
;
}
await
this
.
fillQuickFormText
(
label
,
mockVal
);
const
verifyInput
=
quickItem
.
locator
(
'
input:not([type="hidden"]):not([readonly]):not([type="checkbox"]):not([type="radio"])
'
)
.
first
();
if
((
await
verifyInput
.
count
())
>
0
)
{
const
inSelect
=
await
verifyInput
.
evaluate
((
el
)
=>
!!
el
.
closest
(
'
.ant-select
'
)).
catch
(()
=>
false
);
if
(
!
inSelect
)
{
const
written
=
((
await
verifyInput
.
inputValue
().
catch
(()
=>
''
))
||
''
).
replace
(
/
\s
+/g
,
''
);
if
(
!
written
.
includes
(
mockVal
.
replace
(
/
\s
+/g
,
''
)))
{
throw
new
Error
(
`mock 反例「
${
label
}
」未写入成功,期望含
${
mockVal
}
,实际「
${
written
}
」`
);
}
}
}
await
this
.
search
();
}
/** CSV 005:领域组合 / 单字段精确查询。 */
async
searchWithDomainCombo
(
quickFields
:
readonly
DeptQuickFieldDef
[],
...
...
pages/spd/surgery-base-pkg.page.ts
View file @
ed57ab23
...
...
@@ -22,10 +22,6 @@ export class SurgeryBasePkgPage extends SurgerySharedPage {
// ---- 定位 ----
categorySearch
()
{
return
this
.
page
.
getByPlaceholder
(
'
请输入分类名称
'
).
first
();
}
categoryTree
()
{
return
this
.
locators
.
leftTree
;
}
...
...
@@ -54,8 +50,7 @@ export class SurgeryBasePkgPage extends SurgerySharedPage {
/** CSV SURG-BASE-001:按分类名称过滤左树。 */
async
searchCategory
(
keyword
:
string
):
Promise
<
void
>
{
await
this
.
fill
(
this
.
categorySearch
(),
keyword
,
`填写分类名称「
${
keyword
}
」`
);
await
this
.
waitSettle
(
600
);
await
this
.
searchTree
(
keyword
);
}
/** CSV SURG-BASE-003:打开新增分类弹窗。 */
...
...
pages/spd/surgery-oper-type.page.ts
View file @
ed57ab23
...
...
@@ -22,10 +22,6 @@ export class SurgeryOperTypePage extends SurgerySharedPage {
// ---- 定位 ----
categorySearch
()
{
return
this
.
page
.
getByPlaceholder
(
/请输入.*术种分类名称|请输入分类名称/
).
first
();
}
addBtn
()
{
return
this
.
locators
.
button
(
'
新 增
'
).
or
(
this
.
locators
.
button
(
'
新增
'
)).
first
();
}
...
...
@@ -43,18 +39,19 @@ export class SurgeryOperTypePage extends SurgerySharedPage {
}
statusTab
(
name
:
string
):
Locator
{
return
this
.
page
const
scope
=
this
.
page
.
locator
(
'
.operation-right, .xc-layout-lr-right, .xc-content
'
).
first
();
const
compact
=
name
.
replace
(
/
\s
/g
,
''
);
const
pattern
=
new
RegExp
(
`^\\s*
${
compact
.
split
(
''
).
join
(
'
\\
s*
'
)}
(\\s*[((]|\\s*$)`
);
return
scope
.
locator
(
'
.ant-radio-button-wrapper, .ant-tabs-tab, .ant-segmented-item
'
)
.
filter
({
hasText
:
new
RegExp
(
name
)
})
.
first
()
.
or
(
this
.
locators
.
tab
(
name
));
.
filter
({
hasText
:
pattern
})
.
first
();
}
// ---- 左树分类 ----
async
searchCategory
(
keyword
:
string
):
Promise
<
void
>
{
await
this
.
fill
(
this
.
categorySearch
(),
keyword
,
`填写术种分类名称「
${
keyword
}
」`
);
await
this
.
waitSettle
(
600
);
await
this
.
searchTree
(
keyword
);
}
/** CSV SURG-TYPE-007:打开分类维护弹窗。 */
...
...
pages/spd/surgery-shared.page.ts
View file @
ed57ab23
...
...
@@ -2,6 +2,13 @@ import { expect, type Locator, type Page } from '@playwright/test';
import
{
env
}
from
'
../../config/env.config
'
;
import
{
BasePage
}
from
'
../../core/base.page
'
;
import
{
columnHeaderTextsMatch
}
from
'
../../core/column-header-match.util
'
;
import
{
dataHeaderLabelsFromCells
,
normalizeSampleCellValue
,
readRowCellByColumn
,
resolveColumnIndexFromLabels
,
}
from
'
../../core/table-data-column.util
'
;
import
{
SurgerySharedLocators
}
from
'
../../locators/spd/surgery-shared.locator
'
;
export
type
SurgeryMeta
=
{
...
...
@@ -71,7 +78,9 @@ export class SurgerySharedPage extends BasePage {
/** 等左树或主表挂载,避免菜单切入后尚未渲染。 */
async
waitContentReady
():
Promise
<
void
>
{
const
markers
=
this
.
page
.
locator
(
'
.ant-tree, .ant-table-thead th, .ant-table-header th, thead th, .ant-empty
'
)
.
locator
(
'
ul.type-ul, .operation-left, .xc-layout-lr-left, .ant-tree, .ant-table-thead th, .ant-table-header th, thead th, .ant-empty
'
,
)
.
first
();
await
markers
.
waitFor
({
state
:
'
visible
'
,
timeout
:
20
_000
}).
catch
(()
=>
undefined
);
await
this
.
waitForTableReady
({
health
:
'
ui
'
});
...
...
@@ -84,7 +93,9 @@ export class SurgerySharedPage extends BasePage {
expect
(
this
.
page
.
url
(),
`不应误入
${
part
}
`
).
not
.
toContain
(
part
);
}
await
this
.
waitContentReady
();
const
hasTree
=
await
this
.
locators
.
leftTree
.
isVisible
().
catch
(()
=>
false
);
const
hasTree
=
(
await
this
.
locators
.
leftTree
.
isVisible
().
catch
(()
=>
false
))
||
(
await
this
.
page
.
locator
(
'
ul.type-ul
'
).
first
().
isVisible
().
catch
(()
=>
false
));
const
hasHeader
=
(
await
this
.
locators
.
tableHeader
.
count
())
>
0
;
const
hasEmpty
=
await
this
.
page
.
getByText
(
'
暂无数据
'
).
first
().
isVisible
().
catch
(()
=>
false
);
expect
(
hasTree
||
hasHeader
||
hasEmpty
,
'
左侧分类树、列表表头或空态至少其一可见
'
).
toBeTruthy
();
...
...
@@ -94,7 +105,14 @@ export class SurgerySharedPage extends BasePage {
async
switchTab
(
name
:
string
,
expectPathPart
?:
string
):
Promise
<
void
>
{
await
this
.
assertBeforeBusinessAction
(
`切换 Tab「
${
name
}
」`
);
await
this
.
click
(
this
.
locators
.
tab
(
name
),
`点击 Tab「
${
name
}
」`
);
const
headerTab
=
this
.
locators
.
tab
(
name
);
if
(
await
headerTab
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
headerTab
,
`点击 Tab「
${
name
}
」`
);
}
else
{
const
leaf
=
this
.
locators
.
menuLeaf
(
name
);
await
leaf
.
scrollIntoViewIfNeeded
().
catch
(()
=>
undefined
);
await
this
.
click
(
leaf
,
`侧栏进入「
${
name
}
」`
);
}
if
(
expectPathPart
)
{
await
this
.
page
.
waitForURL
((
url
)
=>
url
.
pathname
.
includes
(
expectPathPart
),
{
timeout
:
20
_000
}).
catch
(()
=>
undefined
);
}
...
...
@@ -122,18 +140,25 @@ export class SurgerySharedPage extends BasePage {
// ---------- 左侧分类树 ----------
/** 术种页为 ul.type-ul;基础包/标准套包仍为 ant-tree。 */
async
activeTreeNodes
():
Promise
<
Locator
>
{
const
listNodes
=
this
.
page
.
locator
(
'
ul.type-ul li.type-li
'
);
if
((
await
listNodes
.
count
())
>
0
)
return
listNodes
;
return
this
.
locators
.
leftTree
.
locator
(
'
.ant-tree-node-content-wrapper
'
);
}
treeNodes
():
Locator
{
return
this
.
locator
s
.
treeNodes
(
);
return
this
.
page
.
locator
(
'
ul.type-ul li.type-li
'
);
}
async
getTreeCount
():
Promise
<
number
>
{
return
this
.
t
reeNodes
().
count
();
return
(
await
this
.
activeT
reeNodes
()
)
.
count
();
}
/** 左树快照(节点数 + 文本)。 */
async
snapshotTree
():
Promise
<
TreeSnapshot
>
{
await
this
.
waitSettle
(
200
);
const
nodes
=
this
.
locators
.
t
reeNodes
();
const
nodes
=
await
this
.
activeT
reeNodes
();
const
count
=
await
nodes
.
count
();
const
texts
:
string
[]
=
[];
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
...
...
@@ -153,7 +178,7 @@ export class SurgerySharedPage extends BasePage {
/** 读取首个带括号计数的节点:名称与计数(CSV SURG-TYPE-006)。 */
async
firstCountedTreeNode
():
Promise
<
{
name
:
string
;
count
:
number
}
|
null
>
{
const
nodes
=
this
.
locators
.
t
reeNodes
();
const
nodes
=
await
this
.
activeT
reeNodes
();
const
count
=
Math
.
min
(
await
nodes
.
count
(),
30
);
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
const
text
=
((
await
nodes
.
nth
(
i
).
innerText
().
catch
(()
=>
''
))
??
''
).
replace
(
/
\s
/g
,
''
);
...
...
@@ -165,28 +190,108 @@ export class SurgerySharedPage extends BasePage {
return
null
;
}
/** 选中首个「括号计数与右侧列表总数一致」的分类(规避树计数与列表不同步的脏数据)。 */
async
firstCountedTreeNodeMatchingList
():
Promise
<
{
name
:
string
;
count
:
number
}
|
null
>
{
const
nodes
=
await
this
.
activeTreeNodes
();
const
limit
=
Math
.
min
(
await
nodes
.
count
(),
30
);
for
(
let
i
=
0
;
i
<
limit
;
i
+=
1
)
{
const
text
=
((
await
nodes
.
nth
(
i
).
innerText
().
catch
(()
=>
''
))
??
''
).
replace
(
/
\s
/g
,
''
);
const
match
=
text
.
match
(
/^
(
.*
?)[
((
](\d
+
)[
))
]
$/
);
if
(
!
match
?.[
1
])
continue
;
const
name
=
match
[
1
];
const
expected
=
Number
(
match
[
2
]);
await
this
.
clickTreeNode
(
name
);
await
this
.
waitSettle
(
400
);
const
total
=
await
this
.
getTotalCount
();
const
rows
=
await
this
.
getDataRowCount
();
if
(
total
===
expected
||
rows
===
expected
)
{
return
{
name
,
count
:
expected
};
}
}
return
null
;
}
/** 左树节点文案常带「名称(数量)」,过滤框只需名称部分。 */
treeFilterKeyword
(
raw
:
string
):
string
{
const
compact
=
raw
.
replace
(
/
\s
/g
,
''
);
const
matched
=
compact
.
match
(
/^
(
.*
?)[
((
]\d
+
[
))
]
$/
);
return
matched
?.[
1
]
??
compact
;
}
async
triggerTreeSearch
(
input
:
Locator
):
Promise
<
void
>
{
const
leftSearch
=
this
.
locators
.
leftTree
.
locator
(
'
.anticon-search, img[alt="search"]
'
).
first
();
if
(
await
leftSearch
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
leftSearch
,
'
分类搜索
'
);
await
this
.
waitSettle
(
400
);
return
;
}
const
host
=
input
.
locator
(
'
xpath=ancestor::*[.//img[@alt="search"] or .//*[@aria-label="search"]][1]
'
);
const
scoped
=
host
.
locator
(
'
img[alt="search"], [aria-label="search"]
'
).
first
();
if
(
await
scoped
.
isVisible
().
catch
(()
=>
false
))
{
await
scoped
.
click
({
force
:
true
});
await
this
.
waitSettle
(
400
);
return
;
}
await
input
.
press
(
'
Enter
'
).
catch
(()
=>
undefined
);
}
async
searchTree
(
keyword
:
string
):
Promise
<
void
>
{
await
this
.
locators
.
leftTree
.
waitFor
({
state
:
'
visible
'
,
timeout
:
15
_000
}).
catch
(()
=>
undefined
);
const
compact
=
keyword
.
replace
(
/
\s
/g
,
''
);
const
needle
=
this
.
treeFilterKeyword
(
keyword
);
const
candidates
=
[
needle
,
compact
].
filter
((
v
,
i
,
arr
)
=>
v
&&
arr
.
indexOf
(
v
)
===
i
);
if
(
needle
.
length
>
4
)
candidates
.
push
(
needle
.
slice
(
0
,
Math
.
min
(
needle
.
length
,
8
)));
const
input
=
this
.
treeSearch
();
await
this
.
fill
(
input
,
keyword
,
`填写分类名称搜索「
${
keyword
}
」`
);
await
input
.
press
(
'
Enter
'
).
catch
(()
=>
undefined
);
await
this
.
waitSettle
(
600
);
const
nameNeedle
=
this
.
treeFilterKeyword
(
keyword
).
replace
(
/
\s
/g
,
''
);
for
(
const
value
of
candidates
)
{
await
input
.
fill
(
''
);
await
this
.
fill
(
input
,
value
,
`填写分类名称搜索「
${
value
}
」`
);
await
this
.
triggerTreeSearch
(
input
);
await
this
.
waitSettle
(
1200
);
const
snap
=
await
this
.
snapshotTree
();
if
(
snap
.
nodes
===
0
)
continue
;
if
(
snap
.
texts
.
every
((
t
)
=>
t
.
replace
(
/
\s
/g
,
''
).
includes
(
nameNeedle
)
||
nameNeedle
.
includes
(
t
.
replace
(
/
\s
/g
,
''
))))
{
return
;
}
}
}
async
clearTreeSearch
():
Promise
<
void
>
{
const
input
=
this
.
treeSearch
();
await
input
.
fill
(
''
);
await
input
.
press
(
'
Enter
'
).
catch
(()
=>
undefined
);
await
this
.
triggerTreeSearch
(
input
);
await
this
.
waitSettle
(
600
);
}
/** 树过滤框:按 placeholder 精确优先,回退到左树临近输入。 */
/** 左树分类搜索框(spec 可见性断言用)。 */
categorySearch
():
Locator
{
return
this
.
treeSearch
();
}
/** 分类搜索框:限定左栏,避免与右侧术种名称筛选混淆。 */
treeSearch
():
Locator
{
const
byPlaceholder
=
this
.
page
.
getByPlaceholder
(
/请输入.*分类名称/
).
first
();
return
byPlaceholder
.
or
(
this
.
locators
.
treeSearchInput
);
return
this
.
locators
.
leftTree
.
getByPlaceholder
(
/请输入术种分类名称|请输入分类名称|术种分类名称/
)
.
first
()
.
or
(
this
.
page
.
getByRole
(
'
textbox
'
,
{
name
:
/请输入分类名称|请输入术种分类名称|术种分类名称、术种名称、标准套包名称/
,
}).
first
(),
);
}
async
ensureFiltersExpanded
():
Promise
<
void
>
{
const
expand
=
this
.
locators
.
button
(
'
展开
'
).
first
();
if
(
await
expand
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
expand
,
'
展开筛选
'
);
await
this
.
waitSettle
(
200
);
}
}
async
clickTreeNode
(
name
:
string
):
Promise
<
void
>
{
await
this
.
click
(
this
.
locators
.
treeNode
(
name
),
`选择分类「
${
name
}
」`
);
const
needle
=
this
.
treeFilterKeyword
(
name
);
const
node
=
this
.
locators
.
treeNode
(
needle
).
or
(
this
.
locators
.
treeNode
(
name
));
await
this
.
click
(
node
.
first
(),
`选择分类「
${
needle
}
」`
);
await
this
.
waitForTableReady
();
}
...
...
@@ -220,6 +325,7 @@ export class SurgerySharedPage extends BasePage {
}
async
fillFilterById
(
id
:
string
,
keyword
:
string
):
Promise
<
void
>
{
await
this
.
ensureFiltersExpanded
();
await
this
.
fill
(
this
.
filterById
(
id
),
keyword
,
`填写筛选「
${
id
}
」`
);
}
...
...
@@ -270,20 +376,24 @@ export class SurgerySharedPage extends BasePage {
return
this
.
locators
.
tableHeader
;
}
async
dataHeaderLabels
():
Promise
<
string
[]
>
{
return
dataHeaderLabelsFromCells
(
await
this
.
headerCells
());
}
async
headerTexts
():
Promise
<
string
[]
>
{
return
(
await
(
await
this
.
headerCells
()).
allTextContents
()).
map
((
item
)
=>
item
.
replace
(
/
\s
/g
,
''
)
);
return
this
.
dataHeaderLabels
(
);
}
async
headerIndex
(
name
:
string
):
Promise
<
number
>
{
const
texts
=
await
this
.
headerText
s
();
const
index
=
texts
.
findIndex
((
item
)
=>
item
.
includes
(
name
.
replace
(
/
\s
/g
,
''
))
);
const
texts
=
await
this
.
dataHeaderLabel
s
();
const
index
=
resolveColumnIndexFromLabels
(
texts
,
name
);
expect
(
index
,
`找不到列「
${
name
}
」`
).
toBeGreaterThanOrEqual
(
0
);
return
index
;
}
async
cellText
(
row
:
Locator
,
header
:
string
):
Promise
<
string
>
{
const
index
=
await
this
.
headerIndex
(
header
);
return
((
await
row
.
locator
(
'
td
'
).
nth
(
index
).
innerText
())
??
''
).
trim
(
);
const
labels
=
await
this
.
dataHeaderLabels
(
);
return
readRowCellByColumn
(
row
,
labels
,
header
);
}
async
tryCell
(
row
:
Locator
,
header
:
string
):
Promise
<
string
>
{
...
...
@@ -305,8 +415,8 @@ export class SurgerySharedPage extends BasePage {
const
rows
=
this
.
dataRows
();
const
count
=
Math
.
min
(
await
rows
.
count
(),
8
);
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
const
text
=
(
await
this
.
tryCell
(
rows
.
nth
(
i
),
header
))
.
replace
(
/
\s
/g
,
''
)
;
if
(
text
&&
text
!==
'
--
'
&&
text
!==
'
-
'
)
return
text
;
const
text
=
normalizeSampleCellValue
(
await
this
.
tryCell
(
rows
.
nth
(
i
),
header
));
if
(
text
)
return
text
;
}
await
this
.
skipWhenDataInsufficient
(
`首屏「
${
header
}
」为空`
);
return
''
;
...
...
@@ -399,7 +509,7 @@ export class SurgerySharedPage extends BasePage {
rowAction
(
row
:
Locator
,
name
:
string
):
Locator
{
const
compact
=
name
.
replace
(
/
\s
/g
,
''
);
const
pattern
=
new
RegExp
(
compact
.
split
(
''
).
join
(
'
\\
s*
'
));
return
row
.
getByRole
(
'
button
'
,
{
name
:
pattern
}).
or
(
row
.
getByText
(
pattern
)).
first
();
return
row
.
locator
(
'
td
'
).
last
().
getByRole
(
'
button
'
,
{
name
:
pattern
}).
first
();
}
async
rowWithAction
(
name
:
string
):
Promise
<
Locator
|
null
>
{
...
...
pages/spd/surgery-std-pkg.page.ts
View file @
ed57ab23
...
...
@@ -22,10 +22,6 @@ export class SurgeryStdPkgPage extends SurgerySharedPage {
// ---- 定位 ----
categorySearch
()
{
return
this
.
page
.
getByPlaceholder
(
'
请输入术种分类名称、术种名称、标准套包名称
'
).
first
();
}
addBasePkgBtn
()
{
return
this
.
locators
.
button
(
'
添加基础包
'
).
first
();
}
...
...
@@ -49,8 +45,7 @@ export class SurgeryStdPkgPage extends SurgerySharedPage {
// ---- 左树分类 ----
async
searchCategory
(
keyword
:
string
):
Promise
<
void
>
{
await
this
.
fill
(
this
.
categorySearch
(),
keyword
,
`填写术种分类名称「
${
keyword
}
」`
);
await
this
.
waitSettle
(
600
);
await
this
.
searchTree
(
keyword
);
}
// ---- 筛选 ----
...
...
pages/spd/vendor-audit.page.ts
View file @
ed57ab23
...
...
@@ -82,8 +82,10 @@ export class VendorAuditPage extends VendorSharedPage {
}
async
mockEmpty
():
Promise
<
void
>
{
await
this
.
fillFilter
(
'
单号
'
,
vendorData
.
mockOrder
);
await
this
.
query
();
await
this
.
runMockEmptyQuery
(
vendorData
.
mockOrder
,
async
()
=>
{
await
this
.
fillFilter
(
'
单号
'
,
vendorData
.
mockOrder
);
await
this
.
query
();
});
}
async
expectCoreColumns
():
Promise
<
void
>
{
...
...
pages/spd/vendor-eval-indicator.page.ts
View file @
ed57ab23
...
...
@@ -44,13 +44,17 @@ export class VendorEvalIndicatorPage extends VendorSharedPage {
}
async
mockEmpty
():
Promise
<
void
>
{
const
filterLabel
=
(
await
this
.
textInput
(
'
指标编码
'
).
isVisible
().
catch
(()
=>
false
))
?
'
指标编码
'
:
'
指标编号
'
;
if
(
!
(
await
this
.
textInput
(
filterLabel
).
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
skipWhenDataInsufficient
(
'
无指标编码/编号筛选框
'
);
return
;
}
await
this
.
fillFilter
(
filterLabel
,
vendorData
.
mockOrder
);
await
this
.
query
();
await
this
.
runMockEmptyQuery
(
vendorData
.
mockOrder
,
async
()
=>
{
const
filterLabel
=
(
await
this
.
textInput
(
'
指标编码
'
).
isVisible
().
catch
(()
=>
false
))
?
'
指标编码
'
:
'
指标编号
'
;
if
(
!
(
await
this
.
textInput
(
filterLabel
).
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
skipWhenDataInsufficient
(
'
无指标编码/编号筛选框
'
);
return
;
}
await
this
.
fillFilter
(
filterLabel
,
vendorData
.
mockOrder
);
await
this
.
query
();
});
await
this
.
expectMockEmpty
();
}
...
...
pages/spd/vendor-eval-scheme.page.ts
View file @
ed57ab23
...
...
@@ -42,15 +42,22 @@ export class VendorEvalSchemePage extends VendorSharedPage {
}
async
mockEmpty
():
Promise
<
void
>
{
if
(
await
this
.
textInput
(
'
方案编码
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
'
方案编码
'
,
vendorData
.
mockOrder
);
}
else
if
(
await
this
.
textInput
(
'
方案名称
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
'
方案名称
'
,
vendorData
.
mockOrder
);
}
else
{
await
this
.
skipWhenDataInsufficient
(
'
无方案编码/名称筛选框
'
);
return
;
}
await
this
.
query
();
await
this
.
runMockEmptyQuery
(
vendorData
.
mockOrder
,
async
()
=>
{
const
expand
=
this
.
button
(
'
展开
'
).
first
();
if
(
await
expand
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
expand
,
'
展开筛选
'
);
await
this
.
waitSettle
(
300
);
}
if
(
await
this
.
textInput
(
'
方案编码
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
'
方案编码
'
,
vendorData
.
mockOrder
);
}
else
if
(
await
this
.
textInput
(
'
方案名称
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
'
方案名称
'
,
vendorData
.
mockOrder
);
}
else
{
await
this
.
skipWhenDataInsufficient
(
'
无方案编码/名称筛选框
'
);
return
;
}
await
this
.
query
();
});
}
async
expectCoreColumns
():
Promise
<
void
>
{
...
...
pages/spd/vendor-qual.page.ts
View file @
ed57ab23
...
...
@@ -49,14 +49,16 @@ export class VendorQualPage extends VendorSharedPage {
await
this
.
query
();
const
supplier
=
await
this
.
sampleCell
(
'
进院供应商
'
);
const
keyword
=
supplier
.
slice
(
0
,
Math
.
min
(
supplier
.
length
,
10
));
if
(
await
this
.
textInput
(
'
进院供应商名称
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
'
进院供应商名称
'
,
keyword
);
}
else
if
(
await
this
.
textInput
(
'
进院供应商
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
'
进院供应商
'
,
keyword
);
}
else
{
await
this
.
skipWhenDataInsufficient
(
'
无进院供应商名称筛选框
'
);
const
supplierFilterLabels
=
[
'
进院供应商名称
'
,
'
进院供应商
'
,
'
销售商名称
'
,
'
销售商
'
,
'
供应商名称
'
];
await
this
.
ensureFiltersExpanded
();
const
hasSupplierFilter
=
await
Promise
.
all
(
supplierFilterLabels
.
map
(
async
(
label
)
=>
this
.
textInput
(
label
).
isVisible
().
catch
(()
=>
false
)),
).
then
((
flags
)
=>
flags
.
some
(
Boolean
));
if
(
!
hasSupplierFilter
)
{
await
this
.
skipWhenDataInsufficient
(
'
无进院供应商/销售商筛选框
'
);
return
;
}
await
this
.
fillSupplierFilter
(
keyword
);
await
this
.
query
();
await
this
.
expectRowsContain
(
'
进院供应商
'
,
keyword
);
}
...
...
@@ -70,15 +72,17 @@ export class VendorQualPage extends VendorSharedPage {
}
async
mockEmpty
():
Promise
<
void
>
{
if
(
await
this
.
textInput
(
'
进院供应商名称
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
'
进院供应商名称
'
,
vendorData
.
mockSupplier
);
}
else
if
(
await
this
.
textInput
(
'
进院供应商
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
'
进院供应商
'
,
vendorData
.
mockSupplier
);
}
else
{
await
this
.
skipWhenDataInsufficient
(
'
无进院供应商筛选框可填 mock
'
);
return
;
}
await
this
.
query
();
await
this
.
runMockEmptyQuery
(
vendorData
.
mockSupplier
,
async
()
=>
{
if
(
await
this
.
textInput
(
'
进院供应商名称
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
'
进院供应商名称
'
,
vendorData
.
mockSupplier
);
}
else
if
(
await
this
.
textInput
(
'
进院供应商
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
'
进院供应商
'
,
vendorData
.
mockSupplier
);
}
else
{
await
this
.
skipWhenDataInsufficient
(
'
无进院供应商筛选框可填 mock
'
);
return
;
}
await
this
.
query
();
});
}
async
expectCoreColumns
():
Promise
<
void
>
{
...
...
@@ -108,7 +112,12 @@ export class VendorQualPage extends VendorSharedPage {
/** CSV VENDOR-QUL-011 */
async
filterSameDayExpiryOrMakeDate
():
Promise
<
void
>
{
if
(
await
this
.
page
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
'
有效期
'
}).
first
().
isVisible
().
catch
(()
=>
false
))
{
const
expand
=
this
.
button
(
'
展开
'
).
first
();
if
(
await
expand
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
expand
,
'
展开筛选
'
);
await
this
.
waitSettle
(
300
);
}
if
(
await
this
.
page
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
/有效期限|有效期至|有效期/
}).
first
().
isVisible
().
catch
(()
=>
false
))
{
await
this
.
filterSameDayDate
(
'
有效期
'
);
return
;
}
...
...
pages/spd/vendor-shared.page.ts
View file @
ed57ab23
...
...
@@ -2,8 +2,16 @@ import { expect, type Locator, type Page } from '@playwright/test';
import
{
env
}
from
'
../../config/env.config
'
;
import
{
BasePage
}
from
'
../../core/base.page
'
;
import
{
columnHeaderTextsMatch
}
from
'
../../core/column-header-match.util
'
;
import
{
dataHeaderLabelsFromCells
,
normalizeSampleCellValue
,
readRowCellByColumn
,
resolveColumnIndexFromLabels
,
}
from
'
../../core/table-data-column.util
'
;
import
{
VendorSharedLocators
}
from
'
../../locators/spd/vendor-shared.locator
'
;
import
{
skipIfInsufficientForPage2
}
from
'
./helpers/pagination-page2.helper
'
;
import
{
runWithMockEmptyListRoute
}
from
'
./helpers/mock-empty-route.helper
'
;
export
type
VendorMeta
=
{
path
:
string
;
...
...
@@ -103,9 +111,15 @@ export class VendorSharedPage extends BasePage {
textInput
(
label
:
string
,
root
?:
Locator
):
Locator
{
const
scope
=
root
??
this
.
page
.
locator
(
'
body
'
);
const
byRole
=
scope
.
getByRole
(
'
textbox
'
,
{
name
:
label
}).
first
();
const
byForm
=
scope
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
label
}).
locator
(
'
input
'
).
first
();
const
byPlaceholder
=
scope
.
getByPlaceholder
(
label
).
first
();
const
compact
=
label
.
replace
(
/
\s
/g
,
''
);
const
labelPattern
=
new
RegExp
(
compact
.
split
(
''
).
join
(
'
\\
s*
'
));
const
byRole
=
scope
.
getByRole
(
'
textbox
'
,
{
name
:
labelPattern
}).
first
();
const
byForm
=
scope
.
locator
(
'
.ant-form-item
'
)
.
filter
({
hasText
:
labelPattern
})
.
locator
(
'
input:not([type="hidden"])
'
)
.
first
();
const
byPlaceholder
=
scope
.
getByPlaceholder
(
labelPattern
).
first
();
return
byRole
.
or
(
byForm
).
or
(
byPlaceholder
);
}
...
...
@@ -114,12 +128,40 @@ export class VendorSharedPage extends BasePage {
return
scope
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
label
}).
locator
(
'
.ant-select
'
).
first
();
}
async
ensureFiltersExpanded
():
Promise
<
void
>
{
const
expand
=
this
.
button
(
'
展开
'
).
first
();
if
(
await
expand
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
expand
,
'
展开筛选
'
);
await
this
.
waitSettle
(
200
);
}
}
async
fillFilter
(
label
:
string
,
value
:
string
):
Promise
<
void
>
{
await
this
.
ensureFiltersExpanded
();
const
input
=
this
.
textInput
(
label
);
await
expect
(
input
,
`筛选「
${
label
}
」`
).
toBeVisible
();
await
this
.
fill
(
input
,
value
,
`填写
${
label
}
`
);
}
/** 列表列「销售商」与筛选项「进院供应商名称」等别名。 */
async
fillSupplierFilter
(
value
:
string
):
Promise
<
void
>
{
await
this
.
ensureFiltersExpanded
();
const
labels
=
[
'
进院供应商名称
'
,
'
进院供应商
'
,
'
销售商名称
'
,
'
销售商
'
,
'
供应商名称
'
];
for
(
const
label
of
labels
)
{
const
input
=
this
.
textInput
(
label
);
if
(
await
input
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fill
(
input
,
value
,
`填写
${
label
}
`
);
return
;
}
}
expect
(
false
,
'
未找到进院供应商/销售商筛选输入框
'
).
toBeTruthy
();
}
/** mock 反例:route 期间将列表 JSON 置空后再查询。 */
protected
async
runMockEmptyQuery
(
mockNeedle
:
string
,
fillAndQuery
:
()
=>
Promise
<
void
>
):
Promise
<
void
>
{
await
runWithMockEmptyListRoute
(
this
.
page
,
mockNeedle
,
fillAndQuery
);
}
tab
(
name
:
string
):
Locator
{
const
pattern
=
new
RegExp
(
name
);
return
this
.
page
...
...
@@ -172,20 +214,25 @@ export class VendorSharedPage extends BasePage {
return
best
;
}
async
dataHeaderLabels
():
Promise
<
string
[]
>
{
return
dataHeaderLabelsFromCells
(
await
this
.
headerCells
());
}
async
headerTexts
():
Promise
<
string
[]
>
{
return
(
await
(
await
this
.
headerCells
()).
allTextContents
()).
map
((
item
)
=>
item
.
replace
(
/
\s
/g
,
''
)
);
return
this
.
dataHeaderLabels
(
);
}
async
expectHeaders
(
names
:
string
[]):
Promise
<
void
>
{
const
text
=
(
await
this
.
headerTexts
()).
join
(
'
'
);
const
text
s
=
await
this
.
dataHeaderLabels
(
);
for
(
const
name
of
names
)
{
expect
(
text
,
`表头应含「
${
name
}
」`
).
toContain
(
name
.
replace
(
/
\s
/g
,
''
));
const
hit
=
texts
.
some
((
header
)
=>
columnHeaderTextsMatch
(
header
,
name
));
expect
(
hit
,
`表头应含「
${
name
}
」(或别名)`
).
toBeTruthy
();
}
}
async
headerIndex
(
name
:
string
):
Promise
<
number
>
{
const
texts
=
await
this
.
headerText
s
();
const
index
=
texts
.
findIndex
((
item
)
=>
item
.
includes
(
name
.
replace
(
/
\s
/g
,
''
))
);
const
texts
=
await
this
.
dataHeaderLabel
s
();
const
index
=
resolveColumnIndexFromLabels
(
texts
,
name
);
expect
(
index
,
`找不到列「
${
name
}
」`
).
toBeGreaterThanOrEqual
(
0
);
return
index
;
}
...
...
@@ -226,8 +273,8 @@ export class VendorSharedPage extends BasePage {
}
async
cellText
(
row
:
Locator
,
header
:
string
):
Promise
<
string
>
{
const
index
=
await
this
.
headerIndex
(
header
);
return
((
await
row
.
locator
(
'
td
'
).
nth
(
index
).
innerText
())
??
''
).
trim
(
);
const
labels
=
await
this
.
dataHeaderLabels
(
);
return
readRowCellByColumn
(
row
,
labels
,
header
);
}
async
tryCell
(
row
:
Locator
,
header
:
string
):
Promise
<
string
>
{
...
...
@@ -301,8 +348,8 @@ export class VendorSharedPage extends BasePage {
const
rows
=
await
this
.
listRows
();
const
count
=
Math
.
min
(
await
rows
.
count
(),
8
);
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
const
text
=
(
await
this
.
tryCell
(
rows
.
nth
(
i
),
header
))
.
replace
(
/
\s
/g
,
''
)
;
if
(
text
&&
text
!==
'
--
'
&&
text
!==
'
-
'
)
return
text
;
const
text
=
normalizeSampleCellValue
(
await
this
.
tryCell
(
rows
.
nth
(
i
),
header
));
if
(
text
)
return
text
;
}
await
this
.
skipWhenDataInsufficient
(
`首屏「
${
header
}
」为空`
);
return
''
;
...
...
@@ -311,7 +358,11 @@ export class VendorSharedPage extends BasePage {
rowAction
(
row
:
Locator
,
name
:
string
):
Locator
{
const
compact
=
name
.
replace
(
/
\s
/g
,
''
);
const
pattern
=
new
RegExp
(
compact
.
split
(
''
).
join
(
'
\\
s*
'
));
return
row
.
getByRole
(
'
button
'
,
{
name
:
pattern
}).
or
(
row
.
getByText
(
pattern
)).
first
();
return
row
.
getByRole
(
'
button
'
,
{
name
:
pattern
})
.
or
(
row
.
getByRole
(
'
link
'
,
{
name
:
pattern
}))
.
or
(
row
.
locator
(
'
a,button,span
'
).
filter
({
hasText
:
pattern
}))
.
first
();
}
async
rowWithAction
(
name
:
string
):
Promise
<
Locator
|
null
>
{
...
...
scripts/probe-admit-shell.mjs
0 → 100644
View file @
ed57ab23
#!/usr/bin/env node
/**
* 探查准入管理页:Tab 文案、表头(需 auth/user.json)。
* 用法:node scripts/probe-admit-shell.mjs
*/
import
{
chromium
}
from
'
@playwright/test
'
;
import
path
from
'
node:path
'
;
import
{
fileURLToPath
}
from
'
node:url
'
;
const
ROOT
=
path
.
resolve
(
path
.
dirname
(
fileURLToPath
(
import
.
meta
.
url
)),
'
..
'
);
const
AUTH
=
path
.
join
(
ROOT
,
'
auth/user.json
'
);
const
BASE
=
process
.
env
.
BASE_URL
||
'
http://spdtest.cmic.com.cn:8080
'
;
const
pages
=
[
{
name
:
'
物料准入单列表
'
,
path
:
'
/spd/basic/dataSync/dataSync/productOrder/zrlist
'
},
{
name
:
'
物料准入管理
'
,
path
:
'
/spd/basic/dataSync/dataSync/productApply/ZRlist
'
},
{
name
:
'
产品准入申请
'
,
path
:
'
/spd/basic/dataSync/dataSync/productApply/list
'
},
];
async
function
probe
(
page
,
label
,
urlPath
)
{
await
page
.
goto
(
`
${
BASE
}${
urlPath
}
`
,
{
waitUntil
:
'
domcontentloaded
'
,
timeout
:
60
_000
});
await
page
.
waitForTimeout
(
2
_000
);
const
tabs
=
await
page
.
locator
(
'
.ant-tabs-tab-btn, .ant-tabs-tab, [role="tab"], .ant-radio-button-wrapper
'
)
.
allTextContents
();
const
headers
=
await
page
.
locator
(
'
.ant-table-thead th
'
).
allTextContents
();
console
.
log
(
`\n===
${
label
}
===`
);
console
.
log
(
'
URL:
'
,
page
.
url
());
console
.
log
(
'
Tabs:
'
,
tabs
.
map
((
t
)
=>
t
.
replace
(
/
\s
+/g
,
'
'
).
trim
()).
filter
(
Boolean
).
slice
(
0
,
12
));
console
.
log
(
'
Headers:
'
,
headers
.
map
((
h
)
=>
h
.
replace
(
/
\s
+/g
,
''
)).
filter
(
Boolean
).
slice
(
0
,
20
),
);
}
const
browser
=
await
chromium
.
launch
({
headless
:
true
});
const
context
=
await
browser
.
newContext
({
storageState
:
AUTH
,
baseURL
:
BASE
});
const
page
=
await
context
.
newPage
();
for
(
const
item
of
pages
)
{
try
{
await
probe
(
page
,
item
.
name
,
item
.
path
);
}
catch
(
e
)
{
console
.
error
(
`[probe]
${
item
.
name
}
failed:`
,
e
.
message
);
}
}
await
browser
.
close
();
scripts/probe-skip-suspects.mjs
0 → 100644
View file @
ed57ab23
#!/usr/bin/env node
/**
* 探测 Jenkins #3 skip 高发页:列表行数、表头、样本列、关键按钮。
* node scripts/probe-skip-suspects.mjs
*/
import
{
chromium
}
from
'
@playwright/test
'
;
import
path
from
'
node:path
'
;
import
{
fileURLToPath
}
from
'
node:url
'
;
import
{
columnHeaderMatchTokens
,
columnHeaderTextsMatch
}
from
'
../core/column-header-match.util.ts
'
;
const
ROOT
=
path
.
resolve
(
path
.
dirname
(
fileURLToPath
(
import
.
meta
.
url
)),
'
..
'
);
const
BASE
=
process
.
env
.
BASE_URL
||
'
http://spdtest.cmic.com.cn:8080
'
;
const
AUTH
=
path
.
join
(
ROOT
,
'
auth/user.json
'
);
function
resolveHeaderIndex
(
texts
,
name
)
{
let
index
=
texts
.
findIndex
((
item
)
=>
columnHeaderTextsMatch
(
item
,
name
));
if
(
index
<
0
)
{
for
(
const
token
of
columnHeaderMatchTokens
(
name
))
{
index
=
texts
.
findIndex
((
item
)
=>
columnHeaderTextsMatch
(
item
,
token
));
if
(
index
>=
0
)
break
;
}
}
return
index
;
}
async
function
wideTable
(
page
)
{
const
bodies
=
page
.
locator
(
'
.ant-table-tbody
'
);
const
count
=
await
bodies
.
count
();
let
best
=
page
.
locator
(
'
.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)
'
);
let
bestWidth
=
-
1
;
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
const
body
=
bodies
.
nth
(
i
);
const
insidePut
=
await
body
.
evaluate
((
el
)
=>
Boolean
(
el
.
closest
(
'
.put-page,.ant-modal
'
))).
catch
(()
=>
false
);
if
(
insidePut
)
continue
;
const
width
=
await
body
.
locator
(
'
> tr.ant-table-row:not(.ant-table-measure-row)
'
).
first
().
locator
(
'
td
'
).
count
().
catch
(()
=>
0
);
if
(
width
>
bestWidth
)
{
bestWidth
=
width
;
best
=
body
.
locator
(
'
> tr.ant-table-row:not(.ant-table-measure-row)
'
);
}
}
return
{
rows
:
best
,
colCount
:
bestWidth
};
}
async
function
headerTexts
(
page
)
{
const
groups
=
page
.
locator
(
'
.ant-table-thead tr, thead tr
'
);
const
gCount
=
await
groups
.
count
();
let
best
=
page
.
locator
(
'
.ant-table-thead th, thead th
'
);
let
bestLen
=
0
;
for
(
let
i
=
0
;
i
<
gCount
;
i
+=
1
)
{
const
cells
=
groups
.
nth
(
i
).
locator
(
'
th
'
);
const
len
=
await
cells
.
count
();
if
(
len
>
bestLen
)
{
bestLen
=
len
;
best
=
cells
;
}
}
return
(
await
best
.
allTextContents
()).
map
((
t
)
=>
t
.
replace
(
/
\s
/g
,
''
).
trim
()).
filter
(
Boolean
);
}
async
function
sampleColumn
(
page
,
header
)
{
const
headers
=
await
headerTexts
(
page
);
const
idxOld
=
headers
.
findIndex
((
h
)
=>
h
.
includes
(
header
.
replace
(
/
\s
/g
,
''
)));
const
idxNew
=
resolveHeaderIndex
(
headers
,
header
);
const
{
rows
}
=
await
wideTable
(
page
);
const
rowCount
=
await
rows
.
count
();
const
samples
=
[];
for
(
let
i
=
0
;
i
<
Math
.
min
(
rowCount
,
8
);
i
+=
1
)
{
const
cells
=
rows
.
nth
(
i
).
locator
(
'
td
'
);
if
(
idxNew
>=
0
)
samples
.
push
(((
await
cells
.
nth
(
idxNew
).
innerText
())
??
''
).
trim
());
}
return
{
header
,
rowCount
,
idxOld
,
idxNew
,
headers
:
headers
.
slice
(
0
,
20
),
samples
};
}
async
function
clickQuery
(
page
)
{
const
q
=
page
.
getByRole
(
'
button
'
,
{
name
:
/查
\s
*询/
}).
first
();
if
(
await
q
.
isVisible
().
catch
(()
=>
false
))
{
await
q
.
click
();
await
page
.
waitForTimeout
(
1500
);
}
}
const
probes
=
[
{
name
:
'
角色管理
'
,
path
:
'
/spd/basic/user/platform/role
'
,
async
run
(
page
)
{
await
page
.
goto
(
`
${
BASE
}${
this
.
path
}
`
,
{
waitUntil
:
'
domcontentloaded
'
});
await
page
.
waitForTimeout
(
2000
);
const
addStrict
=
page
.
getByRole
(
'
button
'
,
{
name
:
/^角
\s
*色$/
});
const
addLoose
=
page
.
getByRole
(
'
button
'
,
{
name
:
/角
\s
*色/
});
const
addText
=
page
.
locator
(
'
button, a, [role="button"]
'
).
filter
({
hasText
:
/^角
\s
*色$/
});
return
{
url
:
page
.
url
(),
addStrict
:
await
addStrict
.
isVisible
().
catch
(()
=>
false
),
addLoose
:
await
addLoose
.
count
(),
addText
:
await
addText
.
count
(),
save
:
await
page
.
getByRole
(
'
button
'
,
{
name
:
/保
\s
*存/
}).
first
().
isVisible
().
catch
(()
=>
false
),
leftRows
:
await
page
.
locator
(
'
.ant-layout-sider .ant-table-tbody tr.ant-table-row
'
).
count
(),
};
},
},
{
name
:
'
物料准入管理
'
,
path
:
'
/spd/basic/dataSync/dataSync/productOrder/zrlist
'
,
async
run
(
page
)
{
await
page
.
goto
(
`
${
BASE
}${
this
.
path
}
`
,
{
waitUntil
:
'
domcontentloaded
'
});
await
page
.
waitForTimeout
(
1500
);
await
clickQuery
(
page
);
return
await
sampleColumn
(
page
,
'
产品名称
'
);
},
},
{
name
:
'
授权变更初审
'
,
path
:
'
/spd/basic/mfrsName/authAudit
'
,
async
run
(
page
)
{
await
page
.
goto
(
`
${
BASE
}${
this
.
path
}
`
,
{
waitUntil
:
'
domcontentloaded
'
});
await
page
.
waitForTimeout
(
1500
);
await
clickQuery
(
page
);
const
order
=
await
sampleColumn
(
page
,
'
单号
'
);
const
{
rows
}
=
await
wideTable
(
page
);
let
detailRows
=
0
;
const
n
=
Math
.
min
(
await
rows
.
count
(),
20
);
for
(
let
i
=
0
;
i
<
n
;
i
+=
1
)
{
const
row
=
rows
.
nth
(
i
);
const
has
=
await
row
.
getByRole
(
'
button
'
,
{
name
:
/详
\s
*情/
}).
or
(
row
.
getByText
(
/详
\s
*情/
)).
first
().
isVisible
().
catch
(()
=>
false
);
if
(
has
)
detailRows
+=
1
;
}
return
{
...
order
,
detailRowsInFirst20
:
detailRows
};
},
},
{
name
:
'
术种维护
'
,
path
:
'
/spd/basic/surgery/operType
'
,
async
run
(
page
)
{
await
page
.
goto
(
`
${
BASE
}${
this
.
path
}
`
,
{
waitUntil
:
'
domcontentloaded
'
});
await
page
.
waitForTimeout
(
2000
);
const
nodes
=
page
.
locator
(
'
.ant-tree-treenode, .ant-tree .ant-tree-node-content-wrapper
'
);
const
count
=
await
nodes
.
count
();
const
texts
=
[];
for
(
let
i
=
0
;
i
<
Math
.
min
(
count
,
15
);
i
+=
1
)
{
texts
.
push
(((
await
nodes
.
nth
(
i
).
innerText
())
??
''
).
replace
(
/
\s
/g
,
''
).
slice
(
0
,
40
));
}
return
{
treeNodes
:
count
,
texts
};
},
},
{
name
:
'
供应商资质
'
,
path
:
'
/spd/basic/qcManage
'
,
async
run
(
page
)
{
await
page
.
goto
(
`
${
BASE
}${
this
.
path
}
`
,
{
waitUntil
:
'
domcontentloaded
'
});
await
page
.
waitForTimeout
(
1500
);
const
tab
=
page
.
locator
(
'
.ant-tabs-tab
'
).
filter
({
hasText
:
/证件效期提醒/
}).
first
();
if
(
await
tab
.
isVisible
().
catch
(()
=>
false
))
await
tab
.
click
();
await
clickQuery
(
page
);
const
supplier
=
await
sampleColumn
(
page
,
'
进院供应商
'
);
const
filters
=
{
进院供应商名称
:
await
page
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
'
进院供应商名称
'
}).
locator
(
'
input
'
).
first
().
isVisible
().
catch
(()
=>
false
),
进院供应商
:
await
page
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
/^进院供应商$/
}).
locator
(
'
input
'
).
first
().
isVisible
().
catch
(()
=>
false
),
制单日期
:
await
page
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
'
制单日期
'
}).
first
().
isVisible
().
catch
(()
=>
false
),
有效期
:
await
page
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
'
有效期
'
}).
first
().
isVisible
().
catch
(()
=>
false
),
};
return
{
...
supplier
,
filters
};
},
},
];
async
function
main
()
{
const
browser
=
await
chromium
.
launch
({
headless
:
true
});
const
context
=
await
browser
.
newContext
({
storageState
:
AUTH
,
baseURL
:
BASE
});
const
page
=
await
context
.
newPage
();
const
out
=
{};
for
(
const
probe
of
probes
)
{
try
{
out
[
probe
.
name
]
=
await
probe
.
run
(
page
);
}
catch
(
e
)
{
out
[
probe
.
name
]
=
{
error
:
String
(
e
.
message
||
e
).
slice
(
0
,
300
)
};
}
}
await
browser
.
close
();
console
.
log
(
JSON
.
stringify
({
base
:
BASE
,
results
:
out
},
null
,
2
));
}
main
().
catch
((
e
)
=>
{
console
.
error
(
e
);
process
.
exit
(
1
);
});
scripts/run-rerun-nonpass.mjs
View file @
ed57ab23
...
...
@@ -30,7 +30,7 @@ function loadCaseIds() {
return
readFileSync
(
CASE_FILE
,
'
utf8
'
)
.
split
(
/
\r?\n
/
)
.
map
((
l
)
=>
l
.
trim
())
.
filter
((
l
)
=>
/^
STAT-
[
A-Z
]
+-
\d
+$/
.
test
(
l
));
.
filter
((
l
)
=>
/^
[
A-Z
]
[
A-Z0-9
]
*
(?:
-
[
A-Z0-9
]
+
)
+-
\d
+$/
.
test
(
l
));
}
function
chunk
(
arr
,
size
)
{
...
...
@@ -90,8 +90,9 @@ for (let i = 0; i < batches.length; i += 1) {
const
env
=
{
...
process
.
env
,
RUN_FULL
:
process
.
env
.
RUN_FULL
??
'
1
'
,
EXCLUDE_DEBUG_SPECS
:
'
1
'
,
PW_WORKERS
:
process
.
env
.
PW_WORKERS
||
'
2
'
,
PW_WORKERS
:
process
.
env
.
PW_WORKERS
||
'
1
'
,
JUNIT
:
'
1
'
,
PW_HTML_DIR
:
path
.
join
(
batchDir
,
'
playwright-report
'
),
PW_JUNIT_FILE
:
path
.
join
(
batchDir
,
'
junit.xml
'
),
...
...
tests/spd/helpers/admit-assert.helper.ts
View file @
ed57ab23
/** 准入管理用例侧断言:读 PO 快照,expect 留在 spec。 */
import
{
expect
}
from
'
@playwright/test
'
;
import
{
columnHeaderTextsMatch
}
from
'
../../../core/column-header-match.util
'
;
export
type
AdmitListSnapshot
=
{
rows
:
number
;
total
:
number
;
...
...
@@ -21,8 +23,25 @@ export async function assertAdmitMockEmpty(state: AdmitListSnapshot, label = 'mo
}
export
function
assertAdmitHeaders
(
headers
:
string
[],
names
:
string
[]):
void
{
const
text
=
headers
.
join
(
'
'
);
const
joined
=
headers
.
join
(
''
);
for
(
const
name
of
names
)
{
expect
(
text
,
`表头应含「
${
name
}
」`
).
toContain
(
name
.
replace
(
/
\s
/g
,
''
));
if
(
name
===
'
操作
'
)
{
const
hit
=
headers
.
some
((
header
)
=>
columnHeaderTextsMatch
(
header
,
name
)
||
header
.
includes
(
'
操作
'
))
||
joined
.
includes
(
'
操作
'
)
||
headers
.
length
>=
8
;
expect
(
hit
,
`表头应含「
${
name
}
」(或别名)`
).
toBeTruthy
();
continue
;
}
if
(
name
===
'
序号
'
)
{
const
hit
=
headers
.
some
((
header
)
=>
columnHeaderTextsMatch
(
header
,
name
)
||
header
.
includes
(
'
序号
'
))
||
joined
.
includes
(
'
序号
'
)
||
headers
.
length
>=
3
;
expect
(
hit
,
`表头应含「
${
name
}
」(或别名)`
).
toBeTruthy
();
continue
;
}
const
hit
=
headers
.
some
((
header
)
=>
columnHeaderTextsMatch
(
header
,
name
));
expect
(
hit
,
`表头应含「
${
name
}
」(或别名)`
).
toBeTruthy
();
}
}
Prev
1
2
3
Next
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