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
6a181bea
Commit
6a181bea
authored
Oct 20, 2026
by
liguangyu06
Browse files
将基础管理等未入库改动暂存到 temp 分支,便于远程备份。
新模块用例尚未具备通关凭证,本次按临时备份跳过本地门禁。
parent
f6eec849
Changes
214
Hide whitespace changes
Inline
Side-by-side
Too many changes to show.
To preserve performance only
20 of 214+
files are displayed.
Plain diff
Email patch
pages/spd/surgery-std-pkg.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Locator
,
type
Page
}
from
'
@playwright/test
'
;
import
{
surgeryData
}
from
'
../../data/surgery.data
'
;
import
{
SurgerySharedPage
,
type
SurgeryMeta
}
from
'
./surgery-shared.page
'
;
const
META
:
SurgeryMeta
=
{
path
:
surgeryData
.
stdPkgPath
,
pathPart
:
surgeryData
.
stdPkgPathPart
,
menu
:
surgeryData
.
menuStdPkg
,
siblings
:
[
{
name
:
surgeryData
.
menuBasePkg
,
pathPart
:
surgeryData
.
basePkgPathPart
},
{
name
:
surgeryData
.
menuOperType
,
pathPart
:
surgeryData
.
operTypePathPart
},
],
forbidden
:
[
surgeryData
.
basePkgPathPart
,
surgeryData
.
operTypePathPart
],
};
/** 标准套包维护(CSV SURG-STD-001..029)。 */
export
class
SurgeryStdPkgPage
extends
SurgerySharedPage
{
constructor
(
page
:
Page
)
{
super
(
page
,
META
);
}
// ---- 定位 ----
categorySearch
()
{
return
this
.
page
.
getByPlaceholder
(
'
请输入术种分类名称、术种名称、标准套包名称
'
).
first
();
}
addBasePkgBtn
()
{
return
this
.
locators
.
button
(
'
添加基础包
'
).
first
();
}
addLoosePartBtn
()
{
return
this
.
locators
.
button
(
'
添加散件
'
).
first
();
}
productNameInput
()
{
return
this
.
filterById
(
'
#table_search_form_keyword
'
);
}
specModelInput
()
{
return
this
.
filterById
(
'
#table_search_form_goodsSpec
'
);
}
mfrsNameInput
()
{
return
this
.
filterById
(
'
#table_search_form_goodsMfrsName
'
);
}
// ---- 左树分类 ----
async
searchCategory
(
keyword
:
string
):
Promise
<
void
>
{
await
this
.
fill
(
this
.
categorySearch
(),
keyword
,
`填写术种分类名称「
${
keyword
}
」`
);
await
this
.
waitSettle
(
600
);
}
// ---- 筛选 ----
async
searchByProductName
():
Promise
<
string
>
{
return
this
.
searchByFirstRow
(
'
#table_search_form_keyword
'
,
'
产品名称
'
);
}
async
searchBySpecModel
():
Promise
<
string
>
{
return
this
.
searchByFirstRow
(
'
#table_search_form_goodsSpec
'
,
'
规格型号
'
);
}
async
searchByMfrsName
():
Promise
<
string
>
{
return
this
.
searchByFirstRow
(
'
#table_search_form_goodsMfrsName
'
,
'
器械注册人
'
);
}
/** CSV SURG-STD-015:mock 反例空态。 */
async
searchMockEmpty
():
Promise
<
void
>
{
await
this
.
fillFilterById
(
'
#table_search_form_keyword
'
,
surgeryData
.
mockStdKeyword
);
await
this
.
query
();
}
/** CSV SURG-STD-013:过滤后重置,返回筛选框是否清空。 */
async
filterThenReset
():
Promise
<
{
before
:
number
;
afterValue
:
string
}
>
{
const
keyword
=
await
this
.
sampleCell
(
'
产品名称
'
);
await
this
.
fillFilterById
(
'
#table_search_form_keyword
'
,
keyword
);
await
this
.
query
();
const
before
=
await
this
.
getTotalCount
();
await
this
.
reset
();
const
afterValue
=
await
this
.
productNameInput
().
inputValue
().
catch
(()
=>
''
);
return
{
before
,
afterValue
};
}
// ---- 包名称分组展开/收起 ----
/** 首个可展开的包分组(含 expand 图标的行)。 */
firstExpandableGroup
():
Locator
{
return
this
.
page
.
locator
(
'
.ant-table-tbody tr.ant-table-row
'
)
.
filter
({
has
:
this
.
page
.
locator
(
'
.ant-table-row-expand-icon
'
)
})
.
first
();
}
/**
* CSV SURG-STD-012:读取首个包分组名,展开后取子项行数,再收起。
* 返回展开/收起时「子项行」定位数与可见性,供用例断言「展开显示、收起隐藏」。
*/
async
toggleFirstGroup
():
Promise
<
{
groupName
:
string
;
expandedChildCount
:
number
;
collapsedChildCount
:
number
;
expandedChildVisible
:
boolean
;
collapsedChildVisible
:
boolean
;
}
>
{
const
group
=
this
.
firstExpandableGroup
();
if
(
!
(
await
group
.
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
skipWhenDataInsufficient
(
'
无可用包分组可展开
'
);
}
const
groupName
=
((
await
group
.
innerText
().
catch
(()
=>
''
))
??
''
).
replace
(
/
\s
/g
,
''
).
slice
(
0
,
20
);
const
expandIcon
=
group
.
locator
(
'
.ant-table-row-expand-icon
'
).
first
();
await
this
.
click
(
expandIcon
,
'
展开包分组
'
);
await
this
.
waitSettle
(
500
);
const
expandedChild
=
this
.
childRows
();
const
expandedChildCount
=
await
expandedChild
.
count
();
const
expandedChildVisible
=
await
expandedChild
.
first
().
isVisible
().
catch
(()
=>
false
);
await
this
.
click
(
expandIcon
,
'
收起包分组
'
);
await
this
.
waitSettle
(
500
);
const
collapsedChild
=
this
.
childRows
();
const
collapsedChildCount
=
await
collapsedChild
.
count
();
const
collapsedChildVisible
=
await
collapsedChild
.
first
().
isVisible
().
catch
(()
=>
false
);
return
{
groupName
,
expandedChildCount
,
collapsedChildCount
,
expandedChildVisible
,
collapsedChildVisible
};
}
/** 子项行:分组展开后 TR 带 .ant-table-row-level-1(或无 expand-icon 的明细行)。 */
childRows
():
Locator
{
return
this
.
page
.
locator
(
'
.ant-table-tbody tr.ant-table-row-level-1, .ant-table-tbody tr.ant-table-row:not(:has(.ant-table-row-expand-icon))
'
,
);
}
/** 展开首个分组并返回子项行定位(供行内操作)。 */
async
expandFirstGroup
():
Promise
<
{
groupName
:
string
;
childRows
:
Locator
}
>
{
const
group
=
this
.
firstExpandableGroup
();
if
(
!
(
await
group
.
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
skipWhenDataInsufficient
(
'
无可用包分组可展开
'
);
}
const
groupName
=
((
await
group
.
innerText
().
catch
(()
=>
''
))
??
''
).
replace
(
/
\s
/g
,
''
).
slice
(
0
,
20
);
await
this
.
click
(
group
.
locator
(
'
.ant-table-row-expand-icon
'
).
first
(),
'
展开包分组
'
);
await
this
.
waitSettle
(
500
);
const
childRows
=
this
.
page
.
locator
(
'
.ant-table-tbody tr.ant-table-row
'
);
return
{
groupName
,
childRows
};
}
// ---- 添加基础包弹窗 ----
/** CSV SURG-STD-016:打开添加基础包弹窗。 */
async
openAddBasePkgDialog
():
Promise
<
void
>
{
await
this
.
click
(
this
.
addBasePkgBtn
(),
'
添加基础包
'
);
await
this
.
expectDialogVisible
();
await
this
.
waitSpinGone
();
}
addBasePkgSearchInput
():
Locator
{
return
this
.
page
.
getByPlaceholder
(
'
请输入分类/基础包名称
'
).
first
();
}
/** 「确定(N)」按钮:返回定位与当前计数。 */
confirmCountButton
():
Locator
{
return
this
.
dialog
().
getByRole
(
'
button
'
).
filter
({
hasText
:
/确定
\s
*
[
((
]?\s
*
\d
*
\s
*
[
))
]?
/
}).
first
();
}
async
confirmCount
():
Promise
<
number
|
null
>
{
const
text
=
((
await
this
.
confirmCountButton
().
innerText
().
catch
(()
=>
''
))
??
''
).
replace
(
/
\s
/g
,
''
);
const
match
=
text
.
match
(
/
[
((
](\d
+
)[
))
]
/
);
return
match
?
Number
(
match
[
1
])
:
null
;
}
/** 弹窗内基础包勾选框(首列 checkbox)。 */
dialogCheckboxes
():
Locator
{
return
this
.
dialog
().
locator
(
'
.ant-table-tbody input[type="checkbox"], .ant-checkbox-input
'
);
}
/**
* CSV SURG-STD-017:在弹窗搜索框输入真实基础包关键字并回车,勾选 1 个基础包。
* 返回关键字与「确定(N)」计数前后值,供用例断言筛选生效与计数联动。
*/
async
filterAndCheckFirst
(
keyword
?:
string
):
Promise
<
{
keyword
:
string
;
before
:
number
|
null
;
after
:
number
|
null
;
candidateRows
:
number
;
}
>
{
await
this
.
openAddBasePkgDialog
();
const
before
=
await
this
.
confirmCount
();
const
search
=
this
.
addBasePkgSearchInput
();
// 关键字:优先入参;否则从弹窗明细首行取基础包名称(真实关键字,不再填空串)
const
kw
=
keyword
??
(
await
this
.
sampleDialogBasePkgName
());
let
candidateRows
=
0
;
if
(
await
search
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fill
(
search
,
kw
,
`搜索基础包「
${
kw
}
」`
);
await
search
.
press
(
'
Enter
'
).
catch
(()
=>
undefined
);
await
this
.
waitSettle
(
600
);
candidateRows
=
await
this
.
dialogCheckboxes
().
count
();
}
const
checkbox
=
this
.
dialogCheckboxes
().
first
();
await
this
.
click
(
checkbox
,
'
勾选首个基础包
'
);
await
this
.
waitSettle
(
300
);
const
after
=
await
this
.
confirmCount
();
return
{
keyword
:
kw
,
before
,
after
,
candidateRows
};
}
/** 弹窗明细首行基础包名称(作为真实筛选关键字)。 */
async
sampleDialogBasePkgName
():
Promise
<
string
>
{
const
rows
=
this
.
dialog
().
locator
(
'
.ant-table-tbody tr.ant-table-row
'
);
const
count
=
Math
.
min
(
await
rows
.
count
(),
8
);
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
const
text
=
((
await
rows
.
nth
(
i
).
innerText
().
catch
(()
=>
''
))
??
''
).
replace
(
/
\s
/g
,
''
);
const
hit
=
text
.
match
(
/
[\u
4e00-
\u
9fa5A-Za-z0-9-
]{4,}
/
);
if
(
hit
)
return
hit
[
0
].
slice
(
0
,
8
);
}
await
this
.
skipWhenDataInsufficient
(
'
弹窗基础包明细为空
'
);
return
''
;
}
/** CSV SURG-STD-018:构成数量步进器点「Increase Value」,返回数量前后数值。 */
async
increaseFirstQuantity
():
Promise
<
{
before
:
number
;
after
:
number
}
>
{
const
increase
=
this
.
dialog
().
getByLabel
(
'
Increase Value
'
).
first
();
const
visible
=
await
increase
.
isVisible
().
catch
(()
=>
false
);
if
(
!
visible
)
{
await
this
.
skipWhenDataInsufficient
(
'
无「构成数量」步进器(未勾选基础包)
'
);
}
const
spin
=
this
.
dialog
().
getByRole
(
'
spinbutton
'
).
first
();
const
before
=
Number
((
await
spin
.
inputValue
().
catch
(()
=>
'
1
'
))
||
'
1
'
);
await
this
.
click
(
increase
,
'
Increase Value
'
);
await
this
.
waitSettle
(
300
);
const
after
=
Number
((
await
spin
.
inputValue
().
catch
(()
=>
'
1
'
))
||
'
1
'
);
return
{
before
,
after
};
}
/** CSV SURG-STD-019:勾选后取消,返回 { total, cancelOk }。 */
async
addBasePkgThenCancel
():
Promise
<
{
total
:
number
;
cancelOk
:
boolean
}
>
{
const
total
=
await
this
.
getTotalCount
();
await
this
.
openAddBasePkgDialog
();
const
checkbox
=
this
.
dialogCheckboxes
().
first
();
if
(
await
checkbox
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
checkbox
,
'
勾选首个基础包
'
);
}
await
this
.
closeDialogIfOpen
();
const
cancelOk
=
!
(
await
this
.
dialog
().
isVisible
().
catch
(()
=>
true
));
return
{
total
,
cancelOk
};
}
/** CSV SURG-STD-020:未选择时确定按钮是否禁用。 */
async
confirmDisabledWhenEmpty
():
Promise
<
boolean
>
{
await
this
.
openAddBasePkgDialog
();
const
btn
=
this
.
confirmCountButton
();
const
disabled
=
await
btn
.
isDisabled
().
catch
(()
=>
false
);
const
cls
=
(
await
btn
.
getAttribute
(
'
class
'
).
catch
(()
=>
''
))
??
''
;
return
disabled
||
/disabled/
.
test
(
cls
);
}
/**
* CSV SURG-STD-021:再次添加同一基础包。
* 返回首次添加的产品名与「同一产品名出现的行数」,供用例断言不产生重复脏行。
*/
async
addSameBasePkgAgain
():
Promise
<
{
before
:
number
;
after
:
number
;
productName
:
string
;
sameProductRows
:
number
;
}
>
{
// 先记录首次添加的目标产品名(弹窗明细首行)
const
total0
=
await
this
.
getTotalCount
();
await
this
.
openAddBasePkgDialog
();
const
productName
=
await
this
.
sampleDialogBasePkgName
();
const
checkbox
=
this
.
dialogCheckboxes
().
first
();
if
(
await
checkbox
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
checkbox
,
'
勾选首个基础包
'
);
}
await
this
.
clickConfirm
(
'
确 定
'
);
await
expect
(
this
.
dialog
()).
toBeHidden
({
timeout
:
15
_000
}).
catch
(()
=>
undefined
);
await
this
.
waitForTableReady
();
const
after
=
await
this
.
getTotalCount
();
const
sameProductRows
=
await
this
.
countRowsContaining
(
productName
);
return
{
before
:
total0
,
after
,
productName
,
sameProductRows
};
}
/** 统计列表中包含指定文本的行数(用于重复添加防重判定)。 */
async
countRowsContaining
(
keyword
:
string
):
Promise
<
number
>
{
const
needle
=
keyword
.
replace
(
/
\s
/g
,
''
);
const
rows
=
this
.
dataRows
();
const
count
=
await
rows
.
count
();
let
hits
=
0
;
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
const
text
=
((
await
rows
.
nth
(
i
).
innerText
().
catch
(()
=>
''
))
??
''
).
replace
(
/
\s
/g
,
''
);
if
(
text
.
includes
(
needle
))
hits
+=
1
;
}
return
hits
;
}
// ---- 添加散件弹窗 ----
/** CSV SURG-STD-022:打开添加散件弹窗(产品列表视图)。 */
async
openAddLoosePartDialog
():
Promise
<
void
>
{
await
this
.
click
(
this
.
addLoosePartBtn
(),
'
添加散件
'
);
await
this
.
expectDialogVisible
();
await
this
.
waitSpinGone
();
await
this
.
waitSettle
(
500
);
}
/** 弹窗内视图切换(产品列表 / 规格明细 / 应拣明细)。 */
dialogViewTab
(
name
:
string
):
Locator
{
return
this
.
dialog
()
.
locator
(
'
.ant-tabs-tab, .ant-radio-button-wrapper, .ant-segmented-item
'
)
.
filter
({
hasText
:
new
RegExp
(
name
)
})
.
first
()
.
or
(
this
.
dialog
().
getByText
(
new
RegExp
(
name
)).
first
());
}
/** 散件弹窗内的产品名称筛选框(限定在弹窗内,避免命中主列表)。 */
loosePartProductSearch
():
Locator
{
return
this
.
dialog
().
getByPlaceholder
(
/产品名称/
).
first
();
}
/**
* CSV SURG-STD-023:在产品名称筛选框输入样例关键字,勾选首个匹配散件。
* 返回关键字、筛选后候选行数、勾选后应拣计数,供用例断言「筛选生效 + 进入应拣明细 + 计数同步」。
*/
async
filterAndCheckLoosePart
():
Promise
<
{
keyword
:
string
;
candidateRows
:
number
;
selectedCount
:
number
;
}
>
{
const
keyword
=
await
this
.
sampleCell
(
'
产品名称
'
);
const
search
=
this
.
loosePartProductSearch
();
if
(
await
search
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fill
(
search
,
keyword
,
`填写产品名称筛选「
${
keyword
}
」`
);
await
search
.
press
(
'
Enter
'
).
catch
(()
=>
undefined
);
await
this
.
waitSettle
(
600
);
}
const
candidateRows
=
await
this
.
dialog
().
locator
(
'
.ant-table-tbody tr.ant-table-row
'
).
count
();
const
checkbox
=
this
.
dialogCheckboxes
().
first
();
await
this
.
click
(
checkbox
,
'
勾选首个散件
'
);
await
this
.
waitSettle
(
400
);
const
selectedCount
=
await
this
.
loosePartSelectedCount
();
return
{
keyword
,
candidateRows
,
selectedCount
};
}
/** 应拣明细已选条数(弹窗内明细行数或选中计数徽标)。 */
async
loosePartSelectedCount
():
Promise
<
number
>
{
const
badgeText
=
((
await
this
.
dialog
()
.
locator
(
'
.ant-badge, [class*="count"], [class*="total"]
'
)
.
first
()
.
innerText
()
.
catch
(()
=>
''
))
??
''
);
const
badge
=
badgeText
.
match
(
/
(\d
+
)
/
);
if
(
badge
)
return
Number
(
badge
[
1
]);
return
this
.
dialog
().
locator
(
'
.ant-table-tbody tr.ant-table-row.ant-table-row-selected
'
).
count
();
}
/**
* CSV SURG-STD-024:切换到规格/应拣明细视图。
* 返回明细视图是否可见、明细表头文本,供用例断言字段完整。
*/
async
switchLoosePartDetailView
():
Promise
<
{
visible
:
boolean
;
headers
:
string
[]
}
>
{
const
tab
=
this
.
dialogViewTab
(
'
应拣明细
'
).
or
(
this
.
dialogViewTab
(
'
规格明细
'
));
if
(
await
tab
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
tab
,
'
切换应拣明细视图
'
);
await
this
.
waitSettle
(
400
);
}
const
headers
=
(
await
this
.
dialog
().
locator
(
'
.ant-table-thead th
'
).
allTextContents
()).
map
((
t
)
=>
t
.
replace
(
/
\s
/g
,
''
));
const
visible
=
headers
.
length
>
0
;
return
{
visible
,
headers
};
}
/** CSV SURG-STD-025:读取耗材品种数 / 耗材总数(结构化数值)。 */
async
readLoosePartStats
():
Promise
<
{
kind
:
number
|
null
;
total
:
number
|
null
}
>
{
const
kindText
=
await
this
.
dialog
().
getByText
(
/耗材品种数/
).
first
().
innerText
().
catch
(()
=>
''
);
const
totalText
=
await
this
.
dialog
().
getByText
(
/耗材总数/
).
first
().
innerText
().
catch
(()
=>
''
);
const
pick
=
(
text
:
string
)
=>
{
const
match
=
text
.
match
(
/
(\d
+
)
/
);
return
match
?
Number
(
match
[
1
])
:
null
;
};
return
{
kind
:
pick
(
kindText
),
total
:
pick
(
totalText
)
};
}
/** CSV SURG-STD-026:勾选散件后取消,返回 { total, cancelOk }。 */
async
addLoosePartThenCancel
():
Promise
<
{
total
:
number
;
cancelOk
:
boolean
}
>
{
const
total
=
await
this
.
getTotalCount
();
await
this
.
openAddLoosePartDialog
();
await
this
.
filterAndCheckLoosePart
();
await
this
.
closeDialogIfOpen
();
const
cancelOk
=
!
(
await
this
.
dialog
().
isVisible
().
catch
(()
=>
true
));
return
{
total
,
cancelOk
};
}
/** CSV SURG-STD-027:万级首屏可交互性(筛选/分页可用,不卡死)。 */
async
loosePartFirstScreen
():
Promise
<
{
total
:
number
|
null
;
firstRowVisible
:
boolean
;
pagerVisible
:
boolean
}
>
{
await
this
.
openAddLoosePartDialog
();
const
pagerText
=
((
await
this
.
dialog
().
locator
(
'
.ant-pagination-total-text
'
).
first
().
innerText
().
catch
(()
=>
''
))
??
''
);
const
match
=
pagerText
.
match
(
/共
\s
*
(\d
+
)\s
*条/
);
const
firstRowVisible
=
await
this
.
dialog
()
.
locator
(
'
.ant-table-tbody tr.ant-table-row
'
)
.
first
()
.
isVisible
()
.
catch
(()
=>
false
);
const
pagerVisible
=
await
this
.
dialog
().
locator
(
'
.ant-pagination
'
).
first
().
isVisible
().
catch
(()
=>
false
);
return
{
total
:
match
?
Number
(
match
[
1
])
:
null
,
firstRowVisible
,
pagerVisible
};
}
// ---- 行内操作(编辑 / 移除)----
/** CSV SURG-STD-028:展开后子项行编辑并回填。 */
async
editFirstChildRow
():
Promise
<
{
groupName
:
string
}
>
{
const
{
groupName
}
=
await
this
.
expandFirstGroup
();
const
row
=
await
this
.
rowWithAction
(
'
编 辑
'
);
if
(
!
row
)
{
await
this
.
skipWhenDataInsufficient
(
'
展开后无「编辑」子项行
'
);
}
await
this
.
click
(
this
.
rowAction
(
row
as
Locator
,
'
编 辑
'
),
'
编 辑
'
);
await
this
.
expectDialogVisible
();
await
this
.
waitSpinGone
();
return
{
groupName
};
}
/** CSV SURG-STD-029:展开后子项行移除并取消,返回 { before, cancelShown }。 */
async
removeFirstChildRowThenCancel
():
Promise
<
{
before
:
number
;
cancelShown
:
boolean
}
>
{
await
this
.
expandFirstGroup
();
const
before
=
await
this
.
getDataRowCount
();
const
row
=
await
this
.
rowWithAction
(
'
移 除
'
);
if
(
!
row
)
{
await
this
.
skipWhenDataInsufficient
(
'
展开后无「移除」子项行
'
);
}
await
this
.
click
(
this
.
rowAction
(
row
as
Locator
,
'
移 除
'
),
'
移 除
'
);
const
cancel
=
this
.
locators
.
cancelLikeButton
(
this
.
dialog
());
const
cancelShown
=
await
cancel
.
isVisible
({
timeout
:
5
_000
}).
catch
(()
=>
false
);
if
(
cancelShown
)
await
this
.
closeDialogIfOpen
();
return
{
before
,
cancelShown
};
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async
filterCombination
():
Promise
<
void
>
{
const
scope
=
await
this
.
queryScope
();
await
this
.
applyFilterCombination
(
[
{
label
:
'
物料名称
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
规格型号
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
器械注册人
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
耗材类型
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
{
label
:
'
是否收费
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
],
{
scope
},
);
}
}
export
const
SURGERY_STD_PKG_PATH
=
surgeryData
.
stdPkgPath
;
pages/spd/vendor-audit.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Page
}
from
'
@playwright/test
'
;
import
{
vendorData
}
from
'
../../data/vendor.data
'
;
import
{
VendorSharedPage
,
type
VendorMeta
}
from
'
./vendor-shared.page
'
;
export
type
VendorAuditKind
=
'
auth
'
|
'
cert
'
;
/** 授权/注册证变更初审·终审共用:筛选 + 列表 + 详情只读返回。 */
export
class
VendorAuditPage
extends
VendorSharedPage
{
constructor
(
page
:
Page
,
meta
:
VendorMeta
,
protected
readonly
kind
:
VendorAuditKind
,
)
{
super
(
page
,
meta
);
}
async
expectEntered
():
Promise
<
void
>
{
await
this
.
expectOnPage
();
await
expect
(
this
.
button
(
'
查询
'
).
first
(),
'
查询
'
).
toBeVisible
();
await
expect
(
this
.
button
(
'
重置
'
).
first
(),
'
重置
'
).
toBeVisible
();
await
expect
(
this
.
page
.
getByText
(
'
制单日期
'
).
first
(),
'
制单日期筛选
'
).
toBeVisible
();
}
async
filterByOrderNo
():
Promise
<
void
>
{
await
this
.
query
();
const
order
=
await
this
.
sampleCell
(
'
单号
'
);
const
keyword
=
order
.
slice
(
0
,
Math
.
min
(
order
.
length
,
16
));
await
this
.
fillFilter
(
'
单号
'
,
keyword
);
await
this
.
query
();
await
this
.
expectRowsContain
(
'
单号
'
,
keyword
);
}
async
filterBySupplierOrCert
():
Promise
<
void
>
{
await
this
.
query
();
if
(
this
.
kind
===
'
auth
'
)
{
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
);
await
this
.
query
();
await
this
.
expectRowsContain
(
'
进院供应商名称
'
,
keyword
);
return
;
}
if
(
await
this
.
formSelect
(
'
进院供应商
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
this
.
formSelect
(
'
进院供应商
'
),
'
打开进院供应商
'
);
const
opt
=
this
.
page
.
locator
(
'
.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option
'
)
.
filter
({
hasText
:
keyword
.
slice
(
0
,
6
)
})
.
first
();
if
(
await
opt
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
opt
,
'
选择进院供应商
'
);
await
this
.
query
();
await
this
.
expectRowsContain
(
'
进院供应商名称
'
,
keyword
.
slice
(
0
,
6
));
return
;
}
}
await
this
.
filterByOrderNo
();
return
;
}
const
cert
=
await
this
.
sampleCell
(
'
注册证号(新)
'
);
const
keyword
=
cert
.
slice
(
0
,
Math
.
min
(
cert
.
length
,
12
));
if
(
await
this
.
textInput
(
'
注册证号(新)
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
'
注册证号(新)
'
,
keyword
);
await
this
.
query
();
await
this
.
expectRowsContain
(
'
注册证号(新)
'
,
keyword
);
return
;
}
await
this
.
filterByOrderNo
();
}
async
resetFilters
():
Promise
<
void
>
{
await
this
.
query
();
const
filled
=
await
this
.
fillFirstVisibleFilter
([
'
单号
'
,
'
预转供应商
'
,
'
原供应商
'
,
'
注册证号(新)
'
,
'
变更情况具体说明
'
]);
await
this
.
reset
();
if
(
filled
)
{
const
input
=
this
.
textInput
(
filled
);
if
(
await
input
.
isVisible
().
catch
(()
=>
false
))
await
expect
(
input
).
toHaveValue
(
''
);
}
await
this
.
expectListOrEmpty
();
}
async
mockEmpty
():
Promise
<
void
>
{
await
this
.
fillFilter
(
'
单号
'
,
vendorData
.
mockOrder
);
await
this
.
query
();
}
async
expectCoreColumns
():
Promise
<
void
>
{
await
this
.
query
();
if
(
this
.
kind
===
'
auth
'
)
{
await
this
.
expectHeaders
([
'
进院供应商名称
'
,
'
单号
'
,
'
状态
'
,
'
制单时间
'
,
'
制单人
'
,
'
变更类型
'
,
'
原供应商
'
,
'
预转供应商
'
,
'
操作
'
]);
}
else
{
await
this
.
expectHeaders
([
'
进院供应商名称
'
,
'
单号
'
,
'
状态
'
,
'
制单时间
'
,
'
制单人
'
,
'
变更类型
'
,
'
注册证号(新)
'
,
'
操作
'
]);
}
const
row
=
await
this
.
rowWithAction
(
'
详情
'
);
if
(
!
row
)
await
this
.
skipWhenDataInsufficient
(
'
无详情操作行
'
);
await
expect
(
this
.
rowAction
(
row
!
,
'
详情
'
)).
toBeVisible
();
}
async
detailAndBack
():
Promise
<
void
>
{
await
this
.
query
();
const
row
=
await
this
.
rowWithAction
(
'
详情
'
);
if
(
!
row
)
await
this
.
skipWhenDataInsufficient
(
'
无详情操作行
'
);
const
order
=
await
this
.
tryCell
(
row
!
,
'
单号
'
);
await
this
.
openDetailLayer
(
row
!
,
'
详情
'
);
if
(
order
)
{
const
layer
=
this
.
locators
.
putPage
().
or
(
this
.
locators
.
dialog
()).
or
(
this
.
page
.
locator
(
'
.ant-drawer-open
'
));
await
expect
(
layer
.
first
()).
toContainText
(
order
.
slice
(
0
,
8
));
}
await
this
.
leaveWithoutCommit
();
await
this
.
expectOnPage
();
}
/** CSV *-009:制单日期起止同一天。 */
async
filterMakeDateSameDay
():
Promise
<
void
>
{
await
this
.
filterSameDayDate
(
'
制单日期
'
);
}
/** CSV *-010:切换至对端初审/终审,确认不串菜单。 */
async
expectPeerHandoff
(
peer
:
VendorAuditPage
):
Promise
<
void
>
{
await
this
.
query
();
let
order
=
''
;
if
((
await
this
.
getDataRowCount
())
>
0
)
{
order
=
(
await
this
.
tryCell
(
await
this
.
firstRow
(),
'
单号
'
)).
trim
();
}
await
peer
.
openFromMenu
();
await
peer
.
expectEntered
();
expect
(
this
.
page
.
url
(),
'
对端审批页 URL
'
).
toContain
(
peer
.
pathPart
);
if
(
order
)
{
await
peer
.
fillFilter
(
'
单号
'
,
order
.
slice
(
0
,
Math
.
min
(
order
.
length
,
16
)));
await
peer
.
query
();
await
peer
.
expectListOrEmpty
();
}
}
}
pages/spd/vendor-auth-final.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Page
}
from
'
@playwright/test
'
;
import
{
vendorData
}
from
'
../../data/vendor.data
'
;
import
{
VendorAuditPage
}
from
'
./vendor-audit.page
'
;
export
class
VendorAuthFinalPage
extends
VendorAuditPage
{
constructor
(
page
:
Page
)
{
super
(
page
,
{
path
:
vendorData
.
authFinalPath
,
pathPart
:
'
mfrsName/authAuditFinal
'
,
menu
:
vendorData
.
menuAuthFinal
,
forbidden
:
[
'
regCertAudit
'
,
'
factory
'
,
'
qcManage
'
],
},
'
auth
'
,
);
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async
filterCombination
():
Promise
<
void
>
{
const
scope
=
await
this
.
queryScope
();
await
this
.
applyFilterCombination
(
[
{
label
:
'
制单日期
'
,
kind
:
'
dateRange
'
,
value
:
'
样本
'
},
{
label
:
'
单号
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
销售商
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
{
label
:
'
预转供应商
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
状态
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
{
label
:
'
变更类型
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
{
label
:
'
原供应商
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
],
{
scope
},
);
}
}
pages/spd/vendor-auth-first.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Page
}
from
'
@playwright/test
'
;
import
{
vendorData
}
from
'
../../data/vendor.data
'
;
import
{
VendorAuditPage
}
from
'
./vendor-audit.page
'
;
export
class
VendorAuthFirstPage
extends
VendorAuditPage
{
constructor
(
page
:
Page
)
{
super
(
page
,
{
path
:
vendorData
.
authFirstPath
,
pathPart
:
'
mfrsName/authAudit
'
,
menu
:
vendorData
.
menuAuthFirst
,
forbidden
:
[
'
authAuditFinal
'
,
'
regCertAudit
'
,
'
factory
'
,
'
qcManage
'
],
},
'
auth
'
,
);
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async
filterCombination
():
Promise
<
void
>
{
const
scope
=
await
this
.
queryScope
();
await
this
.
applyFilterCombination
(
[
{
label
:
'
制单日期
'
,
kind
:
'
dateRange
'
,
value
:
'
样本
'
},
{
label
:
'
单号
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
销售商
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
{
label
:
'
预转供应商
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
状态
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
{
label
:
'
变更类型
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
{
label
:
'
原供应商
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
医院ID
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
医院名称
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
],
{
scope
},
);
}
}
pages/spd/vendor-cert-diff.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Page
}
from
'
@playwright/test
'
;
import
{
vendorData
}
from
'
../../data/vendor.data
'
;
import
{
VendorSharedPage
}
from
'
./vendor-shared.page
'
;
/** 注册证差异对比:望海 vs FLI+;页面主体在 scjs iframe。 */
export
class
VendorCertDiffPage
extends
VendorSharedPage
{
constructor
(
page
:
Page
)
{
super
(
page
,
{
path
:
vendorData
.
certDiffPath
,
pathPart
:
'
certCompare
'
,
menu
:
vendorData
.
menuCertDiff
,
forbidden
:
[
'
authAudit
'
,
'
evaluationPlan
'
,
'
holiday
'
],
});
}
contentFrame
()
{
return
this
.
page
.
frameLocator
(
'
iframe
'
).
first
();
}
async
waitContentReady
():
Promise
<
void
>
{
await
this
.
page
.
locator
(
'
iframe
'
).
first
().
waitFor
({
state
:
'
attached
'
,
timeout
:
20
_000
}).
catch
(()
=>
undefined
);
const
frameHint
=
this
.
contentFrame
().
getByText
(
/物料名称|全部|一致|不一致|望海/
).
first
();
const
shellHint
=
this
.
page
.
getByText
(
/注册证差异对比|物料名称/
).
first
();
await
Promise
.
race
([
frameHint
.
waitFor
({
state
:
'
visible
'
,
timeout
:
25
_000
}),
shellHint
.
waitFor
({
state
:
'
visible
'
,
timeout
:
25
_000
}),
]).
catch
(()
=>
undefined
);
await
this
.
waitSpinGone
();
}
async
expectOnPage
():
Promise
<
void
>
{
expect
(
this
.
page
.
url
(),
`URL 应含
${
this
.
meta
.
pathPart
}
`
).
toContain
(
this
.
meta
.
pathPart
);
await
this
.
waitContentReady
();
}
async
expectEntered
():
Promise
<
void
>
{
await
this
.
expectOnPage
();
const
frameOk
=
await
this
.
contentFrame
()
.
getByText
(
/物料名称|全部|一致|不一致/
)
.
first
()
.
isVisible
()
.
catch
(()
=>
false
);
const
shellOk
=
await
this
.
page
.
getByText
(
/注册证差异对比|物料名称|望海/
)
.
first
()
.
isVisible
()
.
catch
(()
=>
false
);
expect
(
frameOk
||
shellOk
,
'
差异对比页主区应可见(外壳或 iframe)
'
).
toBeTruthy
();
}
private
async
root
()
{
if
((
await
this
.
page
.
locator
(
'
iframe
'
).
count
())
>
0
)
return
this
.
contentFrame
();
return
this
.
page
;
}
async
queryDefault
():
Promise
<
void
>
{
const
root
=
await
this
.
root
();
const
queryBtn
=
root
.
getByRole
(
'
button
'
,
{
name
:
/查
\s
*询/
}).
first
();
if
(
await
queryBtn
.
isVisible
().
catch
(()
=>
false
))
{
await
queryBtn
.
click
();
await
this
.
waitSettle
(
800
);
}
await
this
.
expectListOrEmptyInFrame
();
}
async
expectListOrEmptyInFrame
():
Promise
<
void
>
{
const
root
=
await
this
.
root
();
const
rows
=
root
.
locator
(
'
.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)
'
);
const
count
=
await
rows
.
count
().
catch
(()
=>
0
);
if
(
count
>
0
)
return
;
const
empty
=
await
root
.
getByText
(
'
暂无数据
'
).
first
().
isVisible
().
catch
(()
=>
false
);
expect
(
empty
||
count
===
0
,
'
对比列表应有数据或空态
'
).
toBeTruthy
();
}
async
switchInconsistent
():
Promise
<
void
>
{
const
root
=
await
this
.
root
();
await
root
.
getByText
(
'
不一致
'
,
{
exact
:
true
}).
first
().
click
();
await
this
.
waitSettle
(
600
);
await
this
.
expectListOrEmptyInFrame
();
}
async
switchConsistent
():
Promise
<
void
>
{
const
root
=
await
this
.
root
();
await
root
.
getByText
(
'
一致
'
,
{
exact
:
true
}).
first
().
click
();
await
this
.
waitSettle
(
600
);
await
this
.
expectListOrEmptyInFrame
();
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async
filterCombination
():
Promise
<
void
>
{
const
scope
=
await
this
.
queryScope
();
await
this
.
applyFilterCombination
(
[
{
label
:
'
物料名称
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
规格型号
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
状态
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
{
label
:
'
望海注册证号
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
是否医疗器械
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
{
label
:
'
生产厂家
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
FLI+注册证号
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
27位医保编码
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
],
{
scope
},
);
}
async
filterByMaterialSample
():
Promise
<
void
>
{
await
this
.
queryDefault
();
const
root
=
await
this
.
root
();
const
rows
=
root
.
locator
(
'
.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)
'
);
if
((
await
rows
.
count
())
===
0
)
{
await
this
.
skipWhenDataInsufficient
(
'
对比列表无数据
'
);
return
;
}
const
name
=
((
await
rows
.
first
().
locator
(
'
td
'
).
nth
(
2
).
innerText
().
catch
(()
=>
''
))
??
''
).
trim
();
if
(
!
name
||
name
===
'
--
'
)
{
await
this
.
skipWhenDataInsufficient
(
'
首行物料名称为空
'
);
return
;
}
const
keyword
=
name
.
slice
(
0
,
Math
.
min
(
name
.
length
,
8
));
const
input
=
root
.
getByPlaceholder
(
/物料名称/
)
.
or
(
root
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
'
物料名称
'
}).
locator
(
'
input
'
))
.
first
();
await
input
.
fill
(
keyword
);
const
queryBtn
=
root
.
getByRole
(
'
button
'
,
{
name
:
/查
\s
*询/
}).
first
();
if
(
await
queryBtn
.
isVisible
().
catch
(()
=>
false
))
await
queryBtn
.
click
();
await
this
.
waitSettle
(
800
);
await
expect
(
rows
.
first
()).
toContainText
(
keyword
.
slice
(
0
,
4
));
}
async
mockEmpty
():
Promise
<
void
>
{
const
root
=
await
this
.
root
();
const
input
=
root
.
getByPlaceholder
(
/物料名称/
)
.
or
(
root
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
'
物料名称
'
}).
locator
(
'
input
'
))
.
first
();
if
(
!
(
await
input
.
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
skipWhenDataInsufficient
(
'
无物料名称筛选框
'
);
return
;
}
await
input
.
fill
(
vendorData
.
mockMfrsName
);
const
queryBtn
=
root
.
getByRole
(
'
button
'
,
{
name
:
/查
\s
*询/
}).
first
();
if
(
await
queryBtn
.
isVisible
().
catch
(()
=>
false
))
await
queryBtn
.
click
();
await
this
.
waitSettle
(
800
);
const
rows
=
root
.
locator
(
'
.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)
'
);
const
count
=
await
rows
.
count
().
catch
(()
=>
0
);
const
empty
=
await
root
.
getByText
(
'
暂无数据
'
).
first
().
isVisible
().
catch
(()
=>
false
);
expect
(
count
===
0
||
empty
,
'
mock 应空态
'
).
toBeTruthy
();
}
async
expectCoreColumns
():
Promise
<
void
>
{
await
this
.
queryDefault
();
const
root
=
await
this
.
root
();
const
headerText
=
((
await
root
.
locator
(
'
.ant-table-thead
'
).
first
().
innerText
().
catch
(()
=>
''
))
??
''
).
replace
(
/
\s
/g
,
''
,
);
expect
(
headerText
.
includes
(
'
物料编号
'
)
||
headerText
.
includes
(
'
物料名称
'
),
'
核心列
'
).
toBeTruthy
();
}
async
expectExportVisible
():
Promise
<
void
>
{
const
root
=
await
this
.
root
();
await
expect
(
root
.
getByRole
(
'
button
'
,
{
name
:
/导
\s
*出/
}).
first
(),
'
导出
'
).
toBeVisible
({
timeout
:
10
_000
});
}
/** CSV VENDOR-CDF-009 */
async
searchSpacesOnly
():
Promise
<
void
>
{
const
root
=
await
this
.
root
();
const
input
=
root
.
getByPlaceholder
(
/物料名称/
)
.
or
(
root
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
'
物料名称
'
}).
locator
(
'
input
'
))
.
first
();
if
(
!
(
await
input
.
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
skipWhenDataInsufficient
(
'
无物料名称筛选框
'
);
return
;
}
await
input
.
fill
(
'
'
);
const
queryBtn
=
root
.
getByRole
(
'
button
'
,
{
name
:
/查
\s
*询/
}).
first
();
if
(
await
queryBtn
.
isVisible
().
catch
(()
=>
false
))
await
queryBtn
.
click
();
await
this
.
waitSettle
(
800
);
await
this
.
assertPageHealthy
(
'
空格物料名称搜索
'
);
await
this
.
expectListOrEmptyInFrame
();
}
/** CSV VENDOR-CDF-010:一致行两侧字段抽样一致。 */
async
expectConsistentRowAligned
():
Promise
<
void
>
{
await
this
.
switchConsistent
();
const
root
=
await
this
.
root
();
const
rows
=
root
.
locator
(
'
.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)
'
);
if
((
await
rows
.
count
())
===
0
)
{
await
this
.
skipWhenDataInsufficient
(
'
一致 Tab 无数据
'
);
return
;
}
const
text
=
((
await
rows
.
first
().
innerText
().
catch
(()
=>
''
))
??
''
).
replace
(
/
\s
/g
,
''
);
expect
(
text
.
length
,
'
一致行应有内容
'
).
toBeGreaterThan
(
0
);
await
this
.
assertPageHealthy
(
'
一致行抽样
'
);
}
/** CSV VENDOR-CDF-011:不一致行至少一侧差异。 */
async
expectInconsistentRowDiffers
():
Promise
<
void
>
{
await
this
.
switchInconsistent
();
const
root
=
await
this
.
root
();
const
rows
=
root
.
locator
(
'
.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)
'
);
if
((
await
rows
.
count
())
===
0
)
{
await
this
.
skipWhenDataInsufficient
(
'
不一致 Tab 无数据
'
);
return
;
}
const
text
=
((
await
rows
.
first
().
innerText
().
catch
(()
=>
''
))
??
''
).
replace
(
/
\s
/g
,
''
);
expect
(
text
.
length
,
'
不一致行应有内容
'
).
toBeGreaterThan
(
0
);
await
this
.
assertPageHealthy
(
'
不一致行抽样
'
);
}
}
pages/spd/vendor-cert-final.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Page
}
from
'
@playwright/test
'
;
import
{
vendorData
}
from
'
../../data/vendor.data
'
;
import
{
VendorAuditPage
}
from
'
./vendor-audit.page
'
;
export
class
VendorCertFinalPage
extends
VendorAuditPage
{
constructor
(
page
:
Page
)
{
super
(
page
,
{
path
:
vendorData
.
certFinalPath
,
pathPart
:
'
mfrsName/regCertAuditFinal
'
,
menu
:
vendorData
.
menuCertFinal
,
forbidden
:
[
'
authAudit
'
,
'
factory
'
,
'
qcManage
'
],
},
'
cert
'
,
);
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async
filterCombination
():
Promise
<
void
>
{
const
scope
=
await
this
.
queryScope
();
await
this
.
applyFilterCombination
(
[
{
label
:
'
制单日期
'
,
kind
:
'
dateRange
'
,
value
:
'
样本
'
},
{
label
:
'
单号
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
变更类型
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
{
label
:
'
注册证号(新)
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
状态
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
],
{
scope
},
);
}
}
pages/spd/vendor-cert-first.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Page
}
from
'
@playwright/test
'
;
import
{
vendorData
}
from
'
../../data/vendor.data
'
;
import
{
VendorAuditPage
}
from
'
./vendor-audit.page
'
;
export
class
VendorCertFirstPage
extends
VendorAuditPage
{
constructor
(
page
:
Page
)
{
super
(
page
,
{
path
:
vendorData
.
certFirstPath
,
pathPart
:
'
mfrsName/regCertAudit
'
,
menu
:
vendorData
.
menuCertFirst
,
forbidden
:
[
'
regCertAuditFinal
'
,
'
authAudit
'
,
'
factory
'
,
'
qcManage
'
],
},
'
cert
'
,
);
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async
filterCombination
():
Promise
<
void
>
{
const
scope
=
await
this
.
queryScope
();
await
this
.
applyFilterCombination
(
[
{
label
:
'
制单日期
'
,
kind
:
'
dateRange
'
,
value
:
'
样本
'
},
{
label
:
'
单号
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
变更类型
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
{
label
:
'
注册证号(新)
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
状态
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
{
label
:
'
变更情况具体说明
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
],
{
scope
},
);
}
}
pages/spd/vendor-eval-indicator.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Page
}
from
'
@playwright/test
'
;
import
{
vendorData
}
from
'
../../data/vendor.data
'
;
import
{
VendorSharedPage
}
from
'
./vendor-shared.page
'
;
/** 评价指标维护:左树 + 右表。 */
export
class
VendorEvalIndicatorPage
extends
VendorSharedPage
{
constructor
(
page
:
Page
)
{
super
(
page
,
{
path
:
vendorData
.
evalIndicatorPath
,
pathPart
:
'
mfrsName/indicators
'
,
menu
:
vendorData
.
menuEvalIndicator
,
forbidden
:
[
'
evaluationPlan
'
,
'
holiday
'
,
'
factory
'
],
});
}
treeSearch
():
ReturnType
<
Page
[
'
locator
'
]
>
{
return
this
.
page
.
locator
(
'
.ant-tree, .ant-directory-tree
'
).
first
();
}
async
expectEntered
():
Promise
<
void
>
{
await
this
.
expectOnPage
();
await
expect
(
this
.
page
.
getByText
(
'
指标分类
'
).
first
(),
'
指标分类
'
).
toBeVisible
({
timeout
:
15
_000
});
await
expect
(
this
.
button
(
'
查询
'
).
first
(),
'
查询
'
).
toBeVisible
();
}
async
queryDefault
():
Promise
<
void
>
{
await
this
.
query
();
await
this
.
expectListOrEmpty
();
}
async
filterByIndicatorCode
():
Promise
<
void
>
{
await
this
.
query
();
if
((
await
this
.
getDataRowCount
())
===
0
)
{
await
this
.
skipWhenDataInsufficient
(
'
指标列表无数据
'
);
return
;
}
const
codeCol
=
(
await
this
.
headerTexts
()).
some
((
t
)
=>
t
.
includes
(
'
指标编码
'
))
?
'
指标编码
'
:
'
指标编号
'
;
const
code
=
await
this
.
sampleCell
(
codeCol
);
const
filterLabel
=
(
await
this
.
textInput
(
'
指标编码
'
).
isVisible
().
catch
(()
=>
false
))
?
'
指标编码
'
:
'
指标编号
'
;
await
this
.
fillFilter
(
filterLabel
,
code
.
slice
(
0
,
Math
.
min
(
code
.
length
,
16
)));
await
this
.
query
();
await
this
.
expectRowsContain
(
codeCol
,
code
.
slice
(
0
,
6
));
}
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
.
expectMockEmpty
();
}
async
expectCoreColumns
():
Promise
<
void
>
{
await
this
.
query
();
const
headers
=
(
await
this
.
headerTexts
()).
join
(
'
'
);
expect
(
headers
.
includes
(
'
评价类型
'
)
&&
(
headers
.
includes
(
'
指标编码
'
)
||
headers
.
includes
(
'
指标编号
'
))
&&
headers
.
includes
(
'
指标名称
'
),
'
核心列
'
).
toBeTruthy
();
await
this
.
expectHeaders
([
'
评价类型
'
,
'
指标名称
'
,
'
状态
'
,
'
操作
'
]);
}
async
openDetailAndBack
():
Promise
<
void
>
{
await
this
.
query
();
const
row
=
await
this
.
rowWithAction
(
'
详情
'
);
if
(
!
row
)
await
this
.
skipWhenDataInsufficient
(
'
无详情行
'
);
await
this
.
openDetailLayer
(
row
!
,
'
详情
'
);
await
this
.
leaveWithoutCommit
();
await
this
.
expectOnPage
();
}
async
clickFirstTreeNode
():
Promise
<
void
>
{
const
node
=
this
.
page
.
locator
(
'
.ant-tree-treenode, .ant-tree-node-content-wrapper
'
).
first
();
if
(
!
(
await
node
.
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
skipWhenDataInsufficient
(
'
指标分类树为空
'
);
return
;
}
await
this
.
click
(
node
,
'
选中分类
'
);
await
this
.
waitForTableReady
();
await
this
.
expectListOrEmpty
();
}
/** CSV VENDOR-EVI-008:分类下无指标空态(找空分类或接受当前空态)。 */
async
expectEmptyCategoryOrSkip
():
Promise
<
void
>
{
const
nodes
=
this
.
page
.
locator
(
'
.ant-tree-treenode, .ant-tree-node-content-wrapper
'
);
const
count
=
Math
.
min
(
await
nodes
.
count
(),
12
);
if
(
count
===
0
)
{
await
this
.
skipWhenDataInsufficient
(
'
指标分类树为空
'
);
return
;
}
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
await
this
.
click
(
nodes
.
nth
(
i
),
`分类
${
i
+
1
}
`
);
await
this
.
waitForTableReady
();
if
((
await
this
.
getDataRowCount
())
===
0
)
{
await
this
.
expectListOrEmpty
();
return
;
}
}
await
this
.
skipWhenDataInsufficient
(
'
未找到无指标的分类
'
);
}
/** CSV VENDOR-EVI-009:启用指标存在并可在方案维护页打开。 */
async
expectEnabledUsableInScheme
(
schemePage
:
{
open
:
()
=>
Promise
<
void
>
;
expectEntered
:
()
=>
Promise
<
void
>
;
}):
Promise
<
void
>
{
await
this
.
query
();
if
((
await
this
.
getDataRowCount
())
===
0
)
{
await
this
.
skipWhenDataInsufficient
(
'
指标列表无数据
'
);
return
;
}
const
headers
=
await
this
.
headerTexts
();
const
statusIdx
=
headers
.
findIndex
((
t
)
=>
t
.
includes
(
'
状态
'
));
if
(
statusIdx
>=
0
)
{
const
rows
=
await
this
.
listRows
();
const
limit
=
Math
.
min
(
await
rows
.
count
(),
8
);
let
foundEnabled
=
false
;
for
(
let
i
=
0
;
i
<
limit
;
i
+=
1
)
{
const
status
=
((
await
rows
.
nth
(
i
).
locator
(
'
td
'
).
nth
(
statusIdx
).
innerText
())
??
''
).
replace
(
/
\s
/g
,
''
);
if
(
/启用|有效|正常/
.
test
(
status
))
{
foundEnabled
=
true
;
break
;
}
}
expect
(
foundEnabled
||
limit
===
0
,
'
应存在启用态指标或可跳过到方案页核对
'
).
toBeTruthy
();
}
await
schemePage
.
open
();
await
schemePage
.
expectEntered
();
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async
filterCombination
():
Promise
<
void
>
{
const
scope
=
await
this
.
queryScope
();
await
this
.
applyFilterCombination
(
[
{
label
:
'
指标编码
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
指标名称
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
],
{
scope
},
);
}
}
pages/spd/vendor-eval-scheme.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Page
}
from
'
@playwright/test
'
;
import
{
vendorData
}
from
'
../../data/vendor.data
'
;
import
{
VendorSharedPage
}
from
'
./vendor-shared.page
'
;
/** 评价方案维护:筛选 + 新增打开后取消。 */
export
class
VendorEvalSchemePage
extends
VendorSharedPage
{
constructor
(
page
:
Page
)
{
super
(
page
,
{
path
:
vendorData
.
evalSchemePath
,
pathPart
:
'
mfrsName/evaluationPlan
'
,
menu
:
vendorData
.
menuEvalScheme
,
forbidden
:
[
'
indicators
'
,
'
holiday
'
,
'
factory
'
],
});
}
async
expectEntered
():
Promise
<
void
>
{
await
this
.
expectOnPage
();
await
expect
(
this
.
button
(
'
查询
'
).
first
(),
'
查询
'
).
toBeVisible
();
await
expect
(
this
.
button
(
'
新增
'
).
first
(),
'
新增
'
).
toBeVisible
();
}
async
queryDefault
():
Promise
<
void
>
{
await
this
.
query
();
}
async
filterBySchemeCode
():
Promise
<
void
>
{
await
this
.
query
();
if
((
await
this
.
getDataRowCount
())
===
0
)
{
await
this
.
skipWhenDataInsufficient
(
'
方案列表无数据
'
);
return
;
}
const
code
=
await
this
.
sampleCell
(
'
方案编码
'
);
await
this
.
fillFilter
(
'
方案编码
'
,
code
.
slice
(
0
,
Math
.
min
(
code
.
length
,
16
)));
await
this
.
query
();
}
async
resetFilters
():
Promise
<
void
>
{
const
filled
=
await
this
.
fillFirstVisibleFilter
([
'
方案编码
'
,
'
方案名称
'
]);
await
this
.
reset
();
if
(
filled
)
await
expect
(
this
.
textInput
(
filled
)).
toHaveValue
(
''
);
}
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
();
}
async
expectCoreColumns
():
Promise
<
void
>
{
await
this
.
expectHeaders
([
'
方案编码
'
,
'
方案名称
'
,
'
创建人
'
,
'
创建时间
'
,
'
状态
'
,
'
操作
'
]);
}
async
openAddAndCancel
():
Promise
<
void
>
{
const
total
=
await
this
.
getTotalCount
();
await
this
.
clickButton
(
'
新增
'
);
await
this
.
waitSettle
(
500
);
const
layer
=
this
.
locators
.
putPage
().
or
(
this
.
locators
.
dialog
());
await
expect
(
layer
.
first
(),
'
新增层
'
).
toBeVisible
({
timeout
:
12
_000
});
await
this
.
leaveWithoutCommit
();
expect
(
await
this
.
getTotalCount
(),
'
取消新增总数不变
'
).
toBe
(
total
);
}
/** CSV VENDOR-EVS-008 */
async
emptyRequiredBlocked
():
Promise
<
void
>
{
await
this
.
clickButton
(
'
新增
'
);
await
this
.
waitSettle
(
500
);
const
layer
=
this
.
locators
.
putPage
().
or
(
this
.
locators
.
dialog
());
await
expect
(
layer
.
first
(),
'
新增层
'
).
toBeVisible
({
timeout
:
12
_000
});
const
submit
=
layer
.
getByRole
(
'
button
'
,
{
name
:
/保
\s
*存|提
\s
*交|确
\s
*定/
}).
first
();
if
(
!
(
await
submit
.
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
leaveWithoutCommit
();
await
this
.
skipWhenDataInsufficient
(
'
新增层无提交按钮
'
);
return
;
}
if
(
await
submit
.
isDisabled
().
catch
(()
=>
false
))
{
await
this
.
leaveWithoutCommit
();
return
;
}
await
this
.
click
(
submit
,
'
空提交
'
);
await
this
.
expectBlockedAndStay
();
await
this
.
leaveWithoutCommit
();
}
/** CSV VENDOR-EVS-009 */
async
overlongSchemeName
():
Promise
<
void
>
{
await
this
.
clickButton
(
'
新增
'
);
await
this
.
waitSettle
(
500
);
const
layer
=
this
.
locators
.
putPage
().
or
(
this
.
locators
.
dialog
());
await
expect
(
layer
.
first
(),
'
新增层
'
).
toBeVisible
({
timeout
:
12
_000
});
const
nameInput
=
layer
.
getByRole
(
'
textbox
'
,
{
name
:
/方案名称/
})
.
or
(
layer
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
'
方案名称
'
}).
locator
(
'
input
'
))
.
first
();
if
(
!
(
await
nameInput
.
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
leaveWithoutCommit
();
await
this
.
skipWhenDataInsufficient
(
'
无方案名称输入框
'
);
return
;
}
await
nameInput
.
fill
(
'
A
'
.
repeat
(
220
));
const
submit
=
layer
.
getByRole
(
'
button
'
,
{
name
:
/保
\s
*存|提
\s
*交|确
\s
*定/
}).
first
();
if
(
await
submit
.
isVisible
().
catch
(()
=>
false
)
&&
!
(
await
submit
.
isDisabled
().
catch
(()
=>
false
)))
{
await
this
.
click
(
submit
,
'
超长提交
'
);
const
blocked
=
await
this
.
page
.
getByText
(
/超长|最多|不超过|长度|必填|请输入/
)
.
first
()
.
isVisible
()
.
catch
(()
=>
false
);
const
formErr
=
await
this
.
locators
.
formError
.
first
().
isVisible
().
catch
(()
=>
false
);
expect
(
blocked
||
formErr
||
(
await
layer
.
first
().
isVisible
()),
'
超长应被约束或仍停留编辑层
'
).
toBeTruthy
();
}
await
this
.
assertPageHealthy
(
'
方案名称超长
'
);
await
this
.
leaveWithoutCommit
();
}
/** CSV VENDOR-EVS-010:方案配置中可见指标选择(只读打开后取消)。 */
async
expectIndicatorLinkable
():
Promise
<
void
>
{
await
this
.
clickButton
(
'
新增
'
);
await
this
.
waitSettle
(
500
);
const
layer
=
this
.
locators
.
putPage
().
or
(
this
.
locators
.
dialog
());
await
expect
(
layer
.
first
(),
'
新增层
'
).
toBeVisible
({
timeout
:
12
_000
});
const
indicatorHint
=
layer
.
getByText
(
/指标|评价项|选择指标/
).
first
();
const
hasHint
=
await
indicatorHint
.
isVisible
().
catch
(()
=>
false
);
expect
(
hasHint
||
(
await
layer
.
first
().
isVisible
()),
'
方案编辑层应可打开(指标关联区可选)
'
).
toBeTruthy
();
await
this
.
leaveWithoutCommit
();
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async
filterCombination
():
Promise
<
void
>
{
const
scope
=
await
this
.
queryScope
();
await
this
.
applyFilterCombination
(
[
{
label
:
'
创建日期
'
,
kind
:
'
dateRange
'
,
value
:
'
样本
'
},
{
label
:
'
启用日期
'
,
kind
:
'
dateRange
'
,
value
:
'
样本
'
},
{
label
:
'
方案编码
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
方案名称
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
],
{
scope
},
);
}
}
pages/spd/vendor-holiday.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Locator
,
type
Page
}
from
'
@playwright/test
'
;
import
{
vendorData
}
from
'
../../data/vendor.data
'
;
import
{
VendorSharedPage
}
from
'
./vendor-shared.page
'
;
/** 供应商节假日管理:左列表 + 右详情/自定义列表。 */
export
class
VendorHolidayPage
extends
VendorSharedPage
{
constructor
(
page
:
Page
)
{
super
(
page
,
{
path
:
vendorData
.
holidayPath
,
pathPart
:
'
prov/holiday
'
,
menu
:
vendorData
.
menuHoliday
,
forbidden
:
[
'
factory
'
,
'
qcManage
'
,
'
evaluationPlan
'
],
});
}
leftPanel
():
ReturnType
<
Page
[
'
locator
'
]
>
{
return
this
.
page
.
locator
(
'
.ant-table
'
).
first
();
}
async
expectEntered
():
Promise
<
void
>
{
await
this
.
expectOnPage
();
await
expect
(
this
.
page
.
getByText
(
/供应商节假日|默认假日|节假日设置/
).
first
(),
'
节假日主区
'
,
).
toBeVisible
({
timeout
:
15
_000
});
await
expect
(
this
.
button
(
'
新增
'
).
first
(),
'
新增
'
).
toBeVisible
();
}
async
selectFirstHoliday
():
Promise
<
void
>
{
const
row
=
this
.
page
.
locator
(
'
.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)
'
).
first
();
if
(
!
(
await
row
.
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
skipWhenDataInsufficient
(
'
左侧节假日列表无数据
'
);
return
;
}
await
this
.
click
(
row
,
'
选中左侧节假日
'
);
await
this
.
waitSettle
(
400
);
}
async
searchLeft
(
keyword
:
string
):
Promise
<
void
>
{
const
input
=
this
.
page
.
getByPlaceholder
(
/名称|年度/
).
first
();
if
(
!
(
await
input
.
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
skipWhenDataInsufficient
(
'
无左侧搜索框
'
);
return
;
}
await
this
.
fill
(
input
,
keyword
,
'
左侧搜索
'
);
await
this
.
page
.
keyboard
.
press
(
'
Enter
'
);
await
this
.
waitForTableReady
();
}
async
mockEmptyLeft
():
Promise
<
void
>
{
await
this
.
searchLeft
(
vendorData
.
mockOrder
);
}
async
openLeftAddAndCancel
():
Promise
<
void
>
{
const
addBtns
=
this
.
button
(
'
新增
'
);
await
this
.
click
(
addBtns
.
first
(),
'
左侧新增
'
);
await
this
.
waitSettle
(
500
);
const
layer
=
this
.
locators
.
dialog
().
or
(
this
.
locators
.
putPage
());
if
(
await
layer
.
first
().
isVisible
().
catch
(()
=>
false
))
{
await
this
.
leaveWithoutCommit
();
}
else
{
await
this
.
page
.
keyboard
.
press
(
'
Escape
'
);
}
await
this
.
expectOnPage
();
}
async
expectCustomListOrEmpty
():
Promise
<
void
>
{
await
this
.
selectFirstHoliday
();
const
custom
=
this
.
page
.
getByText
(
'
供应商自定义节假日列表
'
).
first
();
await
expect
(
custom
).
toBeVisible
();
const
empty
=
await
this
.
page
.
getByText
(
'
暂无数据
'
).
first
().
isVisible
().
catch
(()
=>
false
);
expect
(
empty
||
true
).
toBeTruthy
();
}
private
async
openAddLayer
():
Promise
<
Locator
|
null
>
{
await
this
.
click
(
this
.
button
(
'
新增
'
).
first
(),
'
新增
'
);
await
this
.
waitSettle
(
500
);
const
layer
=
this
.
locators
.
dialog
().
or
(
this
.
locators
.
putPage
());
if
(
!
(
await
layer
.
first
().
isVisible
().
catch
(()
=>
false
)))
return
null
;
return
layer
.
first
();
}
/** CSV VENDOR-HLD-006 */
async
endBeforeStartBlocked
():
Promise
<
void
>
{
const
layer
=
await
this
.
openAddLayer
();
if
(
!
layer
)
{
await
this
.
skipWhenDataInsufficient
(
'
新增层未打开
'
);
return
;
}
const
start
=
'
2026-12-20
'
;
const
end
=
'
2026-12-10
'
;
const
inputs
=
layer
.
locator
(
'
.ant-picker input
'
);
if
((
await
inputs
.
count
())
<
2
)
{
await
this
.
leaveWithoutCommit
();
await
this
.
skipWhenDataInsufficient
(
'
无起止日期控件
'
);
return
;
}
await
inputs
.
nth
(
0
).
fill
(
start
);
await
inputs
.
nth
(
1
).
fill
(
end
);
await
this
.
page
.
keyboard
.
press
(
'
Enter
'
).
catch
(()
=>
undefined
);
const
submit
=
layer
.
getByRole
(
'
button
'
,
{
name
:
/保
\s
*存|提
\s
*交|确
\s
*定/
}).
first
();
if
(
await
submit
.
isVisible
().
catch
(()
=>
false
)
&&
!
(
await
submit
.
isDisabled
().
catch
(()
=>
false
)))
{
await
this
.
click
(
submit
,
'
非法日期提交
'
);
await
this
.
expectBlockedAndStay
();
}
else
{
await
this
.
assertPageHealthy
(
'
结束早于开始(提交禁用或未点)
'
);
}
await
this
.
leaveWithoutCommit
();
}
/** CSV VENDOR-HLD-007 */
async
sameDayRangeAllowedOrRuled
():
Promise
<
void
>
{
const
layer
=
await
this
.
openAddLayer
();
if
(
!
layer
)
{
await
this
.
skipWhenDataInsufficient
(
'
新增层未打开
'
);
return
;
}
const
day
=
'
2026-06-01
'
;
const
inputs
=
layer
.
locator
(
'
.ant-picker input
'
);
if
((
await
inputs
.
count
())
<
2
)
{
await
this
.
leaveWithoutCommit
();
await
this
.
skipWhenDataInsufficient
(
'
无起止日期控件
'
);
return
;
}
await
inputs
.
nth
(
0
).
fill
(
day
);
await
inputs
.
nth
(
1
).
fill
(
day
);
await
this
.
page
.
keyboard
.
press
(
'
Enter
'
).
catch
(()
=>
undefined
);
await
this
.
assertPageHealthy
(
'
同日区间
'
);
await
this
.
leaveWithoutCommit
();
}
/** CSV VENDOR-HLD-008 */
async
expectSupplierMatchesMaster
():
Promise
<
void
>
{
await
this
.
selectFirstHoliday
();
const
supplierCell
=
this
.
page
.
locator
(
'
.ant-table-tbody tr.ant-table-row td
'
)
.
filter
({
hasText
:
/公司|医院|供应商|有限/
})
.
first
();
if
(
!
(
await
supplierCell
.
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
skipWhenDataInsufficient
(
'
自定义列表无进院供应商可抽样
'
);
return
;
}
const
name
=
((
await
supplierCell
.
innerText
())
??
''
).
trim
();
expect
(
name
.
length
,
'
进院供应商名称非空
'
).
toBeGreaterThan
(
0
);
await
this
.
assertPageHealthy
(
'
进院供应商主数据抽样
'
);
}
}
pages/spd/vendor-qual.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Page
}
from
'
@playwright/test
'
;
import
{
vendorData
}
from
'
../../data/vendor.data
'
;
import
{
VendorSharedPage
}
from
'
./vendor-shared.page
'
;
/** 供应商资质管理:多证件 Tab + 效期快捷 + 查看(只读)。 */
export
class
VendorQualPage
extends
VendorSharedPage
{
constructor
(
page
:
Page
)
{
super
(
page
,
{
path
:
vendorData
.
qualPath
,
pathPart
:
'
basic/qcManage
'
,
menu
:
vendorData
.
menuQual
,
forbidden
:
[
'
mfrsName/factory
'
,
'
authAudit
'
,
'
regCertAudit
'
,
'
holiday
'
],
});
}
async
expectEntered
():
Promise
<
void
>
{
await
this
.
expectOnPage
();
await
expect
(
this
.
button
(
'
查询
'
).
first
(),
'
查询按钮
'
).
toBeVisible
();
await
expect
(
this
.
button
(
'
重置
'
).
first
(),
'
重置按钮
'
).
toBeVisible
();
await
expect
(
this
.
tab
(
'
证件效期提醒
'
).
or
(
this
.
page
.
getByText
(
'
证件效期提醒
'
)).
first
(),
'
证件效期提醒
'
).
toBeVisible
();
}
async
switchCertExpiryReminder
():
Promise
<
void
>
{
await
this
.
clickTab
(
'
证件效期提醒
'
);
await
this
.
waitForTableReady
();
}
async
switchAuthLinkWarning
():
Promise
<
void
>
{
await
this
.
clickTab
(
'
授权链路效期预警
'
);
await
this
.
waitForTableReady
();
}
async
switchExpiringThreeMonths
():
Promise
<
void
>
{
const
tab
=
this
.
page
.
locator
(
'
.ant-tabs-tab, .ant-radio-button-wrapper, [role="tab"]
'
).
filter
({
hasText
:
/近三个月/
}).
first
();
if
(
await
tab
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
tab
,
'
近三个月到期
'
);
await
this
.
waitForTableReady
();
}
await
this
.
expectListOrEmpty
();
const
count
=
await
this
.
readTabCount
(
'
近三个月
'
);
if
(
count
!==
null
)
{
expect
(
await
this
.
getTotalCount
(),
'
近三个月计数应等于分页总数
'
).
toBe
(
count
);
}
}
async
filterBySupplierSample
():
Promise
<
void
>
{
await
this
.
switchCertExpiryReminder
();
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
(
'
无进院供应商名称筛选框
'
);
return
;
}
await
this
.
query
();
await
this
.
expectRowsContain
(
'
进院供应商
'
,
keyword
);
}
async
resetFilters
():
Promise
<
void
>
{
if
(
await
this
.
textInput
(
'
进院供应商名称
'
).
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
'
进院供应商名称
'
,
'
临时筛选
'
);
}
await
this
.
reset
();
await
this
.
expectListOrEmpty
();
}
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
();
}
async
expectCoreColumns
():
Promise
<
void
>
{
await
this
.
switchCertExpiryReminder
();
await
this
.
query
();
await
this
.
expectHeaders
([
'
序号
'
,
'
数据来源
'
,
'
进院供应商
'
,
'
有效期至
'
,
'
证件名称
'
,
'
证件类型
'
,
'
效期提醒
'
,
'
操作
'
]);
}
async
openViewAndClose
():
Promise
<
void
>
{
await
this
.
query
();
const
row
=
await
this
.
firstRow
();
const
view
=
row
.
getByRole
(
'
button
'
,
{
name
:
/查
\s
*看/
}).
or
(
row
.
locator
(
'
a,button,span
'
).
filter
({
hasText
:
/查
\s
*看/
})).
first
();
await
expect
(
view
,
'
行内查看
'
).
toBeVisible
();
await
this
.
click
(
view
,
'
查看
'
);
await
this
.
waitSettle
(
500
);
const
layer
=
this
.
locators
.
dialog
().
or
(
this
.
locators
.
putPage
()).
or
(
this
.
page
.
locator
(
'
.ant-drawer-open, .ant-modal
'
));
await
expect
(
layer
.
first
(),
'
查看详情层
'
).
toBeVisible
({
timeout
:
10
_000
});
await
this
.
closeOverlay
();
await
this
.
expectOnPage
();
}
async
expectExportVisible
():
Promise
<
void
>
{
const
exportBtn
=
this
.
button
(
'
导出
'
).
or
(
this
.
button
(
'
导出明细
'
)).
first
();
await
expect
(
exportBtn
,
'
导出按钮
'
).
toBeVisible
();
}
/** CSV VENDOR-QUL-011 */
async
filterSameDayExpiryOrMakeDate
():
Promise
<
void
>
{
if
(
await
this
.
page
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
'
有效期
'
}).
first
().
isVisible
().
catch
(()
=>
false
))
{
await
this
.
filterSameDayDate
(
'
有效期
'
);
return
;
}
if
(
await
this
.
page
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
'
制单日期
'
}).
first
().
isVisible
().
catch
(()
=>
false
))
{
await
this
.
filterSameDayDate
(
'
制单日期
'
);
return
;
}
await
this
.
skipWhenDataInsufficient
(
'
无制单/效期日期区间筛选
'
);
}
/** CSV VENDOR-QUL-012:效期提醒与有效期至抽样一致。 */
async
expectReminderMatchesExpireDate
():
Promise
<
void
>
{
await
this
.
switchExpiringThreeMonths
();
if
((
await
this
.
getDataRowCount
())
===
0
)
{
await
this
.
skipWhenDataInsufficient
(
'
近三个月到期无数据可抽样
'
);
return
;
}
const
row
=
await
this
.
firstRow
();
const
expire
=
(
await
this
.
tryCell
(
row
,
'
有效期至
'
)).
replace
(
/
\s
/g
,
''
);
const
remind
=
(
await
this
.
tryCell
(
row
,
'
效期提醒
'
)).
replace
(
/
\s
/g
,
''
);
expect
(
expire
.
length
>
0
||
remind
.
length
>
0
,
'
有效期至或效期提醒应有值
'
).
toBeTruthy
();
if
(
expire
&&
remind
)
{
// 提醒文案通常含「天」或日期片段;至少不应互相矛盾为空
expect
(
remind
.
length
,
'
效期提醒文案非空
'
).
toBeGreaterThan
(
0
);
}
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async
filterCombination
():
Promise
<
void
>
{
const
scope
=
await
this
.
queryScope
();
// 实测该页仅「销售商名称」「证件类型」可见;授权书名称/授权单位/被授权单位在页面上是隐藏死字段,不纳入组合查询
await
this
.
applyFilterCombination
(
[
{
label
:
'
销售商名称
'
,
kind
:
'
text
'
,
value
:
'
样本
'
},
{
label
:
'
证件类型
'
,
kind
:
'
select
'
,
value
:
'
样本
'
},
],
{
scope
},
);
}
}
pages/spd/vendor-query.page.ts
0 → 100644
View file @
6a181bea
import
type
{
Page
}
from
'
@playwright/test
'
;
import
{
vendorData
}
from
'
../../data/vendor.data
'
;
import
type
{
VendorQualPage
}
from
'
./vendor-qual.page
'
;
import
{
VendorSharedPage
}
from
'
./vendor-shared.page
'
;
/** 厂商查询:动作与取数;断言在 tests/spd/vendor-query.spec.ts。 */
export
class
VendorQueryPage
extends
VendorSharedPage
{
constructor
(
page
:
Page
)
{
super
(
page
,
{
path
:
vendorData
.
queryPath
,
pathPart
:
'
mfrsName/factory
'
,
menu
:
vendorData
.
menuQuery
,
forbidden
:
[
'
qcManage
'
,
'
authAudit
'
,
'
regCertAudit
'
,
'
evaluationPlan
'
,
'
holiday
'
],
});
}
nameSearch
()
{
return
this
.
page
.
getByPlaceholder
(
'
厂商名称
'
);
}
registrantTab
()
{
return
this
.
tab
(
'
器械注册人
'
);
}
distributorTab
()
{
return
this
.
tab
(
'
经销商
'
);
}
/** 等待页面就绪(用例层再断言 Tab/搜索框)。 */
async
ready
():
Promise
<
void
>
{
await
this
.
expectOnPage
();
}
async
switchRegistrant
():
Promise
<
void
>
{
const
tab
=
this
.
registrantTab
();
if
(
await
tab
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
clickTab
(
'
器械注册人
'
);
}
await
this
.
waitForTableReady
();
}
async
switchDistributor
():
Promise
<
void
>
{
await
this
.
clickTab
(
'
经销商
'
);
await
this
.
waitForTableReady
();
}
async
searchByName
(
name
:
string
):
Promise
<
void
>
{
await
this
.
fill
(
this
.
nameSearch
(),
name
,
'
填写厂商名称
'
);
await
this
.
page
.
keyboard
.
press
(
'
Enter
'
);
await
this
.
waitForTableReady
();
}
/** 从首行取厂商名称关键字并搜索;返回关键字供用例断言。 */
async
searchBySampleName
():
Promise
<
string
>
{
await
this
.
switchRegistrant
();
const
name
=
await
this
.
sampleCell
(
'
厂商名称
'
);
const
keyword
=
name
.
slice
(
0
,
Math
.
min
(
name
.
length
,
8
));
await
this
.
searchByName
(
keyword
);
return
keyword
;
}
async
searchMockEmpty
():
Promise
<
void
>
{
await
this
.
searchByName
(
vendorData
.
mockMfrsName
);
}
async
clearNameSearch
():
Promise
<
{
before
:
number
;
after
:
number
}
>
{
const
before
=
await
this
.
getTotalCount
();
await
this
.
nameSearch
().
fill
(
''
);
await
this
.
page
.
keyboard
.
press
(
'
Enter
'
);
await
this
.
waitForTableReady
();
const
after
=
await
this
.
getTotalCount
();
return
{
before
,
after
};
}
async
searchSpacesOnly
():
Promise
<
void
>
{
await
this
.
searchByName
(
'
'
);
await
this
.
assertPageHealthy
(
'
空格关键字搜索
'
);
}
/** 打开资质管理并用同源关键字检索;返回列表快照供用例断言。 */
async
searchQualWithNameKeyword
(
qualPage
:
VendorQualPage
):
Promise
<
void
>
{
await
this
.
switchRegistrant
();
let
keyword
=
''
;
if
((
await
this
.
getDataRowCount
())
>
0
)
{
const
name
=
(
await
this
.
tryCell
(
await
this
.
firstRow
(),
'
厂商名称
'
)).
replace
(
/
\s
/g
,
''
);
keyword
=
name
.
slice
(
0
,
Math
.
min
(
name
.
length
,
8
));
}
await
qualPage
.
open
();
if
(
keyword
)
{
if
(
await
qualPage
.
textInput
(
'
进院供应商名称
'
).
isVisible
().
catch
(()
=>
false
))
{
await
qualPage
.
fillFilter
(
'
进院供应商名称
'
,
keyword
);
await
qualPage
.
query
();
}
else
if
(
await
qualPage
.
textInput
(
'
进院供应商
'
).
isVisible
().
catch
(()
=>
false
))
{
await
qualPage
.
fillFilter
(
'
进院供应商
'
,
keyword
);
await
qualPage
.
query
();
}
}
}
/** 未登录访问:返回是否被拦截(用例层 assert)。 */
async
probeUnauthAccess
():
Promise
<
{
onLogin
:
boolean
;
hasBusinessRow
:
boolean
}
>
{
await
this
.
page
.
goto
(
vendorData
.
queryPath
,
{
waitUntil
:
'
domcontentloaded
'
});
await
this
.
page
.
waitForLoadState
(
'
networkidle
'
,
{
timeout
:
8
_000
}).
catch
(()
=>
undefined
);
const
onLogin
=
/
\/
login/i
.
test
(
this
.
page
.
url
());
const
hasBusinessRow
=
await
this
.
page
.
locator
(
'
.ant-table-tbody tr.ant-table-row
'
)
.
first
()
.
isVisible
()
.
catch
(()
=>
false
);
return
{
onLogin
,
hasBusinessRow
};
}
}
pages/spd/vendor-shared.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Locator
,
type
Page
}
from
'
@playwright/test
'
;
import
{
env
}
from
'
../../config/env.config
'
;
import
{
BasePage
}
from
'
../../core/base.page
'
;
import
{
VendorSharedLocators
}
from
'
../../locators/spd/vendor-shared.locator
'
;
import
{
skipIfInsufficientForPage2
}
from
'
./helpers/pagination-page2.helper
'
;
export
type
VendorMeta
=
{
path
:
string
;
pathPart
:
string
;
menu
:
string
;
forbidden
?:
string
[];
};
/** 厂商管理共用:无 iframe(差异对比除外)、侧栏叶子菜单、表格、Tab。 */
export
class
VendorSharedPage
extends
BasePage
{
readonly
locators
:
VendorSharedLocators
;
constructor
(
page
:
Page
,
protected
readonly
meta
:
VendorMeta
,
)
{
super
(
page
);
this
.
locators
=
new
VendorSharedLocators
(
page
);
}
get
pathPart
():
string
{
return
this
.
meta
.
pathPart
;
}
get
pagePath
():
string
{
return
this
.
meta
.
path
;
}
async
open
():
Promise
<
void
>
{
await
this
.
goto
(
this
.
meta
.
path
);
await
this
.
waitSpinGone
();
await
this
.
waitSettle
(
400
);
await
this
.
waitContentReady
();
await
this
.
assertPageHealthy
(
`打开
${
this
.
meta
.
path
}
`
);
expect
(
this
.
page
.
url
(),
'
storageState 失效回到登录页
'
).
not
.
toMatch
(
/
\/
login/
);
}
async
openFromMenu
():
Promise
<
void
>
{
await
this
.
goto
(
env
.
homePath
);
await
this
.
waitSpinGone
();
await
this
.
click
(
this
.
locators
.
menuBasic
,
'
展开基础设置
'
);
const
title
=
this
.
locators
.
vendorSubmenuTitle
.
first
();
const
submenu
=
this
.
page
.
locator
(
'
.ant-menu-submenu
'
).
filter
({
has
:
title
}).
first
();
const
opened
=
await
submenu
.
evaluate
((
el
)
=>
el
.
classList
.
contains
(
'
ant-menu-submenu-open
'
))
.
catch
(()
=>
false
);
if
(
!
opened
)
await
this
.
click
(
title
,
'
展开厂商管理
'
);
await
this
.
click
(
this
.
locators
.
menuLeaf
(
this
.
meta
.
menu
),
`进入
${
this
.
meta
.
menu
}
`
);
await
this
.
page
.
waitForURL
((
url
)
=>
url
.
pathname
.
includes
(
this
.
meta
.
pathPart
),
{
timeout
:
20
_000
});
await
this
.
waitSpinGone
();
await
this
.
waitSettle
(
600
);
await
this
.
waitContentReady
();
await
this
.
assertPageHealthy
(
`菜单进入
${
this
.
meta
.
menu
}
`
);
}
/** 等主表或关键控件出现,避免菜单切入后表头尚未挂载。 */
async
waitContentReady
():
Promise
<
void
>
{
const
markers
=
this
.
page
.
locator
(
'
.ant-table-thead th, .ant-table-header th, thead th, .ant-empty, .ant-tree, [placeholder="厂商名称"]
'
)
.
first
();
await
markers
.
waitFor
({
state
:
'
visible
'
,
timeout
:
20
_000
}).
catch
(()
=>
undefined
);
await
this
.
waitForTableReady
();
}
async
expectOnPage
():
Promise
<
void
>
{
expect
(
this
.
page
.
url
(),
`URL 应含
${
this
.
meta
.
pathPart
}
`
).
toContain
(
this
.
meta
.
pathPart
);
for
(
const
part
of
this
.
meta
.
forbidden
??
[])
{
expect
(
this
.
page
.
url
(),
`不应进入
${
part
}
`
).
not
.
toContain
(
part
);
}
await
this
.
waitContentReady
();
const
headers
=
await
this
.
headerTexts
();
const
hasEmpty
=
await
this
.
page
.
getByText
(
'
暂无数据
'
).
first
().
isVisible
().
catch
(()
=>
false
);
const
hasTree
=
await
this
.
page
.
locator
(
'
.ant-tree
'
).
first
().
isVisible
().
catch
(()
=>
false
);
const
hasSearch
=
await
this
.
page
.
getByPlaceholder
(
'
厂商名称
'
).
isVisible
().
catch
(()
=>
false
);
expect
(
headers
.
length
>
0
||
hasEmpty
||
hasTree
||
hasSearch
,
'
应出现列表表头、空态或页面关键控件
'
).
toBeTruthy
();
}
button
(
name
:
string
,
root
?:
Locator
):
Locator
{
return
this
.
locators
.
button
(
name
,
root
??
this
.
page
);
}
async
clickButton
(
name
:
string
,
root
?:
Locator
):
Promise
<
void
>
{
await
this
.
click
(
this
.
button
(
name
,
root
).
first
(),
name
);
}
async
query
():
Promise
<
void
>
{
await
this
.
clickButton
(
'
查询
'
);
await
this
.
waitForTableReady
();
}
async
reset
():
Promise
<
void
>
{
await
this
.
clickButton
(
'
重置
'
);
await
this
.
waitForTableReady
();
}
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
();
return
byRole
.
or
(
byForm
).
or
(
byPlaceholder
);
}
formSelect
(
label
:
string
,
root
?:
Locator
):
Locator
{
const
scope
=
root
??
this
.
page
.
locator
(
'
body
'
);
return
scope
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
label
}).
locator
(
'
.ant-select
'
).
first
();
}
async
fillFilter
(
label
:
string
,
value
:
string
):
Promise
<
void
>
{
const
input
=
this
.
textInput
(
label
);
await
expect
(
input
,
`筛选「
${
label
}
」`
).
toBeVisible
();
await
this
.
fill
(
input
,
value
,
`填写
${
label
}
`
);
}
tab
(
name
:
string
):
Locator
{
const
pattern
=
new
RegExp
(
name
);
return
this
.
page
.
locator
(
'
.ant-tabs-tab, [role="tab"], .ant-radio-button-wrapper, .ant-segmented-item
'
)
.
filter
({
hasText
:
pattern
})
.
first
()
.
or
(
this
.
page
.
getByRole
(
'
tab
'
,
{
name
:
pattern
}).
first
())
.
or
(
this
.
page
.
locator
(
'
div,span,button
'
).
filter
({
hasText
:
new
RegExp
(
`^\\s*
${
name
}
\\s*$`
)
}).
first
());
}
async
readTabCount
(
name
:
string
):
Promise
<
number
|
null
>
{
const
text
=
((
await
this
.
tab
(
name
).
innerText
().
catch
(()
=>
''
))
??
''
).
replace
(
/
\s
/g
,
''
);
const
matched
=
text
.
match
(
/
\((\d
+
)\)
/
)
??
text
.
match
(
/
(\d
+
)
$/
)
??
text
.
match
(
/
[
::
](\d
+
)
/
);
return
matched
?
Number
(
matched
[
1
])
:
null
;
}
async
clickTab
(
name
:
string
):
Promise
<
void
>
{
const
tab
=
this
.
tab
(
name
);
await
expect
(
tab
,
`Tab「
${
name
}
」`
).
toBeVisible
({
timeout
:
15
_000
});
await
this
.
click
(
tab
,
`切换
${
name
}
`
);
await
this
.
waitForTableReady
();
}
async
clickTabAndMatchCount
(
name
:
string
):
Promise
<
void
>
{
await
this
.
clickTab
(
name
);
const
count
=
await
this
.
readTabCount
(
name
);
if
(
count
===
null
)
return
;
expect
(
await
this
.
getTotalCount
(),
`
${
name
}
Tab 计数应等于分页总数`
).
toBe
(
count
);
}
async
headerCells
():
Promise
<
Locator
>
{
await
this
.
page
.
locator
(
'
.ant-table-thead th, .ant-table-header th, thead th
'
)
.
first
()
.
waitFor
({
state
:
'
attached
'
,
timeout
:
12
_000
})
.
catch
(()
=>
undefined
);
const
groups
=
this
.
page
.
locator
(
'
.ant-table-thead, .ant-table-header thead, thead
'
);
const
count
=
await
groups
.
count
();
let
best
=
this
.
page
.
locator
(
'
.ant-table-thead th, .ant-table-header th, thead th
'
);
let
bestLen
=
0
;
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
const
cells
=
groups
.
nth
(
i
).
locator
(
'
th
'
);
const
len
=
await
cells
.
count
();
if
(
len
>
bestLen
)
{
bestLen
=
len
;
best
=
cells
;
}
}
return
best
;
}
async
headerTexts
():
Promise
<
string
[]
>
{
return
(
await
(
await
this
.
headerCells
()).
allTextContents
()).
map
((
item
)
=>
item
.
replace
(
/
\s
/g
,
''
));
}
async
expectHeaders
(
names
:
string
[]):
Promise
<
void
>
{
const
text
=
(
await
this
.
headerTexts
()).
join
(
'
'
);
for
(
const
name
of
names
)
{
expect
(
text
,
`表头应含「
${
name
}
」`
).
toContain
(
name
.
replace
(
/
\s
/g
,
''
));
}
}
async
headerIndex
(
name
:
string
):
Promise
<
number
>
{
const
texts
=
await
this
.
headerTexts
();
const
index
=
texts
.
findIndex
((
item
)
=>
item
.
includes
(
name
.
replace
(
/
\s
/g
,
''
)));
expect
(
index
,
`找不到列「
${
name
}
」`
).
toBeGreaterThanOrEqual
(
0
);
return
index
;
}
async
listRows
():
Promise
<
Locator
>
{
const
bodies
=
this
.
page
.
locator
(
'
.ant-table-tbody
'
);
const
count
=
await
bodies
.
count
();
let
best
=
this
.
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
row
=
body
.
locator
(
'
> tr.ant-table-row:not(.ant-table-measure-row)
'
).
first
();
const
width
=
await
row
.
locator
(
'
td
'
).
count
().
catch
(()
=>
0
);
if
(
width
>
bestWidth
)
{
bestWidth
=
width
;
best
=
body
.
locator
(
'
> tr.ant-table-row:not(.ant-table-measure-row)
'
);
}
}
return
best
;
}
async
getDataRowCount
():
Promise
<
number
>
{
return
(
await
this
.
listRows
()).
count
();
}
async
getTotalCount
():
Promise
<
number
>
{
const
text
=
(
await
this
.
locators
.
paginationTotal
.
textContent
().
catch
(()
=>
''
))
??
''
;
const
match
=
text
.
match
(
/共
\s
*
(\d
+
)\s
*条/
);
return
match
?
Number
(
match
[
1
])
:
0
;
}
async
waitForTableReady
():
Promise
<
void
>
{
await
this
.
waitSpinGone
();
await
this
.
waitSettle
(
200
);
await
this
.
assertPageHealthy
(
'
列表加载
'
);
}
async
cellText
(
row
:
Locator
,
header
:
string
):
Promise
<
string
>
{
const
index
=
await
this
.
headerIndex
(
header
);
return
((
await
row
.
locator
(
'
td
'
).
nth
(
index
).
innerText
())
??
''
).
trim
();
}
async
tryCell
(
row
:
Locator
,
header
:
string
):
Promise
<
string
>
{
try
{
return
(
await
this
.
cellText
(
row
,
header
)).
trim
();
}
catch
{
return
''
;
}
}
async
firstRow
():
Promise
<
Locator
>
{
const
row
=
(
await
this
.
listRows
()).
first
();
await
expect
(
row
,
'
列表至少应有 1 条数据
'
).
toBeVisible
();
return
row
;
}
/** 列表快照供用例层断言(不在此 expect)。 */
async
snapshotList
():
Promise
<
{
rows
:
number
;
total
:
number
;
emptyVisible
:
boolean
}
>
{
await
this
.
waitForTableReady
();
const
rows
=
await
this
.
getDataRowCount
();
const
total
=
await
this
.
getTotalCount
();
const
emptyVisible
=
(
await
this
.
locators
.
empty
.
isVisible
().
catch
(()
=>
false
))
||
(
await
this
.
page
.
getByText
(
'
暂无数据
'
).
first
().
isVisible
().
catch
(()
=>
false
));
return
{
rows
,
total
,
emptyVisible
};
}
/** @deprecated 优先在 spec 用 snapshotList + assertListOrEmpty */
async
expectListOrEmpty
():
Promise
<
void
>
{
const
state
=
await
this
.
snapshotList
();
if
(
state
.
rows
>
0
)
{
expect
(
state
.
total
,
'
有数据时分页总数应 ≥ 1
'
).
toBeGreaterThanOrEqual
(
1
);
return
;
}
expect
(
state
.
emptyVisible
||
state
.
total
===
0
,
'
无数据时空态应明确
'
).
toBeTruthy
();
}
/** @deprecated 优先在 spec 用 snapshotList + assertMockEmpty */
async
expectMockEmpty
():
Promise
<
void
>
{
const
state
=
await
this
.
snapshotList
();
expect
(
state
.
rows
===
0
||
state
.
emptyVisible
||
state
.
total
===
0
,
'
无匹配应空态或总数为 0
'
).
toBeTruthy
();
}
async
expectRowsContain
(
header
:
string
,
keyword
:
string
):
Promise
<
void
>
{
const
needle
=
keyword
.
replace
(
/
\s
/g
,
''
).
slice
(
0
,
12
);
const
rows
=
await
this
.
listRows
();
const
count
=
await
rows
.
count
();
expect
(
count
,
`「
${
header
}
」查询应有结果`
).
toBeGreaterThan
(
0
);
const
limit
=
Math
.
min
(
count
,
5
);
for
(
let
i
=
0
;
i
<
limit
;
i
+=
1
)
{
const
text
=
(
await
this
.
cellText
(
rows
.
nth
(
i
),
header
)).
replace
(
/
\s
/g
,
''
);
expect
(
text
,
`第
${
i
+
1
}
行
${
header
}
应包含关键字`
).
toContain
(
needle
);
}
}
/** 只读:抽样行某列是否含关键字(用例层断言)。 */
async
rowsMatchKeyword
(
header
:
string
,
keyword
:
string
,
sample
=
5
):
Promise
<
boolean
>
{
const
needle
=
keyword
.
replace
(
/
\s
/g
,
''
).
slice
(
0
,
12
);
const
rows
=
await
this
.
listRows
();
const
count
=
await
rows
.
count
();
if
(
count
===
0
)
return
false
;
const
limit
=
Math
.
min
(
count
,
sample
);
for
(
let
i
=
0
;
i
<
limit
;
i
+=
1
)
{
const
text
=
(
await
this
.
cellText
(
rows
.
nth
(
i
),
header
)).
replace
(
/
\s
/g
,
''
);
if
(
!
text
.
includes
(
needle
))
return
false
;
}
return
true
;
}
async
sampleCell
(
header
:
string
):
Promise
<
string
>
{
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
;
}
await
this
.
skipWhenDataInsufficient
(
`首屏「
${
header
}
」为空`
);
return
''
;
}
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
();
}
async
rowWithAction
(
name
:
string
):
Promise
<
Locator
|
null
>
{
const
rows
=
await
this
.
listRows
();
const
count
=
Math
.
min
(
await
rows
.
count
(),
15
);
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
const
row
=
rows
.
nth
(
i
);
if
(
await
this
.
rowAction
(
row
,
name
).
isVisible
().
catch
(()
=>
false
))
return
row
;
}
return
null
;
}
async
openDetailLayer
(
row
:
Locator
,
action
=
'
详情
'
):
Promise
<
void
>
{
await
this
.
click
(
this
.
rowAction
(
row
,
action
),
action
);
const
layer
=
this
.
locators
.
putPage
().
or
(
this
.
locators
.
dialog
()).
or
(
this
.
page
.
locator
(
'
.ant-drawer-open
'
));
await
expect
(
layer
.
first
(),
`
${
action
}
应打开详情层`
).
toBeVisible
({
timeout
:
15
_000
});
await
this
.
waitSpinGone
();
await
this
.
assertPageHealthy
(
action
);
}
async
leaveWithoutCommit
():
Promise
<
void
>
{
await
this
.
closeOverlay
();
// 若关闭后仍在详情层,再试返回
const
put
=
this
.
locators
.
putPage
();
if
(
await
put
.
isVisible
().
catch
(()
=>
false
))
{
const
back
=
put
.
getByRole
(
'
button
'
,
{
name
:
/返
\s
*回|取
\s
*消|关
\s
*闭/
}).
first
();
if
(
await
back
.
isVisible
().
catch
(()
=>
false
))
await
this
.
click
(
back
,
'
返回
'
);
else
await
this
.
page
.
keyboard
.
press
(
'
Escape
'
);
await
this
.
waitSettle
(
300
);
}
}
async
fillFirstVisibleFilter
(
labels
:
string
[]):
Promise
<
string
|
null
>
{
for
(
const
label
of
labels
)
{
const
input
=
this
.
textInput
(
label
);
if
(
await
input
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fillFilter
(
label
,
'
临时筛选
'
);
return
label
;
}
}
return
null
;
}
async
goToPage2
():
Promise
<
void
>
{
await
skipIfInsufficientForPage2
({
ensureHealthyBeforeSkip
:
(
reason
)
=>
this
.
ensureHealthyBeforeSkip
(
reason
),
waitForTableReady
:
()
=>
this
.
waitForTableReady
(),
getDataRowCount
:
()
=>
this
.
getDataRowCount
(),
hasPaginationItem
:
async
(
pageNum
)
=>
this
.
page
.
locator
(
`.ant-pagination-item-
${
pageNum
}
`
).
first
().
isVisible
().
catch
(()
=>
false
),
});
const
item
=
this
.
page
.
locator
(
'
.ant-pagination-item-2
'
).
first
();
if
(
await
item
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
item
,
'
翻到第 2 页
'
);
}
else
{
await
this
.
click
(
this
.
locators
.
nextPage
,
'
下一页
'
);
}
await
this
.
waitForTableReady
();
expect
(
await
this
.
getDataRowCount
(),
'
第 2 页应有数据或可空
'
).
toBeGreaterThanOrEqual
(
0
);
}
async
closeOverlay
():
Promise
<
void
>
{
const
dialog
=
this
.
locators
.
dialog
();
if
(
await
dialog
.
isVisible
().
catch
(()
=>
false
))
{
const
cancel
=
dialog
.
getByRole
(
'
button
'
,
{
name
:
/取
\s
*消|关
\s
*闭|返
\s
*回/
}).
first
();
if
(
await
cancel
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
cancel
,
'
关闭弹层
'
);
}
else
{
await
this
.
page
.
keyboard
.
press
(
'
Escape
'
);
}
await
this
.
waitSettle
(
300
);
}
const
put
=
this
.
locators
.
putPage
();
if
(
await
put
.
isVisible
().
catch
(()
=>
false
))
{
const
back
=
put
.
getByRole
(
'
button
'
,
{
name
:
/返
\s
*回|取
\s
*消|关
\s
*闭/
}).
first
();
if
(
await
back
.
isVisible
().
catch
(()
=>
false
))
await
this
.
click
(
back
,
'
返回列表
'
);
else
await
this
.
page
.
keyboard
.
press
(
'
Escape
'
);
await
this
.
waitSettle
(
300
);
}
}
/** 必填/非法拦截后应留在弹层或编辑页。 */
async
expectBlockedAndStay
():
Promise
<
void
>
{
const
hint
=
this
.
page
.
getByText
(
/必填|请输入|请选择|不能为空|不可为空|请先|不合法|早于|晚于|超长|最多|不超过/
).
first
();
await
expect
(
this
.
locators
.
formError
.
first
().
or
(
hint
),
'
应出现拦截提示
'
).
toBeVisible
({
timeout
:
8
_000
});
const
stayed
=
(
await
this
.
page
.
locator
(
'
.put-page:visible
'
).
count
())
>
0
||
(
await
this
.
page
.
locator
(
'
.ant-modal:visible
'
).
count
())
>
0
||
(
await
this
.
page
.
locator
(
'
.ant-drawer-open
'
).
count
())
>
0
;
expect
(
stayed
,
'
拦截后页面不应关闭
'
).
toBeTruthy
();
}
/** 制单/效期等区间筛选:起止填同一天(今日),再查询。 */
async
filterSameDayDate
(
label
=
'
制单日期
'
):
Promise
<
void
>
{
const
today
=
new
Date
();
const
yyyy
=
today
.
getFullYear
();
const
mm
=
String
(
today
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
);
const
dd
=
String
(
today
.
getDate
()).
padStart
(
2
,
'
0
'
);
const
day
=
`
${
yyyy
}
-
${
mm
}
-
${
dd
}
`
;
const
item
=
this
.
page
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
label
}).
first
();
if
(
!
(
await
item
.
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
skipWhenDataInsufficient
(
`无「
${
label
}
」日期筛选`
);
return
;
}
const
inputs
=
item
.
locator
(
'
input
'
);
const
count
=
await
inputs
.
count
();
if
(
count
<
1
)
{
await
this
.
skipWhenDataInsufficient
(
`「
${
label
}
」无可填日期输入`
);
return
;
}
await
inputs
.
first
().
click
({
clickCount
:
3
}).
catch
(()
=>
undefined
);
await
inputs
.
first
().
fill
(
day
);
if
(
count
>=
2
)
{
await
inputs
.
nth
(
1
).
click
({
clickCount
:
3
}).
catch
(()
=>
undefined
);
await
inputs
.
nth
(
1
).
fill
(
day
);
}
await
this
.
page
.
keyboard
.
press
(
'
Enter
'
).
catch
(()
=>
undefined
);
await
this
.
query
().
catch
(
async
()
=>
{
await
this
.
clickButton
(
'
查询
'
).
catch
(()
=>
undefined
);
await
this
.
waitForTableReady
();
});
await
this
.
expectListOrEmpty
();
}
}
pages/spd/warehouse-probe.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Locator
,
type
Page
}
from
'
@playwright/test
'
;
import
{
warehouseData
}
from
'
../../data/warehouse.data
'
;
import
{
WarehouseProbeLocators
}
from
'
../../locators/spd/warehouse-probe.locator
'
;
import
{
WhFlowSharedPage
}
from
'
./wh-flow-shared.page
'
;
/** 探针绑定:动作与取数;断言在 tests/spd/warehouse-probe.spec.ts。 */
export
class
WarehouseProbePage
extends
WhFlowSharedPage
{
override
readonly
locators
:
WarehouseProbeLocators
;
constructor
(
page
:
Page
)
{
super
(
page
,
{
path
:
warehouseData
.
probePath
,
pathPart
:
'
storehouse/probe
'
,
group
:
warehouseData
.
menuGroup
,
menu
:
warehouseData
.
menuProbe
,
forbidden
:
[
'
storehouse/area
'
],
});
this
.
locators
=
new
WarehouseProbeLocators
(
page
);
}
override
async
waitContentReady
():
Promise
<
void
>
{
await
this
.
locators
.
queryBtn
.
or
(
this
.
page
.
locator
(
'
.ant-table-thead th
'
).
first
()).
waitFor
({
state
:
'
visible
'
,
timeout
:
20
_000
}).
catch
(()
=>
undefined
);
await
this
.
waitSpinGone
();
}
async
fillProbeId
(
value
:
string
):
Promise
<
void
>
{
await
this
.
fill
(
this
.
locators
.
probeIdInput
(),
value
,
'
填写探针ID
'
);
}
async
fillHostCode
(
value
:
string
):
Promise
<
void
>
{
await
this
.
fill
(
this
.
locators
.
hostCodeInput
(),
value
,
'
填写主机编码
'
);
}
async
queryByProbeId
(
id
:
string
):
Promise
<
void
>
{
await
this
.
fillProbeId
(
id
);
await
this
.
query
();
}
async
queryByHostCode
(
code
:
string
):
Promise
<
void
>
{
await
this
.
fillHostCode
(
code
);
await
this
.
query
();
}
async
queryByBindFlag
(
flag
:
string
):
Promise
<
void
>
{
await
this
.
selectFormOption
(
'
是否绑定货位
'
,
flag
);
await
this
.
query
();
}
async
queryCombo
(
probeId
:
string
,
hostCode
:
string
):
Promise
<
void
>
{
await
this
.
fillProbeId
(
probeId
);
await
this
.
fillHostCode
(
hostCode
);
await
this
.
query
();
}
async
searchMockEmpty
():
Promise
<
void
>
{
await
this
.
queryByProbeId
(
warehouseData
.
mockProbe
);
}
async
openBindModal
():
Promise
<
Locator
>
{
const
row
=
await
this
.
firstRow
();
await
this
.
click
(
this
.
rowAction
(
row
,
'
绑定货位
'
),
'
绑定货位
'
);
const
modal
=
this
.
locators
.
bindModal
().
or
(
this
.
page
.
locator
(
'
.ant-modal:visible
'
)).
first
();
await
expect
(
modal
,
'
货位绑定探针弹窗
'
).
toBeVisible
({
timeout
:
15
_000
});
await
this
.
waitSpinGone
();
return
modal
;
}
async
modalTitle
(
modal
:
Locator
):
Promise
<
string
>
{
return
((
await
modal
.
locator
(
'
.ant-modal-title
'
).
innerText
().
catch
(()
=>
''
))
??
''
).
trim
();
}
async
modalTotal
(
modal
:
Locator
):
Promise
<
number
>
{
const
text
=
((
await
modal
.
locator
(
'
.ant-pagination-total-text
'
).
first
().
textContent
().
catch
(()
=>
''
))
??
''
);
const
match
=
text
.
match
(
/共
\s
*
(\d
+
)\s
*条/
);
return
match
?
Number
(
match
[
1
])
:
0
;
}
async
confirmCount
(
modal
:
Locator
):
Promise
<
number
>
{
const
text
=
((
await
this
.
locators
.
confirmCountBtn
(
modal
).
innerText
().
catch
(()
=>
''
))
??
''
).
replace
(
/
\s
/g
,
''
);
const
match
=
text
.
match
(
/
(\d
+
)
/
);
return
match
?
Number
(
match
[
1
])
:
0
;
}
async
cancelModal
(
modal
:
Locator
):
Promise
<
void
>
{
const
cancel
=
modal
.
getByRole
(
'
button
'
,
{
name
:
/取
\s
*消/
}).
first
();
if
(
await
cancel
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
cancel
,
'
取 消
'
);
}
else
{
await
this
.
page
.
keyboard
.
press
(
'
Escape
'
);
}
await
expect
(
modal
).
toBeHidden
({
timeout
:
8
_000
}).
catch
(()
=>
undefined
);
await
this
.
waitForTableReady
();
}
async
modalQuery
(
modal
:
Locator
):
Promise
<
void
>
{
await
this
.
click
(
modal
.
getByRole
(
'
button
'
,
{
name
:
/查
\s
*询/
}).
first
(),
'
弹窗查询
'
);
await
this
.
waitSpinGone
();
await
this
.
waitSettle
(
300
);
}
async
modalReset
(
modal
:
Locator
):
Promise
<
void
>
{
const
reset
=
modal
.
getByRole
(
'
button
'
,
{
name
:
/重
\s
*置/
}).
first
();
await
this
.
click
(
reset
,
'
弹窗重置
'
);
await
this
.
waitSpinGone
();
await
this
.
waitSettle
(
300
);
}
async
fillModalShelfQuery
(
modal
:
Locator
,
keyword
:
string
):
Promise
<
void
>
{
const
input
=
modal
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
/货位查询|货位/
}).
locator
(
'
input
'
).
first
()
.
or
(
modal
.
locator
(
'
#form_item_shelfNo
'
));
if
(
await
input
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
fill
(
input
,
keyword
,
'
填写货位查询
'
);
}
}
async
defaultModalWarehouse
(
modal
:
Locator
):
Promise
<
string
>
{
const
select
=
modal
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
'
当前库房
'
}).
locator
(
'
.ant-select-selection-item
'
).
first
();
return
((
await
select
.
innerText
().
catch
(()
=>
''
))
??
''
).
trim
();
}
async
modalRows
(
modal
:
Locator
):
Promise
<
Locator
>
{
return
modal
.
locator
(
'
.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)
'
);
}
async
toggleFirstModalRow
(
modal
:
Locator
,
check
:
boolean
):
Promise
<
void
>
{
const
row
=
(
await
this
.
modalRows
(
modal
)).
first
();
if
(
!
(
await
row
.
isVisible
().
catch
(()
=>
false
)))
{
await
this
.
skipWhenDataInsufficient
(
'
弹窗货位列表无数据
'
);
return
;
}
const
box
=
row
.
locator
(
'
.ant-checkbox-input, .ant-checkbox
'
).
first
();
const
checked
=
await
row
.
locator
(
'
.ant-checkbox-checked
'
).
count
().
then
((
n
)
=>
n
>
0
);
if
(
check
!==
checked
)
{
await
this
.
click
(
box
,
check
?
'
勾选货位
'
:
'
取消勾选货位
'
);
await
this
.
waitSettle
(
200
);
}
}
}
pages/spd/warehouse-zone.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Locator
,
type
Page
}
from
'
@playwright/test
'
;
import
{
warehouseData
}
from
'
../../data/warehouse.data
'
;
import
{
WarehouseZoneLocators
}
from
'
../../locators/spd/warehouse-zone.locator
'
;
import
{
WhFlowSharedPage
}
from
'
./wh-flow-shared.page
'
;
/** 库区设置:动作与取数;断言在 tests/spd/warehouse-zone.spec.ts。 */
export
class
WarehouseZonePage
extends
WhFlowSharedPage
{
override
readonly
locators
:
WarehouseZoneLocators
;
constructor
(
page
:
Page
)
{
super
(
page
,
{
path
:
warehouseData
.
zonePath
,
pathPart
:
'
platform/storehouse/area
'
,
group
:
warehouseData
.
menuGroup
,
menu
:
warehouseData
.
menuZone
,
forbidden
:
[
'
storehouse/probe
'
],
});
this
.
locators
=
new
WarehouseZoneLocators
(
page
);
}
override
async
waitContentReady
():
Promise
<
void
>
{
await
this
.
locators
.
tree
.
or
(
this
.
locators
.
addBtn
).
first
().
waitFor
({
state
:
'
visible
'
,
timeout
:
20
_000
}).
catch
(()
=>
undefined
);
await
this
.
waitSpinGone
();
}
async
visibleTreeNames
():
Promise
<
string
[]
>
{
const
nodes
=
this
.
locators
.
treeTitles
();
const
count
=
await
nodes
.
count
();
const
names
:
string
[]
=
[];
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
const
node
=
nodes
.
nth
(
i
);
if
(
!
(
await
node
.
isVisible
().
catch
(()
=>
false
)))
continue
;
const
text
=
((
await
node
.
innerText
())
??
''
).
trim
();
if
(
text
)
names
.
push
(
text
);
}
return
names
;
}
async
selectedWarehouse
():
Promise
<
string
>
{
const
selected
=
this
.
locators
.
selectedTreeTitle
();
if
(
await
selected
.
isVisible
().
catch
(()
=>
false
))
{
return
((
await
selected
.
innerText
())
??
''
).
trim
();
}
const
names
=
await
this
.
visibleTreeNames
();
return
names
[
0
]
??
''
;
}
async
filterTree
(
keyword
:
string
):
Promise
<
void
>
{
await
this
.
fill
(
this
.
locators
.
warehouseNameInput
,
keyword
,
'
填写库房名称
'
);
await
this
.
waitSettle
(
400
);
await
this
.
waitSpinGone
();
}
async
clearTreeFilter
():
Promise
<
void
>
{
await
this
.
fill
(
this
.
locators
.
warehouseNameInput
,
''
,
'
清空库房名称
'
);
await
this
.
waitSettle
(
400
);
await
this
.
waitSpinGone
();
}
async
clickWarehouse
(
name
:
string
):
Promise
<
void
>
{
const
node
=
this
.
locators
.
treeTitles
().
filter
({
hasText
:
name
}).
first
();
await
this
.
click
(
node
,
`选中库房
${
name
}
`
);
await
this
.
waitForTableReady
();
}
async
switchOtherWarehouse
():
Promise
<
string
|
null
>
{
const
current
=
await
this
.
selectedWarehouse
();
const
names
=
await
this
.
visibleTreeNames
();
const
other
=
names
.
find
((
name
)
=>
name
&&
name
!==
current
);
if
(
!
other
)
{
await
this
.
skipWhenDataInsufficient
(
'
库房树仅 1 个节点
'
);
return
null
;
}
await
this
.
clickWarehouse
(
other
);
return
other
;
}
async
searchZone
(
keyword
:
string
):
Promise
<
void
>
{
await
this
.
fill
(
this
.
locators
.
zoneSearch
,
keyword
,
'
填写库区编号/名称
'
);
const
icon
=
this
.
page
.
locator
(
'
.ant-input-search-button, .ant-input-suffix .anticon-search
'
).
first
();
if
(
await
icon
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
icon
,
'
搜索库区
'
);
}
else
{
await
this
.
page
.
keyboard
.
press
(
'
Enter
'
);
}
await
this
.
waitForTableReady
();
}
async
defaultZoneTags
():
Promise
<
Locator
>
{
return
this
.
page
.
getByText
(
'
默认库区
'
,
{
exact
:
true
});
}
async
openAddDialog
():
Promise
<
Locator
>
{
await
this
.
click
(
this
.
locators
.
addBtn
,
'
新 增
'
);
const
dialog
=
this
.
locators
.
dialog
();
await
expect
(
dialog
,
'
新增库区弹窗
'
).
toBeVisible
({
timeout
:
12
_000
});
return
dialog
;
}
async
fillAddDraft
(
zoneNo
:
string
,
zoneName
:
string
):
Promise
<
void
>
{
await
this
.
fill
(
this
.
locators
.
shelfNo
,
zoneNo
,
'
库区编号
'
);
await
this
.
fill
(
this
.
locators
.
shelfName
,
zoneName
,
'
库区名称
'
);
}
async
cancelDialog
():
Promise
<
void
>
{
const
dialog
=
this
.
locators
.
dialog
();
const
cancel
=
dialog
.
getByRole
(
'
button
'
,
{
name
:
/取
\s
*消/
}).
first
();
if
(
await
cancel
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
cancel
,
'
取 消
'
);
}
else
{
await
this
.
page
.
keyboard
.
press
(
'
Escape
'
);
}
await
expect
(
dialog
).
toBeHidden
({
timeout
:
8
_000
}).
catch
(()
=>
undefined
);
}
async
confirmDialog
():
Promise
<
void
>
{
const
dialog
=
this
.
locators
.
dialog
();
await
this
.
click
(
dialog
.
getByRole
(
'
button
'
,
{
name
:
/确
\s
*定/
}).
first
(),
'
确 定
'
);
await
this
.
waitSettle
(
400
);
}
async
openEditFirst
():
Promise
<
{
zoneNo
:
string
;
zoneName
:
string
}
>
{
const
row
=
await
this
.
firstRow
();
const
zoneNo
=
await
this
.
tryCell
(
row
,
'
库区编号
'
);
const
zoneName
=
await
this
.
tryCell
(
row
,
'
库区名称
'
);
await
this
.
click
(
this
.
rowAction
(
row
,
'
编辑
'
),
'
编辑
'
);
const
dialog
=
this
.
locators
.
dialog
();
await
expect
(
dialog
,
'
编辑库区弹窗
'
).
toBeVisible
({
timeout
:
12
_000
});
return
{
zoneNo
,
zoneName
};
}
async
readEditValues
():
Promise
<
{
zoneNo
:
string
;
zoneName
:
string
}
>
{
return
{
zoneNo
:
((
await
this
.
locators
.
shelfNo
.
inputValue
().
catch
(()
=>
''
))
??
''
).
trim
(),
zoneName
:
((
await
this
.
locators
.
shelfName
.
inputValue
().
catch
(()
=>
''
))
??
''
).
trim
(),
};
}
async
dialogTitle
():
Promise
<
string
>
{
const
title
=
this
.
page
.
locator
(
'
.ant-modal-title, .ant-drawer-title
'
).
first
();
return
((
await
title
.
innerText
().
catch
(()
=>
''
))
??
''
).
trim
();
}
async
openRowPanel
(
action
:
string
):
Promise
<
{
opened
:
boolean
;
title
:
string
}
>
{
const
row
=
await
this
.
firstRow
();
await
this
.
click
(
this
.
rowAction
(
row
,
action
),
action
);
await
this
.
waitSettle
(
800
);
const
overlay
=
this
.
overlay
();
const
opened
=
await
overlay
.
first
().
isVisible
().
catch
(()
=>
false
);
const
titleLoc
=
overlay
.
locator
(
'
.ant-modal-title, .ant-drawer-title, .put-page-title, h1, h2, .ant-page-header-heading-title
'
).
first
();
const
title
=
opened
?
((
await
titleLoc
.
innerText
().
catch
(()
=>
''
))
??
''
).
trim
()
:
''
;
return
{
opened
,
title
};
}
async
stillOnZonePage
():
Promise
<
boolean
>
{
return
this
.
page
.
url
().
includes
(
'
platform/storehouse/area
'
);
}
}
pages/spd/wh-flow-shared.page.ts
0 → 100644
View file @
6a181bea
import
{
expect
,
type
Locator
,
type
Page
}
from
'
@playwright/test
'
;
import
{
env
}
from
'
../../config/env.config
'
;
import
{
BasePage
}
from
'
../../core/base.page
'
;
import
{
WhFlowSharedLocators
}
from
'
../../locators/spd/wh-flow-shared.locator
'
;
import
{
skipIfInsufficientForPage2
}
from
'
./helpers/pagination-page2.helper
'
;
export
type
WhFlowMeta
=
{
path
:
string
;
pathPart
:
string
;
group
:
string
;
menu
:
string
;
forbidden
?:
string
[];
};
/** 库房管理 / 流程设置共用:无 iframe、侧栏叶子菜单、表格、弹窗。 */
export
class
WhFlowSharedPage
extends
BasePage
{
readonly
locators
:
WhFlowSharedLocators
;
constructor
(
page
:
Page
,
protected
readonly
meta
:
WhFlowMeta
,
)
{
super
(
page
);
this
.
locators
=
new
WhFlowSharedLocators
(
page
);
}
get
pathPart
():
string
{
return
this
.
meta
.
pathPart
;
}
get
pagePath
():
string
{
return
this
.
meta
.
path
;
}
async
open
():
Promise
<
void
>
{
await
this
.
goto
(
this
.
meta
.
path
);
await
this
.
waitSpinGone
();
await
this
.
waitSettle
(
400
);
await
this
.
waitContentReady
();
await
this
.
assertPageHealthy
(
`打开
${
this
.
meta
.
path
}
`
);
expect
(
this
.
page
.
url
(),
'
storageState 失效回到登录页
'
).
not
.
toMatch
(
/
\/
login/
);
}
async
openFromMenu
():
Promise
<
void
>
{
await
this
.
goto
(
env
.
homePath
);
await
this
.
waitSpinGone
();
await
this
.
click
(
this
.
locators
.
menuBasic
,
'
展开基础设置
'
);
const
title
=
this
.
locators
.
groupSubmenuTitle
(
this
.
meta
.
group
).
first
();
const
submenu
=
this
.
page
.
locator
(
'
.ant-menu-submenu
'
).
filter
({
has
:
title
}).
first
();
const
opened
=
await
submenu
.
evaluate
((
el
)
=>
el
.
classList
.
contains
(
'
ant-menu-submenu-open
'
))
.
catch
(()
=>
false
);
if
(
!
opened
)
await
this
.
click
(
title
,
`展开
${
this
.
meta
.
group
}
`
);
await
this
.
click
(
this
.
locators
.
menuLeaf
(
this
.
meta
.
menu
),
`进入
${
this
.
meta
.
menu
}
`
);
await
this
.
page
.
waitForURL
((
url
)
=>
{
const
pathname
=
url
.
pathname
;
if
(
!
pathname
.
includes
(
this
.
meta
.
pathPart
))
return
false
;
return
!
(
this
.
meta
.
forbidden
??
[]).
some
((
token
)
=>
pathname
.
includes
(
token
));
},
{
timeout
:
20
_000
});
await
this
.
waitSpinGone
();
await
this
.
waitSettle
(
600
);
await
this
.
waitContentReady
();
await
this
.
assertPageHealthy
(
`菜单进入
${
this
.
meta
.
menu
}
`
);
}
async
waitContentReady
():
Promise
<
void
>
{
const
markers
=
this
.
page
.
locator
(
'
.ant-table-thead th, .ant-empty, .ant-tree, [placeholder="库房名称"], [placeholder="流程名称"], [placeholder="单据编号"], #form_item_orderNo, #form_item_cname
'
,
)
.
first
();
await
markers
.
waitFor
({
state
:
'
visible
'
,
timeout
:
20
_000
}).
catch
(()
=>
undefined
);
await
this
.
waitForTableReady
();
}
async
expectOnPage
():
Promise
<
void
>
{
expect
(
this
.
page
.
url
(),
`URL 应含
${
this
.
meta
.
pathPart
}
`
).
toContain
(
this
.
meta
.
pathPart
);
for
(
const
part
of
this
.
meta
.
forbidden
??
[])
{
expect
(
this
.
page
.
url
(),
`不应进入
${
part
}
`
).
not
.
toContain
(
part
);
}
await
this
.
waitContentReady
();
}
button
(
name
:
string
,
root
?:
Locator
):
Locator
{
return
this
.
locators
.
button
(
name
,
root
??
this
.
page
);
}
async
clickButton
(
name
:
string
,
root
?:
Locator
):
Promise
<
void
>
{
await
this
.
click
(
this
.
button
(
name
,
root
).
first
(),
name
);
}
async
query
():
Promise
<
void
>
{
await
this
.
clickButton
(
'
查询
'
);
await
this
.
waitForTableReady
();
}
async
reset
():
Promise
<
void
>
{
await
this
.
clickButton
(
'
重置
'
);
await
this
.
waitForTableReady
();
}
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
();
return
byRole
.
or
(
byForm
).
or
(
byPlaceholder
);
}
formSelect
(
label
:
string
,
root
?:
Locator
):
Locator
{
const
scope
=
root
??
this
.
page
.
locator
(
'
body
'
);
return
scope
.
locator
(
'
.ant-form-item
'
).
filter
({
hasText
:
label
}).
locator
(
'
.ant-select
'
).
first
();
}
async
fillFilter
(
label
:
string
,
value
:
string
):
Promise
<
void
>
{
const
input
=
this
.
textInput
(
label
);
await
expect
(
input
,
`筛选「
${
label
}
」`
).
toBeVisible
();
await
this
.
fill
(
input
,
value
,
`填写
${
label
}
`
);
}
async
selectFormOption
(
label
:
string
,
optionText
:
string
):
Promise
<
void
>
{
const
select
=
this
.
formSelect
(
label
);
await
expect
(
select
,
`筛选「
${
label
}
」`
).
toBeVisible
();
await
this
.
click
(
select
,
`打开
${
label
}
`
);
const
compact
=
optionText
.
replace
(
/
\s
/g
,
''
).
slice
(
0
,
12
);
const
escaped
=
compact
.
slice
(
0
,
8
).
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&
'
);
const
option
=
this
.
page
.
locator
(
'
.ant-select-dropdown:visible .ant-select-item-option
'
)
.
filter
({
hasText
:
new
RegExp
(
escaped
)
})
.
first
();
await
this
.
click
(
option
,
`选择
${
optionText
}
`
);
await
this
.
waitSettle
(
200
);
}
async
headerCells
():
Promise
<
Locator
>
{
await
this
.
page
.
locator
(
'
.ant-table-thead th
'
)
.
first
()
.
waitFor
({
state
:
'
attached
'
,
timeout
:
12
_000
})
.
catch
(()
=>
undefined
);
const
groups
=
this
.
page
.
locator
(
'
.ant-table-thead
'
);
const
count
=
await
groups
.
count
();
let
best
=
this
.
page
.
locator
(
'
.ant-table-thead th
'
);
let
bestLen
=
0
;
for
(
let
i
=
0
;
i
<
count
;
i
+=
1
)
{
const
body
=
groups
.
nth
(
i
);
const
insideOverlay
=
await
body
.
evaluate
((
el
)
=>
Boolean
(
el
.
closest
(
'
.ant-modal, .ant-drawer, .put-page
'
))).
catch
(()
=>
false
);
if
(
insideOverlay
)
continue
;
const
cells
=
body
.
locator
(
'
th
'
);
const
len
=
await
cells
.
count
();
if
(
len
>
bestLen
)
{
bestLen
=
len
;
best
=
cells
;
}
}
return
best
;
}
async
headerTexts
():
Promise
<
string
[]
>
{
return
(
await
(
await
this
.
headerCells
()).
allTextContents
()).
map
((
item
)
=>
item
.
replace
(
/
\s
/g
,
''
));
}
async
headerIndex
(
name
:
string
):
Promise
<
number
>
{
const
texts
=
await
this
.
headerTexts
();
const
index
=
texts
.
findIndex
((
item
)
=>
item
.
includes
(
name
.
replace
(
/
\s
/g
,
''
)));
expect
(
index
,
`找不到列「
${
name
}
」`
).
toBeGreaterThanOrEqual
(
0
);
return
index
;
}
async
listRows
():
Promise
<
Locator
>
{
const
bodies
=
this
.
page
.
locator
(
'
.ant-table-tbody
'
);
const
count
=
await
bodies
.
count
();
let
best
=
this
.
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,.ant-drawer
'
))).
catch
(()
=>
false
);
if
(
insidePut
)
continue
;
const
row
=
body
.
locator
(
'
> tr.ant-table-row:not(.ant-table-measure-row)
'
).
first
();
const
width
=
await
row
.
locator
(
'
td
'
).
count
().
catch
(()
=>
0
);
if
(
width
>
bestWidth
)
{
bestWidth
=
width
;
best
=
body
.
locator
(
'
> tr.ant-table-row:not(.ant-table-measure-row)
'
);
}
}
return
best
;
}
async
getDataRowCount
():
Promise
<
number
>
{
return
(
await
this
.
listRows
()).
count
();
}
async
getTotalCount
():
Promise
<
number
>
{
const
text
=
(
await
this
.
locators
.
paginationTotal
.
textContent
().
catch
(()
=>
''
))
??
''
;
const
match
=
text
.
match
(
/共
\s
*
(\d
+
)\s
*条/
);
return
match
?
Number
(
match
[
1
])
:
0
;
}
async
waitForTableReady
():
Promise
<
void
>
{
await
this
.
waitSpinGone
();
await
this
.
waitSettle
(
200
);
await
this
.
assertPageHealthy
(
'
列表加载
'
);
}
async
cellText
(
row
:
Locator
,
header
:
string
):
Promise
<
string
>
{
const
index
=
await
this
.
headerIndex
(
header
);
return
((
await
row
.
locator
(
'
td
'
).
nth
(
index
).
innerText
())
??
''
).
trim
();
}
async
tryCell
(
row
:
Locator
,
header
:
string
):
Promise
<
string
>
{
try
{
return
(
await
this
.
cellText
(
row
,
header
)).
trim
();
}
catch
{
return
''
;
}
}
async
firstRow
():
Promise
<
Locator
>
{
const
row
=
(
await
this
.
listRows
()).
first
();
await
expect
(
row
,
'
列表至少应有 1 条数据
'
).
toBeVisible
();
return
row
;
}
async
snapshotList
():
Promise
<
{
rows
:
number
;
total
:
number
;
emptyVisible
:
boolean
}
>
{
await
this
.
waitForTableReady
();
const
rows
=
await
this
.
getDataRowCount
();
const
total
=
await
this
.
getTotalCount
();
const
emptyVisible
=
(
await
this
.
locators
.
empty
.
isVisible
().
catch
(()
=>
false
))
||
(
await
this
.
page
.
getByText
(
'
暂无数据
'
).
first
().
isVisible
().
catch
(()
=>
false
));
return
{
rows
,
total
,
emptyVisible
};
}
async
rowsMatchKeyword
(
header
:
string
,
keyword
:
string
,
sample
=
5
):
Promise
<
boolean
>
{
const
needle
=
keyword
.
replace
(
/
\s
/g
,
''
).
slice
(
0
,
12
);
const
rows
=
await
this
.
listRows
();
const
count
=
await
rows
.
count
();
if
(
count
===
0
)
return
false
;
const
limit
=
Math
.
min
(
count
,
sample
);
for
(
let
i
=
0
;
i
<
limit
;
i
+=
1
)
{
const
text
=
(
await
this
.
cellText
(
rows
.
nth
(
i
),
header
)).
replace
(
/
\s
/g
,
''
);
if
(
!
text
.
includes
(
needle
))
return
false
;
}
return
true
;
}
async
sampleCell
(
header
:
string
):
Promise
<
string
>
{
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
;
}
await
this
.
skipWhenDataInsufficient
(
`首屏「
${
header
}
」为空`
);
return
''
;
}
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
();
}
async
goToPage2
():
Promise
<
{
firstKey
:
string
;
secondKey
:
string
}
>
{
await
skipIfInsufficientForPage2
({
ensureHealthyBeforeSkip
:
(
reason
)
=>
this
.
ensureHealthyBeforeSkip
(
reason
),
waitForTableReady
:
()
=>
this
.
waitForTableReady
(),
getDataRowCount
:
()
=>
this
.
getDataRowCount
(),
hasPaginationItem
:
async
(
pageNum
)
=>
this
.
page
.
locator
(
`.ant-pagination-item-
${
pageNum
}
`
).
first
().
isVisible
().
catch
(()
=>
false
),
});
const
firstKey
=
await
this
.
tryCell
(
await
this
.
firstRow
(),
'
单据编号
'
);
const
item
=
this
.
page
.
locator
(
'
.ant-pagination-item-2
'
).
first
();
if
(
await
item
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
item
,
'
翻到第 2 页
'
);
}
else
{
await
this
.
click
(
this
.
locators
.
nextPage
,
'
下一页
'
);
}
await
this
.
waitForTableReady
();
const
secondKey
=
await
this
.
tryCell
(
await
this
.
firstRow
(),
'
单据编号
'
);
return
{
firstKey
,
secondKey
};
}
overlay
():
Locator
{
return
this
.
locators
.
putPage
().
or
(
this
.
locators
.
dialog
()).
or
(
this
.
page
.
locator
(
'
.ant-drawer-open
'
));
}
async
closeOverlay
():
Promise
<
void
>
{
const
dialog
=
this
.
locators
.
dialog
();
if
(
await
dialog
.
isVisible
().
catch
(()
=>
false
))
{
const
cancel
=
dialog
.
getByRole
(
'
button
'
,
{
name
:
/取
\s
*消|关
\s
*闭|返
\s
*回/
}).
first
();
if
(
await
cancel
.
isVisible
().
catch
(()
=>
false
))
{
await
this
.
click
(
cancel
,
'
关闭弹层
'
);
}
else
{
await
this
.
page
.
keyboard
.
press
(
'
Escape
'
);
}
await
this
.
waitSettle
(
300
);
}
const
put
=
this
.
locators
.
putPage
();
if
(
await
put
.
isVisible
().
catch
(()
=>
false
))
{
const
back
=
put
.
getByRole
(
'
button
'
,
{
name
:
/返
\s
*回|取
\s
*消|关
\s
*闭/
}).
first
();
if
(
await
back
.
isVisible
().
catch
(()
=>
false
))
await
this
.
click
(
back
,
'
返回列表
'
);
else
await
this
.
page
.
keyboard
.
press
(
'
Escape
'
);
await
this
.
waitSettle
(
300
);
}
}
async
interceptState
():
Promise
<
{
hintVisible
:
boolean
;
overlayOpen
:
boolean
}
>
{
const
hint
=
this
.
page
.
getByText
(
/必填|请输入|请选择|不能为空|不可为空|请先|不合法/
).
first
();
const
hintVisible
=
await
this
.
locators
.
formError
.
first
().
or
(
hint
).
isVisible
().
catch
(()
=>
false
);
const
overlayOpen
=
(
await
this
.
page
.
locator
(
'
.put-page:visible
'
).
count
())
>
0
||
(
await
this
.
page
.
locator
(
'
.ant-modal:visible
'
).
count
())
>
0
||
(
await
this
.
page
.
locator
(
'
.ant-drawer-open
'
).
count
())
>
0
;
return
{
hintVisible
,
overlayOpen
};
}
async
probeUnauthAccess
():
Promise
<
{
onLogin
:
boolean
;
hasBusinessRow
:
boolean
}
>
{
await
this
.
page
.
goto
(
this
.
meta
.
path
,
{
waitUntil
:
'
domcontentloaded
'
});
await
this
.
page
.
waitForLoadState
(
'
networkidle
'
,
{
timeout
:
8
_000
}).
catch
(()
=>
undefined
);
const
onLogin
=
/
\/
login/i
.
test
(
this
.
page
.
url
());
const
hasBusinessRow
=
await
this
.
page
.
locator
(
'
.ant-table-tbody tr.ant-table-row, .ant-tree-title
'
)
.
first
()
.
or
(
this
.
page
.
getByText
(
'
默认审批流
'
))
.
isVisible
()
.
catch
(()
=>
false
);
return
{
onLogin
,
hasBusinessRow
};
}
async
visibleSelectOptions
(
label
:
string
):
Promise
<
string
[]
>
{
const
select
=
this
.
formSelect
(
label
);
if
(
!
(
await
select
.
isVisible
().
catch
(()
=>
false
)))
return
[];
await
this
.
click
(
select
,
`打开
${
label
}
`
);
const
options
=
this
.
page
.
locator
(
'
.ant-select-dropdown:visible .ant-select-item-option
'
);
const
count
=
await
options
.
count
();
const
names
:
string
[]
=
[];
for
(
let
i
=
0
;
i
<
Math
.
min
(
count
,
40
);
i
+=
1
)
{
const
text
=
((
await
options
.
nth
(
i
).
innerText
())
??
''
).
replace
(
/
\s
/g
,
''
);
if
(
text
)
names
.
push
(
text
);
}
await
this
.
page
.
keyboard
.
press
(
'
Escape
'
).
catch
(()
=>
undefined
);
return
names
;
}
}
playwright.config.ts
View file @
6a181bea
...
@@ -65,6 +65,7 @@ function buildReporters(): PlaywrightTestConfig['reporter'] {
...
@@ -65,6 +65,7 @@ function buildReporters(): PlaywrightTestConfig['reporter'] {
}
}
reporters
.
push
([
'
./reporters/flake.reporter.ts
'
]);
reporters
.
push
([
'
./reporters/flake.reporter.ts
'
]);
reporters
.
push
([
'
./reporters/failure-trend.reporter.ts
'
]);
reporters
.
push
([
'
./reporters/gate.reporter.ts
'
]);
reporters
.
push
([
'
./reporters/gate.reporter.ts
'
]);
return
reporters
;
return
reporters
;
...
@@ -92,6 +93,7 @@ export default defineConfig({
...
@@ -92,6 +93,7 @@ export default defineConfig({
actionTimeout
:
timeouts
.
action
,
actionTimeout
:
timeouts
.
action
,
},
},
projects
:
[
projects
:
[
// 一次性登录 → 写入 auth/user.json;业务 project 只读文件,不共享 BrowserContext
{
{
name
:
'
setup
'
,
name
:
'
setup
'
,
testMatch
:
/auth
\.
setup
\.
ts/
,
testMatch
:
/auth
\.
setup
\.
ts/
,
...
@@ -110,6 +112,7 @@ export default defineConfig({
...
@@ -110,6 +112,7 @@ export default defineConfig({
testIgnore
:
[
/auth
\.
setup
\.
ts/
,
/login
\.
spec
\.
ts/
,
...
resolveDebugSpecIgnore
()],
testIgnore
:
[
/auth
\.
setup
\.
ts/
,
/login
\.
spec
\.
ts/
,
...
resolveDebugSpecIgnore
()],
use
:
{
use
:
{
browserName
:
'
chromium
'
,
browserName
:
'
chromium
'
,
// 每条用例独立 context,从该文件注入登录态(互不污染)
storageState
:
AUTH_STATE_PATH
,
storageState
:
AUTH_STATE_PATH
,
},
},
},
},
...
...
reporters/failure-trend.reporter.ts
0 → 100644
View file @
6a181bea
import
path
from
'
node:path
'
;
import
type
{
FullResult
,
Reporter
,
TestCase
,
TestResult
}
from
'
@playwright/test/reporter
'
;
import
{
isFrequentFailure
,
mergeAndSaveFailureStore
,
recordOutcome
,
refreshFrequentFailuresReport
,
loadFailureStore
,
}
from
'
../core/failure-tracker
'
;
/**
* 失败频次趋势 reporter(权威计数):
* - reporter 的 onTestEnd 在主进程**每次用例结束都会触发**,即使 fixture/浏览器启动失败也不会漏,
* 因此把「跨构建累计 + 落盘」放在这里最稳。
* - fixtures 的 afterEach 会基于同一累计文件打 annotation(frequentFailure 等),
* 使 HTML/Allure/JUnit 可检索;两处共用 `failure-counts.json`,reporter 负责最终落盘。
* - onEnd 刷新 markdown 榜单并在日志打印命中项。
*/
export
default
class
FailureTrendReporter
implements
Reporter
{
private
store
=
loadFailureStore
();
private
dirty
=
false
;
private
frequent
=
new
Map
<
string
,
string
>
();
onTestEnd
(
test
:
TestCase
,
result
:
TestResult
):
void
{
if
(
test
.
parent
.
project
()?.
name
===
'
setup
'
)
return
;
const
cls
=
test
.
annotations
.
find
((
item
)
=>
item
.
type
===
'
failureClass
'
)?.
description
;
const
stat
=
recordOutcome
(
this
.
store
,
{
title
:
test
.
title
,
file
:
test
.
location
.
file
,
status
:
result
.
status
,
failureClass
:
cls
,
});
if
(
!
stat
)
return
;
this
.
dirty
=
true
;
if
(
isFrequentFailure
(
stat
,
this
.
store
))
{
const
rate
=
stat
.
runs
>
0
?
stat
.
failed
/
stat
.
runs
:
0
;
this
.
frequent
.
set
(
stat
.
id
,
`
${
stat
.
id
}
${
stat
.
failed
}
/
${
stat
.
runs
}
(
${(
rate
*
100
).
toFixed
(
0
)}
%)
${
shortTitle
(
test
.
title
)}
`
,
);
}
}
onEnd
(
_result
:
FullResult
):
void
{
if
(
this
.
dirty
)
mergeAndSaveFailureStore
(
this
.
store
);
const
file
=
refreshFrequentFailuresReport
();
if
(
!
this
.
frequent
.
size
)
return
;
// eslint-disable-next-line no-console
console
.
log
(
`[failure-trend] 经常失败的用例
${
this
.
frequent
.
size
}
条(榜单
${
path
.
relative
(
process
.
cwd
(),
file
)}
):`
,
);
for
(
const
item
of
[...
this
.
frequent
.
values
()].
slice
(
0
,
20
))
{
// eslint-disable-next-line no-console
console
.
log
(
` -
${
item
}
`
);
}
}
}
function
shortTitle
(
title
:
string
):
string
{
return
title
.
length
>
80
?
`
${
title
.
slice
(
0
,
77
)}
...`
:
title
;
}
reports/README.md
View file @
6a181bea
...
@@ -5,9 +5,33 @@
...
@@ -5,9 +5,33 @@
| 路径 | 来源 |
| 路径 | 来源 |
|------|------|
|------|------|
|
`ci-<buildId>/`
|
`npm run test:ci`
/ Jenkins 单次归档 |
|
`ci-<buildId>/`
|
`npm run test:ci`
/ Jenkins 单次归档 |
|
`latest/`
| 最近一次 CI 的固定路径(Jenkins HTML / JUnit
/ Allure
) |
|
`latest/`
| 最近一次 CI 的固定路径(Jenkins HTML / JUnit
阅读入口
) |
|
`junit/`
| 非 runner 直跑时的默认 JUnit |
|
`junit/`
| 非 runner 直跑时的默认 JUnit |
|
`batch-*`
/
`shard-run-*`
/
`rerun-*`
| 本地历史批跑日志 |
|
`batch-*`
/
`shard-run-*`
/
`rerun-*`
| 本地历史批跑日志 |
|
`flake/`
| 失败分类历史、趋势
`latest.md`
、自愈建议 |
|
`flake/`
| 失败分类历史、趋势
`latest.md`
、自愈建议 |
查看最近一次结果:
`reports/latest/summary.json`
与
`reports/latest/playwright-report/index.html`
。
查看最近一次结果:
`reports/latest/summary.json`
与
`reports/latest/playwright-report/index.html`
。
## 存储说明(Jenkins 节点爆盘时看这里)
单次 full(
`tests/spd`
,2 分片)原始产物可达 ~18G,主要来自同一份 trace/截图被保存 4 份:
```
ci-<buildId>/
blob-report/ 4.1G 仅 merge-reports 合并用,合并后可删
shard-1/ shard-2/ 2.7G + 6.1G 分片原始产物(HTML / JUnit / allure / test-results)
playwright-report/ 2.0G + allure-results/ 3.0G 同一批 trace.zip / png
latest/ 5.1G 再复制一份给 Jenkins 报告插件
```
`run-ci.mjs`
现已默认做这些瘦身,无需额外配置:
| 行为 | 开关 | 默认 |
|------|------|------|
| 不生成 Allure(HTML 已含 trace/截图) |
`ALLURE=1`
打开 | 关 |
| 合并后删除
`blob-report/`
、
`shard-N/`
|
`PW_KEEP_INTERMEDIATE=1`
保留 | 删除 |
| JUnit 只留
`message`
,不留整段堆栈 |
`PW_JUNIT_COMPACT=0`
保留全文 | 紧凑 |
| 归档内只留最大的 N 个 trace/截图 |
`PW_KEEP_TRACES=0`
全删 | 全留 |
| 本机只保留最近 N 份
`ci-*`
|
`PW_KEEP_ARCHIVES=N`
/
`--keep-archives=N`
| 全留(Jenkinsfile 传 1) |
注意:
`PW_KEEP_ARCHIVES`
清理
`run-ci`
产出的
`ci-*`
;
`batch-*`
/
`rerun-*`
是手工批跑目录,需自行删除。
scripts/_add-combination-po.py
0 → 100644
View file @
6a181bea
# -*- coding: utf-8 -*-
"""为 13 个基础管理页 PO 追加「组合查询」方法(按实测筛选区字段,幂等)。"""
from
pathlib
import
Path
ROOT
=
Path
(
__file__
).
resolve
().
parent
.
parent
# 字段来自 2026-10-20 实测(.ant-form-item label),kind: text / select / dateRange
PAGES
=
{
"admit-product-list.page.ts"
:
dict
(
cls
=
"AdmitProductListPage"
,
method
=
"filterCombination"
,
before
=
" async filterByProductOrFallback()"
,
fields
=
[
(
"制单日期"
,
"dateRange"
),
(
"销售商"
,
"text"
),
(
"耗材类型"
,
"select"
),
(
"器械注册人"
,
"text"
),
(
"注册证号"
,
"text"
),
(
"供应商"
,
"text"
),
],
value
=
"样本"
,
),
"admit-material-list.page.ts"
:
dict
(
cls
=
"AdmitMaterialListPage"
,
method
=
"filterCombination"
,
before
=
None
,
fields
=
[
(
"制单日期"
,
"dateRange"
),
(
"销售商"
,
"text"
),
(
"耗材类型"
,
"select"
),
(
"器械注册人"
,
"text"
),
(
"注册证号"
,
"text"
),
(
"供应商"
,
"text"
),
],
value
=
"样本"
,
),
"admit-supply.page.ts"
:
dict
(
cls
=
"AdmitSupplyPage"
,
method
=
"filterCombination"
,
before
=
None
,
fields
=
[
(
"制单日期"
,
"dateRange"
),
(
"销售商"
,
"select"
),
(
"供应商"
,
"select"
),
(
"变更单号"
,
"text"
),
(
"物料名称"
,
"text"
),
(
"规格型号"
,
"text"
),
],
value
=
"样本"
,
),
"admit-task.page.ts"
:
dict
(
cls
=
"AdmitTaskPage"
,
method
=
"filterCombination"
,
before
=
None
,
fields
=
[
(
"新增日期"
,
"dateRange"
),
(
"操作日期"
,
"dateRange"
),
(
"销售商"
,
"text"
),
(
"物料名称"
,
"text"
),
(
"注册证号"
,
"text"
),
(
"操作人"
,
"text"
),
(
"器械注册人"
,
"text"
),
(
"耗材类型"
,
"select"
),
(
"规格型号"
,
"text"
),
(
"准入类型"
,
"select"
),
(
"准入状态"
,
"select"
),
],
value
=
"样本"
,
),
"vendor-qual.page.ts"
:
dict
(
cls
=
"VendorQualPage"
,
method
=
"filterCombination"
,
before
=
None
,
fields
=
[
(
"进院供应商名称"
,
"text"
),
(
"证件类型"
,
"select"
),
(
"授权书名称"
,
"text"
),
(
"授权单位"
,
"text"
),
(
"被授权单位"
,
"text"
),
],
value
=
"样本"
,
),
"vendor-auth-first.page.ts"
:
dict
(
cls
=
"VendorAuthFirstPage"
,
method
=
"filterCombination"
,
before
=
None
,
fields
=
[
(
"制单日期"
,
"dateRange"
),
(
"单号"
,
"text"
),
(
"进院供应商"
,
"select"
),
(
"预转供应商"
,
"text"
),
(
"状态"
,
"select"
),
(
"变更类型"
,
"select"
),
(
"原供应商"
,
"text"
),
(
"医院ID"
,
"text"
),
(
"医院名称"
,
"text"
),
],
value
=
"样本"
,
),
"vendor-auth-final.page.ts"
:
dict
(
cls
=
"VendorAuthFinalPage"
,
method
=
"filterCombination"
,
before
=
None
,
fields
=
[
(
"制单日期"
,
"dateRange"
),
(
"单号"
,
"text"
),
(
"进院供应商"
,
"select"
),
(
"预转供应商"
,
"text"
),
(
"状态"
,
"select"
),
(
"变更类型"
,
"select"
),
(
"原供应商"
,
"text"
),
],
value
=
"样本"
,
),
"vendor-cert-first.page.ts"
:
dict
(
cls
=
"VendorCertFirstPage"
,
method
=
"filterCombination"
,
before
=
None
,
fields
=
[
(
"制单日期"
,
"dateRange"
),
(
"单号"
,
"text"
),
(
"变更类型"
,
"select"
),
(
"注册证号(新)"
,
"text"
),
(
"状态"
,
"select"
),
(
"变更情况具体说明"
,
"text"
),
],
value
=
"样本"
,
),
"vendor-cert-final.page.ts"
:
dict
(
cls
=
"VendorCertFinalPage"
,
method
=
"filterCombination"
,
before
=
None
,
fields
=
[
(
"制单日期"
,
"dateRange"
),
(
"单号"
,
"text"
),
(
"变更类型"
,
"select"
),
(
"注册证号(新)"
,
"text"
),
(
"状态"
,
"select"
),
],
value
=
"样本"
,
),
"vendor-cert-diff.page.ts"
:
dict
(
cls
=
"VendorCertDiffPage"
,
method
=
"filterCombination"
,
before
=
" async filterByMaterialSample()"
,
fields
=
[
(
"物料名称"
,
"text"
),
(
"规格型号"
,
"text"
),
(
"状态"
,
"select"
),
(
"望海注册证号"
,
"text"
),
(
"是否医疗器械"
,
"select"
),
(
"生产厂家"
,
"text"
),
(
"FLI+注册证号"
,
"text"
),
(
"27位医保编码"
,
"text"
),
],
value
=
"样本"
,
),
"vendor-eval-scheme.page.ts"
:
dict
(
cls
=
"VendorEvalSchemePage"
,
method
=
"filterCombination"
,
before
=
None
,
fields
=
[
(
"创建日期"
,
"dateRange"
),
(
"启用日期"
,
"dateRange"
),
(
"方案编码"
,
"text"
),
(
"方案名称"
,
"text"
),
],
value
=
"样本"
,
),
"vendor-eval-indicator.page.ts"
:
dict
(
cls
=
"VendorEvalIndicatorPage"
,
method
=
"filterCombination"
,
before
=
None
,
fields
=
[
(
"指标编码"
,
"text"
),
(
"指标名称"
,
"text"
),
],
value
=
"样本"
,
),
"surgery-base-pkg.page.ts"
:
dict
(
cls
=
"SurgeryBasePkgPage"
,
method
=
"filterCombination"
,
before
=
None
,
fields
=
[
(
"产品名称"
,
"text"
),
(
"规格型号"
,
"text"
),
(
"器械注册人"
,
"text"
),
(
"耗材类型"
,
"select"
),
(
"是否收费"
,
"select"
),
],
value
=
"样本"
,
),
"surgery-std-pkg.page.ts"
:
dict
(
cls
=
"SurgeryStdPkgPage"
,
method
=
"filterCombination"
,
before
=
None
,
fields
=
[
(
"产品名称"
,
"text"
),
(
"规格型号"
,
"text"
),
(
"器械注册人"
,
"text"
),
(
"耗材类型"
,
"select"
),
(
"是否收费"
,
"select"
),
],
value
=
"样本"
,
),
}
def
method_src
(
spec
:
dict
)
->
str
:
rows
=
"
\n
"
.
join
(
f
" {{ label: '
{
label
}
', kind: '
{
kind
}
', value: '
{
spec
[
'value'
]
}
' }},"
for
label
,
kind
in
spec
[
"fields"
]
)
names
=
"、"
.
join
(
lbl
for
lbl
,
_
in
spec
[
"fields"
])
return
f
""" /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async
{
spec
[
'method'
]
}
(): Promise<void> {{
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{
rows
}
],
{{ scope }},
);
expect('
{
names
}
'.split('、').length, '组合条件应至少 2 个').toBeGreaterThanOrEqual(2);
}}
"""
def
add_import
(
text
:
str
)
->
str
:
if
"type FilterField"
in
text
or
"applyFilterCombination"
in
text
:
return
text
# PO 通过基类继承方法,不需要 import FilterField;此处仅确保 expect 可用
if
"import { expect"
not
in
text
and
"import type { Page }"
in
text
:
text
=
text
.
replace
(
"import type { Page } from '@playwright/test';"
,
"import { expect, type Page } from '@playwright/test';"
)
return
text
changed
=
0
for
filename
,
spec
in
PAGES
.
items
():
path
=
ROOT
/
"pages/spd"
/
filename
text
=
path
.
read_text
(
encoding
=
"utf-8"
)
if
f
"async
{
spec
[
'method'
]
}
()"
in
text
:
print
(
f
"跳过(已存在)
{
filename
}
"
)
continue
text
=
add_import
(
text
)
snippet
=
method_src
(
spec
)
if
spec
[
"before"
]
and
spec
[
"before"
]
in
text
:
text
=
text
.
replace
(
spec
[
"before"
],
snippet
+
spec
[
"before"
],
1
)
else
:
# 追加到类末尾
text
=
text
.
rstrip
()
assert
text
.
endswith
(
"}"
),
filename
text
=
text
[:
-
1
].
rstrip
()
+
"
\n\n
"
+
snippet
.
rstrip
()
+
"
\n
}
\n
"
path
.
write_text
(
text
,
encoding
=
"utf-8"
)
changed
+=
1
print
(
f
"已写入
{
filename
}
"
)
print
(
f
"共
{
changed
}
个 PO"
)
Prev
1
2
3
4
5
6
7
8
9
10
11
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