Commit 6a181bea authored by liguangyu06's avatar liguangyu06
Browse files

将基础管理等未入库改动暂存到 temp 分支,便于远程备份。

新模块用例尚未具备通关凭证,本次按临时备份跳过本地门禁。
parent f6eec849
#!/usr/bin/env python3
"""生成基础管理-厂商管理 10 页结构化 MD(testcases/)与同源 CSV(testcase/)。"""
from __future__ import annotations
import csv
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MD_DIR = ROOT / "testcases"
CSV_DIR = ROOT / "testcase"
# case: (seq, h3, h4, prio P0/P1/P2, title, tp_suffix or full tp, pre, steps_list, expect, auto_id optional)
# tp_suffix: used as TP_VENDOR_{PREFIX}_{suffix:03d} if int, else literal TP string
PAGES: list[dict] = []
def page(**kwargs) -> dict:
return kwargs
def case(
seq: int,
h3: str,
h4: str,
prio: str,
title: str,
tp: str | int,
steps: list[str],
expect: str,
pre: str = "",
auto: str | None = None,
) -> dict:
return {
"seq": seq,
"h3": h3,
"h4": h4,
"prio": prio,
"title": title,
"tp": tp,
"steps": steps,
"expect": expect,
"pre": pre,
"auto": auto,
}
PAGES = [
page(
code="VENDOR-QRY",
prefix="QRY",
name="厂商查询",
path="/spd/basic/mfrs/paltform/mfrsName/factory",
scan="vendor-query",
count=47,
spec="UI-SPEC-SPD-VENDOR-001",
spec_file="vendor-query.spec.ts",
iframe=False,
write_note="本页只读查询,无新增/编辑提交。",
samples="从器械注册人 Tab 首行读取 `${sampleMfrsName}`;禁止写死厂商名称;mock:`__NOT_EXIST_MFRS__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "菜单进入厂商查询页", 1,
["按菜单「基础设置 → 厂商管理 → 厂商查询」进入",
"断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/factory`",
"断言可见「器械注册人」「经销商」Tab、厂商名称搜索框及列表表头"],
"进入厂商查询页;不误入供应商资质管理。",
pre="已登录且具备厂商管理权限", auto="VENDOR-QRY-001"),
case(2, "Tab 与搜索", "功能验证点", "P0", "默认器械注册人列表有数据或空态明确", 9,
["切换或停留「器械注册人」", "断言出现数据行,或「暂无数据」且分页总数为 0"],
"器械注册人列表稳定;有数据时分页「共 N 条」N≥1。", auto="VENDOR-QRY-002"),
case(3, "Tab 与搜索", "功能验证点", "P0", "切换经销商 Tab", 10,
["点击「经销商」", "断言 Tab 激活,列表有数据或空态明确"],
"经销商列表加载成功;总数可与器械注册人不同。", auto="VENDOR-QRY-003"),
case(4, "Tab 与搜索", "功能验证点", "P0", "按厂商名称搜索", 11,
["从首行读取 `${sampleMfrsName}` 取关键字", "在厂商名称搜索框填入关键字并回车",
"断言结果行「厂商名称」包含关键字"],
"厂商名称列与关键字匹配。",
pre="器械注册人至少 1 条且厂商名称非空", auto="VENDOR-QRY-004"),
case(5, "Tab 与搜索", "功能验证点", "P1", "清空搜索恢复", 13,
["在已过滤状态下清空搜索框并回车", "断言分页总数不少于过滤后"],
"恢复更宽结果集。", pre="已按厂商名称过滤", auto="VENDOR-QRY-005"),
case(6, "Tab 与搜索", "异常验证点", "P1", "无匹配空态(mock反例)", 17,
["厂商名称填入 `__NOT_EXIST_MFRS__99999` 并回车", "断言空态且分页总数为 0"],
"无匹配时不报错;总数为 0。", auto="VENDOR-QRY-006"),
case(7, "Tab 与搜索", "功能验证点", "P0", "列表核心列完整", 12,
["断言表头含序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号"],
"核心列可见。", auto="VENDOR-QRY-007"),
case(8, "Tab 与搜索", "功能验证点", "P1", "分页翻至第 2 页", 13,
["总数足够时翻到第 2 页", "断言列表可加载;不足两页则 skip"],
"第 2 页可加载。", pre="器械注册人总数足够翻页", auto="VENDOR-QRY-008"),
case(9, "Tab 与搜索", "边界验证点", "P2", "仅空格关键字搜索", 15,
["厂商名称仅填空格并回车", "断言按空查询处理或提示,不白屏"],
"不报错;行为明确。"),
case(10, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
["清除登录态后直接打开厂商查询 URL", "断言跳转登录或不展示业务列表"],
"未登录不可见厂商数据。"),
case(11, "Tab 与搜索", "集成验证点", "P1", "厂商名称与资质管理进院供应商可对照", 18,
["记录厂商查询中某厂商名称(若有)", "打开供应商资质管理,用同源关键字检索或目视对照",
"断言不出现明显主数据串名(允许一方无记录)"],
"厂商主数据跨菜单可对照,不串名。"),
],
),
page(
code="VENDOR-QUL",
prefix="QUL",
name="供应商资质管理",
path="/spd/basic/mfrs/basic/qcManage",
scan="vendor-qual",
count=99,
spec="UI-SPEC-SPD-VENDOR-002",
spec_file="vendor-qual.spec.ts",
iframe=False,
write_note="本批只读:查看打开后关闭;不确认导出下载、不改证件。",
samples="证件效期提醒 Tab 首行读取 `${sampleHospitalSupplier}`;mock:`__NOT_EXIST_SUPPLIER__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "菜单进入供应商资质管理页", 1,
["按菜单「基础设置 → 厂商管理 → 供应商资质管理」进入",
"断言 URL 含 `/spd/basic/mfrs/basic/qcManage`",
"断言可见「查 询」「重 置」及证件效期提醒相关入口"],
"进入供应商资质管理;不误入厂商查询。",
pre="已登录且具备厂商管理权限", auto="VENDOR-QUL-001"),
case(2, "筛选与 Tab", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
["点击「查 询」并等待列表稳定", "断言出现数据行或空态明确"],
"查询无报错;有数据时分页 N≥1。", auto="VENDOR-QUL-002"),
case(3, "筛选与 Tab", "功能验证点", "P0", "切换授权链路效期预警 Tab", 9,
["点击「授权链路效期预警」", "断言列表有数据或空态明确"],
"Tab 切换成功,列表字段可随 Tab 变化。", auto="VENDOR-QUL-003"),
case(4, "筛选与 Tab", "功能验证点", "P0", "近三个月到期计数一致", 10,
["切换「近三个月到期」(或等价文案)",
"若可读到括号/冒号计数,断言等于分页「共 N 条」"],
"计数与分页一致(可读时)。", auto="VENDOR-QUL-004"),
case(5, "筛选与 Tab", "功能验证点", "P0", "按进院供应商筛选", 11,
["从首行读取 `${sampleHospitalSupplier}` 取关键字",
"在进院供应商名称填入关键字并「查 询」",
"断言结果行「进院供应商」包含关键字"],
"进院供应商列与条件一致。",
pre="证件效期提醒至少 1 条且进院供应商非空", auto="VENDOR-QUL-005"),
case(6, "筛选与 Tab", "功能验证点", "P1", "重置恢复默认", 11,
["填写筛选条件后点击「重 置」", "断言列表按默认规则刷新"],
"筛选恢复默认。", auto="VENDOR-QUL-006"),
case(7, "筛选与 Tab", "异常验证点", "P1", "无匹配空态(mock反例)", 16,
["进院供应商名称填入 `__NOT_EXIST_SUPPLIER__99999`", "点击「查 询」",
"断言空态且分页总数为 0"],
"无匹配时不报错;总数为 0。", auto="VENDOR-QUL-007"),
case(8, "筛选与 Tab", "功能验证点", "P0", "证件效期提醒核心列完整", 12,
["处于证件效期提醒 Tab",
"断言表头含序号、数据来源、进院供应商、有效期至、证件名称、证件类型、效期提醒、操作"],
"核心列可见。", auto="VENDOR-QUL-008"),
case(9, "查看与导出", "功能验证点", "P0", "打开查看后关闭", 14,
["点击首行「查 看」", "断言详情层可见", "关闭/返回,断言回到列表"],
"详情可打开;关闭后回到列表。",
pre="列表至少 1 行含「查看」", auto="VENDOR-QUL-009"),
case(10, "查看与导出", "功能验证点", "P1", "导出按钮可见", 15,
["断言「导出」或「导出明细」按钮可见(本批不确认下载)"],
"导出入口可见。", auto="VENDOR-QUL-010"),
case(11, "筛选与 Tab", "边界验证点", "P2", "制单/效期日期起止同一天", 15,
["若存在日期区间筛选,起止选同一天并查询", "断言仅返回该日数据或空态明确,不报错"],
"同日区间行为明确。"),
case(12, "筛选与 Tab", "集成验证点", "P1", "效期提醒与有效期至抽样一致", 18,
["在近三个月到期列表抽样一行", "核对「效期提醒」文案与「有效期至」日期逻辑一致"],
"提醒文案与有效期至不矛盾。"),
],
),
]
def audit_page(code: str, prefix: str, name: str, path: str, scan: str, count: int, spec: str, kind: str) -> dict:
"""kind: auth | cert"""
peer = "终审" if "初审" in name else "初审"
filter_label = "进院供应商" if kind == "auth" else "注册证号(新)"
cols = (
"进院供应商名称、单号、状态、制单时间、制单人、变更类型、原供应商、预转供应商、操作"
if kind == "auth"
else "进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新)、操作"
)
return page(
code=code,
prefix=prefix,
name=name,
path=path,
scan=scan,
count=count,
spec=spec,
spec_file="vendor-audit.spec.ts",
iframe=False,
write_note="审批写操作只打开详情后返回,不确认通过/驳回/提交。",
samples=f"默认查询后从首行读取 `${{sampleOrderNo}}`;禁止写死单号;mock:`__NOT_EXIST_ORDER__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", f"菜单进入{name}页", 1,
[f"按菜单「基础设置 → 厂商管理 → {name}」进入",
f"断言 URL 含 `{path}`",
"断言可见「查 询」「重 置」及制单日期筛选"],
f"进入{name}页;不误入其它变更审批菜单。",
pre="已登录且具备厂商管理权限", auto=f"{code}-001"),
case(2, "筛选与列表", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
["点击「查 询」并等待列表稳定", "断言出现数据行或空态明确"],
"查询无报错;有数据时分页 N≥1。", auto=f"{code}-002"),
case(3, "筛选与列表", "功能验证点", "P0", "按单号筛选", 10,
["从首行读取 `${sampleOrderNo}` 取关键字", "单号填入关键字并「查 询」",
"断言结果行「单号」包含关键字"],
"单号列与条件一致。",
pre="列表至少 1 条且单号非空", auto=f"{code}-003"),
case(4, "筛选与列表", "功能验证点", "P0", f"按{filter_label}筛选", 11,
[f"按样本填写或选择「{filter_label}」并查询", "断言结果列与条件匹配"],
"筛选条件生效。",
pre="列表至少 1 条", auto=f"{code}-004"),
case(5, "筛选与列表", "功能验证点", "P1", "重置恢复默认", 12,
["填写筛选项后点击「重 置」", "断言文本条件清空、列表按默认规则刷新"],
"重置成功。", auto=f"{code}-005"),
case(6, "筛选与列表", "异常验证点", "P1", "无匹配空态(mock反例)", 16,
["单号填入 `__NOT_EXIST_ORDER__99999` 并查询", "断言空态且分页总数为 0"],
"无匹配时不报错;总数为 0。", auto=f"{code}-006"),
case(7, "筛选与列表", "功能验证点", "P0", "列表核心列完整", 13,
[f"断言表头含 {cols}", "断言存在「详情」操作"],
"核心列与详情入口可见。", auto=f"{code}-007"),
case(8, "详情", "功能验证点", "P0", "打开详情后返回", 14,
["点击行内「详 情」", "断言详情层可见且含单号片段", "返回/关闭,不提交审批"],
"详情只读可返回;列表状态不变。",
pre="存在含「详情」的行", auto=f"{code}-008"),
case(9, "筛选与列表", "边界验证点", "P2", "制单日期起止同一天", 15,
["制单日期起止选同一天并查询", "断言仅返回该日单据或空态明确"],
"同日区间行为明确。"),
case(10, "详情", "集成验证点", "P0", f"与{peer}单据衔接不串单", 18,
[f"记录本页某单号后切换到对应{peer}菜单", "断言同业务单据状态流转合理、不串到非本业务菜单"],
f"初审/终审衔接正确。"),
],
)
PAGES.extend(
[
audit_page("VENDOR-AF1", "AF1", "授权变更初审", "/spd/basic/mfrs/paltform/mfrsName/authAudit", "vendor-auth-first", 40, "UI-SPEC-SPD-VENDOR-003", "auth"),
audit_page("VENDOR-AF2", "AF2", "授权变更终审", "/spd/basic/mfrs/paltform/mfrsName/authAuditFinal", "vendor-auth-final", 40, "UI-SPEC-SPD-VENDOR-004", "auth"),
audit_page("VENDOR-CF1", "CF1", "注册证变更初审", "/spd/basic/mfrs/paltform/mfrsName/regCertAudit", "vendor-cert-first", 40, "UI-SPEC-SPD-VENDOR-005", "cert"),
audit_page("VENDOR-CF2", "CF2", "注册证变更终审", "/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal", "vendor-cert-final", 40, "UI-SPEC-SPD-VENDOR-006", "cert"),
]
)
PAGES.append(
page(
code="VENDOR-CDF",
prefix="CDF",
name="注册证差异对比",
path="/spd/basic/mfrs/scjs/yc/certCompare",
scan="vendor-cert-diff",
count=54,
spec="UI-SPEC-SPD-VENDOR-007",
spec_file="vendor-cert-diff.spec.ts",
iframe=True,
write_note="主体在 scjs iframe;导出本批只断言按钮可见,不确认下载。",
samples="iframe 内首行读取 `${sampleMaterialName}`;mock:`__NOT_EXIST_MFRS__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "菜单进入注册证差异对比页", 1,
["按菜单「基础设置 → 厂商管理 → 注册证差异对比」进入",
"断言 URL 含 `certCompare`",
"断言外壳或 iframe 内可见物料名称/全部/一致/不一致等主区"],
"进入差异对比页;iframe 或主区加载成功。",
pre="已登录且具备厂商管理权限", auto="VENDOR-CDF-001"),
case(2, "筛选与对比", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
["在对比页执行查询(若有查询按钮)", "断言列表有数据或空态明确"],
"查询无报错。", auto="VENDOR-CDF-002"),
case(3, "筛选与对比", "功能验证点", "P0", "切换不一致 Tab", 13,
["点击「不一致」", "断言列表稳定"],
"不一致 Tab 切换成功。", auto="VENDOR-CDF-003"),
case(4, "筛选与对比", "功能验证点", "P1", "切换一致 Tab", 13,
["点击「一致」", "断言列表稳定"],
"一致 Tab 切换成功。", auto="VENDOR-CDF-004"),
case(5, "筛选与对比", "功能验证点", "P0", "按物料名称筛选", 10,
["从首行读取 `${sampleMaterialName}` 取关键字", "物料名称填入并查询",
"断言结果行物料名称包含关键字"],
"物料名称匹配。",
pre="对比列表至少 1 条", auto="VENDOR-CDF-005"),
case(6, "筛选与对比", "异常验证点", "P1", "无匹配空态(mock反例)", 16,
["物料名称填 mock 关键字并查询", "断言空态或总数为 0"],
"无匹配时不报错。", auto="VENDOR-CDF-006"),
case(7, "筛选与对比", "功能验证点", "P0", "列表核心列完整", 14,
["断言表头含物料编号/物料名称/规格型号/生产厂家等关键列"],
"核心对比列可见。", auto="VENDOR-CDF-007"),
case(8, "导出", "功能验证点", "P1", "导出按钮可见", 15,
["断言「导出」按钮可见(不确认下载)"],
"导出入口可见。", auto="VENDOR-CDF-008"),
case(9, "筛选与对比", "边界验证点", "P2", "仅空格物料名称搜索", 15,
["物料名称仅填空格并查询", "断言按空查询处理或提示,不白屏"],
"空格关键字不导致页面异常。"),
case(10, "筛选与对比", "集成验证点", "P0", "一致行两侧字段抽样一致", 17,
["在「一致」Tab 抽样一行", "核对望海与 FLI+ 注册证号/效期关键字段一致"],
"标记一致的行两侧关键字段抽样相同。"),
case(11, "筛选与对比", "集成验证点", "P0", "不一致行至少一侧差异", 18,
["在「不一致」Tab 抽样一行", "断言望海与 FLI+ 至少一处字段不同"],
"不一致结论与字段差异相符。"),
],
)
)
PAGES.append(
page(
code="VENDOR-EVS",
prefix="EVS",
name="评价方案维护",
path="/spd/basic/mfrs/paltform/mfrsName/evaluationPlan",
scan="vendor-eval-scheme",
count=57,
spec="UI-SPEC-SPD-VENDOR-008",
spec_file="vendor-eval-scheme.spec.ts",
iframe=False,
write_note="新增只打开后取消,不提交保存。",
samples="有数据时从首行读取 `${sampleSchemeCode}`;mock:`__NOT_EXIST_ORDER__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "菜单进入评价方案维护页", 1,
["按菜单进入评价方案维护", "断言 URL 含 `evaluationPlan`", "断言可见查询与新增"],
"进入评价方案维护页。",
pre="已登录且具备厂商管理权限", auto="VENDOR-EVS-001"),
case(2, "筛选与维护", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
["点击查询", "断言列表有数据或「暂无数据」空态"],
"查询无报错。", auto="VENDOR-EVS-002"),
case(3, "筛选与维护", "功能验证点", "P0", "按方案编码筛选", 10,
["取样本方案编码筛选并查询", "断言方案编码列匹配"],
"方案编码匹配。",
pre="列表有数据", auto="VENDOR-EVS-003"),
case(4, "筛选与维护", "功能验证点", "P1", "重置恢复默认", 11,
["填写筛选后重置", "断言条件清空、列表刷新"],
"重置成功。", auto="VENDOR-EVS-004"),
case(5, "筛选与维护", "异常验证点", "P1", "无匹配空态(mock反例)", 15,
["方案编码填 mock 后查询", "断言空态总数 0"],
"无匹配不报错。", auto="VENDOR-EVS-005"),
case(6, "筛选与维护", "功能验证点", "P0", "列表核心列完整", 12,
["断言表头含方案编码、方案名称、创建人、创建时间、状态、操作"],
"核心列可见。", auto="VENDOR-EVS-006"),
case(7, "新增", "功能验证点", "P0", "打开新增后取消", 13,
["记录总数", "点击新增打开编辑层", "取消/返回不提交", "断言总数不变"],
"取消不落库。", auto="VENDOR-EVS-007"),
case(8, "新增", "异常验证点", "P0", "必填为空提交拦截", 16,
["打开新增不填必填项尝试提交(若可点)", "断言拦截提示,页面不关闭或总数不变"],
"空提交被拦截。"),
case(9, "筛选与维护", "边界验证点", "P2", "方案名称超长输入", 15,
["打开新增,方案名称输入超长字符串(如 200+ 字符)", "断言前端截断/校验提示或后端拒绝,不白屏"],
"超长输入被约束且页面可用。"),
case(10, "筛选与维护", "集成验证点", "P1", "方案与评价指标可关联", 18,
["在方案配置中查看指标选择(只读打开)", "断言可选指标来源于评价指标维护启用项或规则允许集合"],
"方案与指标主数据衔接正确。"),
],
)
)
PAGES.append(
page(
code="VENDOR-EVI",
prefix="EVI",
name="评价指标维护",
path="/spd/basic/mfrs/paltform/mfrsName/indicators",
scan="vendor-eval-indicator",
count=151,
spec="UI-SPEC-SPD-VENDOR-009",
spec_file="vendor-eval-indicator.spec.ts",
iframe=False,
write_note="详情只读返回;不落库新增/编辑提交。",
samples="右表首行读取 `${sampleIndicatorCode}`;列名可能为指标编码或指标编号;mock:`__NOT_EXIST_ORDER__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "菜单进入评价指标维护页", 1,
["按菜单进入评价指标维护", "断言 URL 含 `indicators`", "断言可见「指标分类」与查询"],
"进入评价指标维护页。",
pre="已登录且具备厂商管理权限", auto="VENDOR-EVI-001"),
case(2, "树与列表", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
["点击查询", "断言右表有数据或空态"],
"查询无报错。", auto="VENDOR-EVI-002"),
case(3, "树与列表", "功能验证点", "P0", "点击左侧分类树", 10,
["点击左侧首个分类节点", "断言右侧列表刷新"],
"分类切换成功。", auto="VENDOR-EVI-003"),
case(4, "树与列表", "功能验证点", "P0", "按指标编码筛选", 11,
["取样本指标编码/编号筛选并查询", "断言编码列匹配"],
"指标编码匹配。",
pre="右表有数据", auto="VENDOR-EVI-004"),
case(5, "树与列表", "异常验证点", "P1", "无匹配空态(mock反例)", 15,
["指标编码填 mock 后查询", "断言空态"],
"无匹配不报错。", auto="VENDOR-EVI-005"),
case(6, "树与列表", "功能验证点", "P0", "列表核心列完整", 12,
["断言表头含评价类型、指标编码或指标编号、指标名称、状态、操作"],
"核心列可见。", auto="VENDOR-EVI-006"),
case(7, "详情", "功能验证点", "P0", "打开详情后返回", 14,
["点击行内详情", "断言详情层可见", "返回不保存"],
"详情只读可返回。",
pre="存在详情操作行", auto="VENDOR-EVI-007"),
case(8, "树与列表", "边界验证点", "P2", "分类下无指标空态", 15,
["选择无指标的分类(若有)", "断言右表空态明确"],
"空分类下列表空态正确。"),
case(9, "树与列表", "集成验证点", "P1", "启用指标可供方案使用", 18,
["确认状态为启用的指标", "在评价方案维护中核对其可选(只读核对或抽样)"],
"启用指标与方案引用衔接正确。"),
],
)
)
PAGES.append(
page(
code="VENDOR-HLD",
prefix="HLD",
name="供应商节假日管理",
path="/spd/basic/mfrs/basic/prov/holiday",
scan="vendor-holiday",
count=80,
spec="UI-SPEC-SPD-VENDOR-010",
spec_file="vendor-holiday.spec.ts",
iframe=False,
write_note="左侧新增只打开后取消;不批量删除真实数据。",
samples="左侧首行读取 `${sampleHolidayName}`/年度;mock:`__NOT_EXIST_ORDER__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "菜单进入供应商节假日管理页", 1,
["按菜单进入供应商节假日管理", "断言 URL 含 `prov/holiday`",
"断言可见节假日主区与新增"],
"进入供应商节假日管理页。",
pre="已登录且具备厂商管理权限", auto="VENDOR-HLD-001"),
case(2, "左右分栏", "功能验证点", "P0", "选中左侧节假日展示详情", 9,
["点击左侧首行", "断言默认假日详情或自定义列表区可见"],
"详情联动成功。",
pre="左侧有数据", auto="VENDOR-HLD-002"),
case(3, "左右分栏", "异常验证点", "P1", "左侧搜索无匹配空态(mock反例)", 15,
["左侧搜索框填 mock 并回车", "断言左侧列表空态"],
"无匹配空态明确。", auto="VENDOR-HLD-003"),
case(4, "维护", "功能验证点", "P0", "左侧新增取消", 11,
["点击新增", "取消/关闭不保存", "断言仍停留本页且无异常"],
"取消不落库。", auto="VENDOR-HLD-004"),
case(5, "左右分栏", "功能验证点", "P1", "自定义节假日列表区可见", 12,
["选中左侧行", "断言「供应商自定义节假日列表」区域可见(可为空态)"],
"自定义区可见。", auto="VENDOR-HLD-005"),
case(6, "维护", "异常验证点", "P0", "结束早于开始日期拦截", 16,
["打开新增/编辑,结束日期早于开始日期尝试保存", "断言拒绝保存并提示"],
"非法日期被拦截。"),
case(7, "维护", "边界验证点", "P2", "开始结束为同一天", 15,
["打开新增/编辑,开始与结束日期选同一天尝试保存(或仅校验控件)",
"断言允许保存或明确提示规则(按产品规则),不报错白屏"],
"同日区间规则明确。"),
case(8, "左右分栏", "集成验证点", "P1", "进院供应商主数据一致", 18,
["在自定义列表抽样进院供应商(若有数据)", "断言可在厂商/供应商主数据中找到对应记录"],
"自定义节假日供应商与主数据一致。"),
],
)
)
def tp_id(p: dict, c: dict) -> str:
tp = c["tp"]
if isinstance(tp, int):
return f"TP_VENDOR_{p['prefix']}_{tp:03d}"
return tp
def prio_csv(p: str) -> str:
return {"P0": "高", "P1": "中", "P2": "低"}[p]
def render_md(p: dict) -> str:
lines = [
f"# 基础管理-厂商管理-{p['name']} UI 自动化测试用例",
"",
f"**来源测试点**:`testpoint/基础管理-厂商管理-{p['name']}-测试点分析-01.md` ",
f"**执行 CSV**:`testcase/基础管理-厂商管理-{p['name']}-UI自动化测试用例-01.csv` ",
f"**页面 URL**:`{p['path']}` ",
f"**定位器 YAML**:`locators/spd/{p['scan']}.locator.yaml` ",
f"**元素扫描**:`data/scan/{p['scan']}-elements.json`({p['count']} 项) ",
f"**关联规范**:`knowledge-base/08-UI专项规范库/{p['spec']}.md` ",
f"**版本**:2026-09-30 ",
f"**自动化框架**:Playwright + TypeScript(`tests/spd/{p['spec_file']}`,依赖 `auth.setup` 登录态)",
"",
f"**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → {p['name']}」权限。 ",
f"**页面约定**:{'主体在 scjs **iframe**。' if p['iframe'] else '整页渲染,**无报表 iframe**。'} ",
f"**写操作**:{p['write_note']} ",
f"**动态取数**:{p['samples']}",
"",
"---",
"",
"## 基础设置 · 厂商管理",
"",
]
current_h3 = ""
current_h4 = ""
for c in p["cases"]:
h3 = f"{p['name']} · {c['h3']}"
if h3 != current_h3:
current_h3 = h3
current_h4 = ""
lines += [f"### {h3}", ""]
if c["h4"] != current_h4:
current_h4 = c["h4"]
lines += [f"#### {current_h4}", ""]
case_id = f"{p['code']}-{c['seq']:03d}"
tc = f"TC-UI-{case_id}"
lines += [
f"##### 【{c['prio']}】【{tc}】【{tp_id(p, c)}】{c['title']}",
"",
f"- 前置条件:{c['pre'] or ('已进入' + p['name'] + '页')}",
"- 操作步骤:",
]
for i, step in enumerate(c["steps"], 1):
lines.append(f" {i}. {step}")
if c.get("auto"):
lines.append(f"- 自动化断言:与 CSV `{c['auto']}` 及 `tests/spd/{p['spec_file']}` 一致")
else:
lines.append("- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖")
lines += ["", "###### 预期结果", "", c["expect"], "", "---", ""]
# trim trailing ---
while lines and lines[-1] in ("", "---"):
lines.pop()
lines.append("")
return "\n".join(lines)
def render_csv_rows(p: dict) -> list[dict]:
rows = []
# 账号密码仅存 .env / auth.setup;CSV 不写明文凭据
account = "测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限"
for c in p["cases"]:
case_id = c.get("auto") or f"{p['code']}-{c['seq']:03d}"
pre_parts = [account]
if c["seq"] > 1:
pre_parts.append(f"已进入{p['name']}页({p['path']})")
if c["pre"]:
pre_parts.append(c["pre"])
steps = "\n".join(
[f"1. 确认 chromium 已加载 storageState(auth.setup),无需在用例内重复登录"]
+ [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)]
)
rows.append(
{
"用例编号": case_id,
"功能模块": "基础设置 · 厂商管理",
"功能测试点": f"{p['name']} · {c['h3']}",
"用例标题": c["title"],
"优先级": prio_csv(c["prio"]),
"前置条件": ";".join(pre_parts),
"测试步骤": steps,
"预期结果": c["expect"],
"实际结果": "",
}
)
return rows
def main() -> None:
MD_DIR.mkdir(parents=True, exist_ok=True)
CSV_DIR.mkdir(parents=True, exist_ok=True)
total = 0
for p in PAGES:
md_path = MD_DIR / f"基础管理-厂商管理-{p['name']}-UI自动化测试用例-01.md"
csv_path = CSV_DIR / f"基础管理-厂商管理-{p['name']}-UI自动化测试用例-01.csv"
md_path.write_text(render_md(p), encoding="utf-8")
rows = render_csv_rows(p)
with csv_path.open("w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(
f,
fieldnames=["用例编号", "功能模块", "功能测试点", "用例标题", "优先级", "前置条件", "测试步骤", "预期结果", "实际结果"],
)
w.writeheader()
w.writerows(rows)
total += len(rows)
print(f"OK {p['code']}\t{len(rows):2d} cases\t{md_path.name}")
print(f"done pages={len(PAGES)} cases={total}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Generate vendor (厂商管理) testpoint MD + patch UI-SPEC URLs from scan summary."""
from __future__ import annotations
import json
import re
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCAN_DIR = ROOT / "data" / "scan"
OUT_DIR = ROOT / "testpoint"
KB = ROOT / "knowledge-base" / "08-UI专项规范库"
TODAY = date.today().isoformat()
PAGES = [
{
"pageKey": "vendor-query",
"specId": "VENDOR-001",
"name": "厂商查询",
"prefix": "TP_VENDOR_QRY",
"file": "基础管理-厂商管理-厂商查询-测试点分析-01.md",
"kind": "query",
},
{
"pageKey": "vendor-qual",
"specId": "VENDOR-002",
"name": "供应商资质管理",
"prefix": "TP_VENDOR_QUL",
"file": "基础管理-厂商管理-供应商资质管理-测试点分析-01.md",
"kind": "qual",
},
{
"pageKey": "vendor-auth-first",
"specId": "VENDOR-003",
"name": "授权变更初审",
"prefix": "TP_VENDOR_AF1",
"file": "基础管理-厂商管理-授权变更初审-测试点分析-01.md",
"kind": "audit",
"peer": "授权变更终审",
"doc_type": "授权变更",
},
{
"pageKey": "vendor-auth-final",
"specId": "VENDOR-004",
"name": "授权变更终审",
"prefix": "TP_VENDOR_AF2",
"file": "基础管理-厂商管理-授权变更终审-测试点分析-01.md",
"kind": "audit",
"peer": "授权变更初审",
"doc_type": "授权变更",
},
{
"pageKey": "vendor-cert-first",
"specId": "VENDOR-005",
"name": "注册证变更初审",
"prefix": "TP_VENDOR_CF1",
"file": "基础管理-厂商管理-注册证变更初审-测试点分析-01.md",
"kind": "audit",
"peer": "注册证变更终审",
"doc_type": "注册证变更",
},
{
"pageKey": "vendor-cert-final",
"specId": "VENDOR-006",
"name": "注册证变更终审",
"prefix": "TP_VENDOR_CF2",
"file": "基础管理-厂商管理-注册证变更终审-测试点分析-01.md",
"kind": "audit",
"peer": "注册证变更初审",
"doc_type": "注册证变更",
},
{
"pageKey": "vendor-cert-diff",
"specId": "VENDOR-007",
"name": "注册证差异对比",
"prefix": "TP_VENDOR_CDF",
"file": "基础管理-厂商管理-注册证差异对比-测试点分析-01.md",
"kind": "diff",
},
{
"pageKey": "vendor-eval-scheme",
"specId": "VENDOR-008",
"name": "评价方案维护",
"prefix": "TP_VENDOR_EVS",
"file": "基础管理-厂商管理-评价方案维护-测试点分析-01.md",
"kind": "scheme",
},
{
"pageKey": "vendor-eval-indicator",
"specId": "VENDOR-009",
"name": "评价指标维护",
"prefix": "TP_VENDOR_EVI",
"file": "基础管理-厂商管理-评价指标维护-测试点分析-01.md",
"kind": "indicator",
},
{
"pageKey": "vendor-holiday",
"specId": "VENDOR-010",
"name": "供应商节假日管理",
"prefix": "TP_VENDOR_HLD",
"file": "基础管理-厂商管理-供应商节假日管理-测试点分析-01.md",
"kind": "holiday",
},
]
def load_summary() -> dict[str, dict]:
path = SCAN_DIR / "vendor-scan-summary.json"
rows = json.loads(path.read_text(encoding="utf-8"))
return {r["page"]: r for r in rows if r.get("ok")}
def tp(prefix: str, n: int, prio: str, title: str, check: str, spec: str) -> str:
return f"- **{prefix}_{n:03d}**({prio})|{title}|验证要点:{check}|关联需求:{spec}"
def section(title: str, blocks: list[tuple[str, list[str]]]) -> str:
parts = [f"### {title}", ""]
for h4, lines in blocks:
parts.append(f"#### {h4}")
parts.append("")
parts.extend(lines or ["- 无"])
parts.append("")
return "\n".join(parts)
def nav_section(p: dict, url: str, spec: str) -> str:
pref = p["prefix"]
name = p["name"]
n = 1
lines_f = [
tp(pref, n, "高", f"菜单路径可达{name}", f"登录后通过「基础设置 → 厂商管理 → {name}」进入,菜单与 Tab 高亮正确", spec),
]
n += 1
lines_f.append(
tp(pref, n, "高", "页面主区加载完成", "整页渲染,筛选/主表/分页或左右分栏关键区可见", spec)
)
n += 1
lines_f.append(
tp(pref, n, "中", "同组厂商管理菜单可切换", f"切到其它厂商管理页再返回{name},URL 与内容对应", spec)
)
lines_b = [
tp(pref, 4, "低", "已登录直接打开 URL", f"携带有效登录态打开 `{url}`,页面正常渲染", spec),
]
lines_e = [
tp(pref, 5, "高", "未登录访问拦截", "无登录态访问跳转登录或不展示业务数据", spec),
tp(pref, 6, "中", "登录态过期", "会话失效后查询/写操作被拦截并提示重新登录", spec),
tp(pref, 7, "中", "无菜单权限", "无权限账号不可见菜单或 URL 被拒绝", spec),
]
lines_i = [
tp(pref, 8, "中", "顶栏院区与厂商范围一致", "列表/筛选中的院区或供应商范围与顶栏院区不矛盾", spec),
]
return section(
"页面访问与导航",
[
("功能验证点", lines_f),
("边界验证点", lines_b),
("异常验证点", lines_e),
("集成验证点", lines_i),
],
), 8
def body_for(p: dict, spec: str, start: int) -> tuple[str, int]:
kind = p["kind"]
pref = p["prefix"]
n = start
def nxt(prio: str, title: str, check: str) -> str:
nonlocal n
n += 1
return tp(pref, n, prio, title, check, spec)
chunks: list[str] = []
if kind == "query":
chunks.append(
section(
"Tab、搜索与列表",
[
(
"功能验证点",
[
nxt("高", "器械注册人 Tab", "切换「器械注册人」后列表为注册人数据,分页「共 N 条」与结果一致"),
nxt("高", "经销商 Tab", "切换「经销商」后列表为经销商数据,总数与器械注册人可不同"),
nxt("高", "按厂商名称搜索", "输入厂商名称关键字后结果名称匹配,无匹配时空态明确"),
nxt("高", "列表核心列", "展示序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号"),
nxt("中", "分页与每页条数", "翻页与每页条数切换正确,切换 Tab 后分页重置合理"),
],
),
(
"边界验证点",
[
nxt("中", "超长厂商名称关键字", "超长输入可查询或被截断并提示,不白屏"),
nxt("低", "仅空格关键字", "按空查询处理或提示,不报错"),
],
),
(
"异常验证点",
[
nxt("中", "查询接口失败", "失败有错误提示,列表不白屏"),
nxt("低", "无匹配空态", "总数为 0,空态文案明确"),
],
),
(
"集成验证点",
[
nxt("中", "Tab 与分页总数一致", "当前 Tab 下可见行逻辑与「共 N 条」一致"),
],
),
],
)
)
elif kind == "qual":
chunks.append(
section(
"证件 Tab、效期与列表",
[
(
"功能验证点",
[
nxt("高", "证件分类 Tab 切换", "证件效期提醒/授权链路效期预警/供应商三证/器械注册人/注册证号/授权书/承诺书/法人委托书/二级供应商三证可切换且列表字段随 Tab 变化"),
nxt("高", "效期快捷筛选", "近三个月到期、近一个月到期、已过期切换后列表与 Tab 计数一致"),
nxt("高", "证件效期提醒筛选", "按进院供应商名称、证件类型查询,结果匹配"),
nxt("高", "授权链路预警筛选", "按授权书名称、授权单位、被授权单位查询,结果匹配"),
nxt("中", "重置筛选", "重置后条件恢复默认,列表按默认规则刷新"),
nxt("高", "行内查看", "点击「查看」打开证件/授权详情,关键字段与列表行一致"),
nxt("中", "导出", "导出/导出明细与当前 Tab+筛选条件一致"),
],
),
(
"边界验证点",
[
nxt("中", "某效期 Tab 计数为 0", "列表空态明确,总数为 0"),
nxt("低", "效期临界日", "即将到期与已过期边界日分类正确(以业务规则为准)"),
],
),
(
"异常验证点",
[
nxt("中", "查询失败", "接口失败有提示,不白屏"),
nxt("中", "导出失败", "导出失败有提示,不误导为成功"),
],
),
(
"集成验证点",
[
nxt("中", "效期提醒文案与有效期至一致", "「N 天后过期/即将过期」与有效期至抽样一致"),
],
),
],
)
)
elif kind == "audit":
doc = p.get("doc_type", "变更")
peer = p.get("peer", "")
is_auth = "授权" in doc
filter_points = [
nxt("高", "制单日期查询", "按制单日期区间查询,结果制单时间落在区间内"),
]
if is_auth:
filter_points.extend(
[
nxt("高", "单号与供应商筛选", "按单号、进院供应商、预转/原供应商查询,结果匹配"),
nxt("高", "状态与变更类型", "状态下拉与变更类型筛选后列表状态/类型匹配"),
]
)
if "初审" in p["name"]:
filter_points.append(nxt("中", "医院ID/医院名称筛选", "有该字段时按医院条件查询结果匹配"))
filter_points.append(nxt("中", "组合查询与重置", "多条件同时生效;重置恢复默认"))
filter_points.extend(
[
nxt(
"高",
"列表核心列",
f"展示进院供应商名称、单号、状态、制单时间、制单人、变更类型及{doc}相关关键列、详情",
),
nxt("高", "打开详情", "行内「详情」展示单据头与明细,单号与列表一致"),
]
)
else:
filter_points.extend(
[
nxt("高", "单号与注册证号筛选", "按单号、注册证号(新)、变更类型、状态查询,结果匹配"),
nxt("中", "变更情况说明筛选", "有该字段时按说明关键字查询或精确匹配"),
nxt("中", "组合查询与重置", "多条件同时生效;重置恢复默认;收起后再展开值保留"),
nxt(
"高",
"列表核心列",
"展示进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新)、详情",
),
nxt("高", "打开详情", "行内「详情」展示变更前后注册证信息,单号与列表一致"),
]
)
chunks.append(
section(
f"{doc}列表与详情",
[
("功能验证点", filter_points),
(
"边界验证点",
[
nxt("中", "制单日期起止同一天", "仅返回该日单据或空态明确"),
nxt("低", "无匹配单据", "总数为 0,空态明确"),
],
),
(
"异常验证点",
[
nxt("中", "日期开始晚于结束", "拒绝查询或自动纠正并提示"),
nxt("中", "查询失败", "失败有提示,不白屏"),
],
),
(
"集成验证点",
[
nxt(
"高",
f"与{peer}单据衔接",
f"同一业务单在初审/终审列表状态流转一致,不串到非{doc}菜单",
),
],
),
],
)
)
elif kind == "diff":
chunks.append(
section(
"差异筛选与对比结果",
[
(
"功能验证点",
[
nxt("高", "默认查询", "进入后查询有数据或空态明确,分页总数正确"),
nxt("高", "物料/规格/厂家筛选", "按物料名称、规格型号、生产厂家查询,结果匹配"),
nxt("高", "望海与 FLI+ 注册证号筛选", "按两侧注册证号查询,结果匹配"),
nxt("中", "是否医疗器械与状态", "状态下拉与是否医疗器械条件生效"),
nxt("高", "一致/不一致 Tab", "全部、一致、不一致切换后对比结论匹配,计数合理"),
nxt("中", "立即更新", "点击立即更新后时间戳刷新或提示成功,列表可刷新"),
nxt("中", "导出", "导出与当前筛选+Tab 条件一致"),
nxt("高", "对比列完整", "展示物料编号/名称、规格、厂家、医保编码及望海/FLI+注册证号与效期字段"),
],
),
(
"边界验证点",
[
nxt("中", "不一致 Tab 无数据", "空态明确,总数为 0"),
nxt("低", "包含长期有效勾选项", "勾选「包含FLI+长期有效数据」后结果集变化符合规则"),
],
),
(
"异常验证点",
[
nxt("中", "查询或更新失败", "失败有提示,不白屏"),
nxt("中", "iframe/内嵌页加载失败", "内嵌对比页失败有提示,外壳不假成功"),
],
),
(
"集成验证点",
[
nxt("高", "一致行两侧关键字段抽样一致", "标记为一致的行望海与 FLI+ 注册证号/效期抽样相同"),
nxt("高", "不一致行至少一侧差异", "标记为不一致的行两侧字段至少一处不同"),
],
),
],
)
)
elif kind == "scheme":
chunks.append(
section(
"方案筛选与维护",
[
(
"功能验证点",
[
nxt("高", "按创建/启用日期筛选", "按日期区间查询结果落在区间内"),
nxt("高", "方案编码/名称筛选", "按编码、名称查询结果匹配"),
nxt("中", "重置", "重置后条件恢复默认"),
nxt("高", "打开新增", "点击「新增」进入方案编辑,含方案编码/名称等必填区"),
nxt("高", "列表核心列", "展示方案编码、名称、创建人/时间、启用人/时间、停用人/时间、状态、备注、操作"),
nxt("中", "空列表态", "无数据时「暂无数据」空态明确"),
],
),
(
"边界验证点",
[
nxt("中", "起止同一天", "仅返回该日创建/启用的方案"),
],
),
(
"异常验证点",
[
nxt("高", "必填为空提交拦截", "方案编码或名称为空时提交失败并提示"),
nxt("中", "查询失败", "失败有提示"),
],
),
(
"集成验证点",
[
nxt("中", "方案与评价指标可关联", "方案引用的指标在评价指标维护中存在或可配置"),
],
),
],
)
)
elif kind == "indicator":
chunks.append(
section(
"指标分类树与指标列表",
[
(
"功能验证点",
[
nxt("高", "左树加载", "指标分类树可见,可搜索分类名称"),
nxt("高", "选中分类刷新右表", "点击分类后右侧列表为该分类指标"),
nxt("高", "指标编码/名称筛选", "右侧按编码、名称查询结果匹配"),
nxt("高", "列表核心列", "展示评价类型、指标编码、指标名称、状态、操作(编辑/详情/日志)"),
nxt("高", "新增分类", "左侧「新增」可创建分类,树中出现新节点"),
nxt("高", "新增指标", "右侧「新增」可创建指标并回填到列表"),
nxt("中", "编辑/详情/日志", "行内编辑打开回填;详情只读;日志可打开"),
],
),
(
"边界验证点",
[
nxt("中", "分类下无指标", "右表空态明确,总数为 0"),
nxt("低", "分类名超长", "超长被拦截或截断并提示"),
],
),
(
"异常验证点",
[
nxt("高", "必填为空拦截", "指标编码/名称为空提交失败并提示"),
nxt("中", "删除或停用受引用指标", "若被方案引用则拒绝或提示(以产品规则为准)"),
],
),
(
"集成验证点",
[
nxt("中", "状态启用后可供方案使用", "状态为启用的指标可在评价方案中被选择"),
],
),
],
)
)
elif kind == "holiday":
chunks.append(
section(
"默认假日与供应商自定义",
[
(
"功能验证点",
[
nxt("高", "左列表加载", "供应商节假日设置列表展示年度、名称,可按名称/年度搜索"),
nxt("高", "选中行展示默认详情", "点击左侧行后右侧默认假日详情名称/年度/起止/创建人时间与选中行一致"),
nxt("高", "左侧新增/编辑", "新增或编辑默认假日设置后列表与详情刷新"),
nxt("高", "供应商自定义新增", "右侧「新增」可添加进院供应商自定义节假日"),
nxt("中", "批量删除", "勾选自定义行后批量删除,列表条数减少"),
nxt("中", "自定义列表列", "展示进院供应商编码/名称、起止、备注、创建人时间、操作"),
],
),
(
"边界验证点",
[
nxt("中", "起止同一天", "默认或自定义假日起止同一天可保存或按规则提示"),
nxt("低", "自定义列表空态", "无自定义数据时「暂无数据」明确"),
],
),
(
"异常验证点",
[
nxt("高", "结束早于开始", "日期非法时拒绝保存并提示"),
nxt("中", "未勾选批量删除", "无勾选时批量删除不可用或提示"),
],
),
(
"集成验证点",
[
nxt("中", "进院供应商主数据一致", "自定义列表中的进院供应商可在厂商/供应商主数据中找到"),
],
),
],
)
)
else:
chunks.append("### 业务功能\n\n#### 功能验证点\n\n- 无\n")
return "\n".join(chunks), n
def render_testpoint(p: dict, summary: dict) -> str:
row = summary[p["pageKey"]]
url = row["url"]
count = row["elementCount"]
spec = f"UI-SPEC-SPD-{p['specId']}"
nav, last = nav_section(p, url, spec)
body, last = body_for(p, spec, last)
return f"""# 基础管理-厂商管理-{p['name']} — 测试点分析
**关联需求**:`knowledge-base/08-UI专项规范库/{spec}.md`
**页面 URL**:`{url}`
**版本 / 定稿日期**:{TODAY}
**元素扫描**:`data/scan/{p['pageKey']}-elements.json`({count} 项)
**定位器**:`locators/spd/{p['pageKey']}.locator.yaml`
---
## 基础设置 · 厂商管理 · {p['name']}
{nav}
---
{body}
---
## 质量自检摘要
- 覆盖:页面访问、筛选/Tab、列表分页、关键写操作或详情、权限与登录异常、同模块衔接。
- 颗粒度:一条测试点只验证一件独立事;优先级已标高/中/低。
- 待评审项:审批通过/驳回写操作是否自动化(建议只读详情冒烟);注册证差异对比 iframe 断言策略。
- 下游:可转结构化 MD/CSV;自动化需先补 Page Object(`pages/spd/{p['pageKey']}.page.ts`)。
"""
def patch_ui_spec(p: dict, summary: dict) -> None:
row = summary[p["pageKey"]]
path = KB / f"UI-SPEC-SPD-{p['specId']}.md"
text = path.read_text(encoding="utf-8")
text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1)
text = re.sub(
r'related_tc: "[^"]*"',
f'related_tc: "testpoint/{p["file"]}"',
text,
count=1,
)
url = row["url"]
count = row["elementCount"]
page_key = p["pageKey"]
table = f"""| 项 | 值 |
|----|-----|
| 主框架 URL | `{url}` |
| iframe 选择器 | {"内嵌 scjs iframe(对比页)" if p["kind"] == "diff" else "无(整页渲染,非 iframe 报表)"} |
| 元素扫描 JSON | `data/scan/{page_key}-elements.json`({count} 项) |
| 定位器 YAML | `locators/spd/{page_key}.locator.yaml` |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |"""
text = re.sub(
r"\| 项 \| 值 \|\n\|----\|-----\|\n(?:\| .+\|\n)+",
table + "\n",
text,
count=1,
)
# 自动化建议首条改为已扫描
text = text.replace(
"1. 完成专项元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object。",
f"1. 元素扫描已完成({count} 项):`data/scan/{page_key}-elements.json`;下一步补充 Page Object 与用例。",
)
path.write_text(text, encoding="utf-8")
def main() -> None:
summary = load_summary()
OUT_DIR.mkdir(parents=True, exist_ok=True)
for p in PAGES:
if p["pageKey"] not in summary:
raise SystemExit(f"missing scan summary for {p['pageKey']}")
md = render_testpoint(p, summary)
out = OUT_DIR / p["file"]
out.write_text(md, encoding="utf-8")
patch_ui_spec(p, summary)
print(f"OK {p['specId']}\t{p['name']}\t{summary[p['pageKey']]['elementCount']} el\t{out.name}")
print("done", len(PAGES))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""生成库房管理 / 流程设置结构化 MD(testcases/)与同源 CSV(testcase/)。
正例登录账号可写入 CSV;密码只放 .env TEST_PASS / auth.setup,禁止入库。
反例筛选用 mock(__NOT_EXIST_ZONE__/PROBE/FLOW)。
"""
from __future__ import annotations
import csv
from collections import Counter
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MD_DIR = ROOT / "testcases"
CSV_DIR = ROOT / "testcase"
TODAY = date.today().isoformat()
POSITIVE_USER = "test_1"
CSV_FIELDS = [
"用例编号",
"功能模块",
"功能测试点",
"用例标题",
"优先级",
"前置条件",
"测试步骤",
"预期结果",
"实际结果",
]
def page(**kwargs) -> dict:
return kwargs
def case(
seq: int,
h3: str,
h4: str,
prio: str,
title: str,
tp: int,
steps: list[str],
expect: str,
pre: str = "",
) -> dict:
return {
"seq": seq,
"h3": h3,
"h4": h4,
"prio": prio,
"title": title,
"tp": tp,
"steps": steps,
"expect": expect,
"pre": pre,
}
PAGES = [
page(
code="WH-ZON",
tp_prefix="TP_WH_ZON",
group="库房管理",
name="库区设置",
path="/spd/basic/storehouse/platform/storehouse/area",
scan="warehouse-zone",
count=106,
spec="UI-SPEC-SPD-WH-001",
spec_file="warehouse-zone.spec.ts",
tp_total=74,
nav_note=(
"菜单「基础设置 → 库房管理 → 库区设置」;建议 "
"`openPath('/spd/basic/storehouse/platform/storehouse/area')` 并断言 URL,勿误入探针绑定。"
),
write_note=(
"新增/编辑只打开后取消或点确定做必填拦截,不提交成功保存;"
"不停用/启用落库;人员管理/货位管理只打开后关闭;同步仅点一次并断言树仍在。"
),
samples=(
"从左侧树读取 `${sampleWhName}`,从右侧首行读取 `${sampleZoneNo}`、`${sampleZoneName}`;"
"mock:`__NOT_EXIST_ZONE__99999`。"
),
skip="真实保存新增/编辑、停用启用持久化、接口失败注入、会话过期、无菜单权限、人员/货位子页字段细节",
cases=[
case(
1, "页面进入", "功能验证点", "P0", "菜单进入库区设置页", 1,
[
"按菜单「基础设置 → 库房管理 → 库区设置」进入,或 `openPath` 打开 `/spd/basic/storehouse/platform/storehouse/area`",
"断言 URL 含 `/spd/basic/storehouse/platform/storehouse/area`",
"断言左侧「库房信息」树、右侧库区列表、「新 增」与分页可见",
],
"进入库区设置页;不误入探针绑定;无报错遮罩。",
),
case(
2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
[
"清除登录态后直接打开 `/spd/basic/storehouse/platform/storehouse/area`",
"断言跳转登录或不展示库房/库区业务数据",
],
"未登录不可见库区数据。",
),
case(
3, "左侧库房树", "功能验证点", "P0", "库房树默认有选中项", 9,
[
"断言左侧「库房信息」至少 1 个节点且有选中项",
"读取选中节点 `${sampleWhName}`",
"断言右侧列表为该库房库区,或空态且「共 0 条」",
],
"树有选中;右侧对应当前库房。",
),
case(
4, "左侧库房树", "功能验证点", "P0", "按库房名称过滤树", 10,
[
"读取当前选中 `${sampleWhName}` 取关键字(可截取前若干字)",
"在左侧「库房名称」输入关键字",
"断言可见节点名称均包含该关键字",
],
"树过滤结果与关键字匹配。",
pre="库房树至少 1 个节点",
),
case(
5, "左侧库房树", "功能验证点", "P1", "清空库房名称恢复全量树", 11,
[
"记录过滤前节点数",
"输入关键字过滤后再清空",
"断言节点数恢复为过滤前",
],
"清空后树恢复全量。",
),
case(
6, "左侧库房树", "功能验证点", "P0", "切换库房刷新右侧列表", 12,
[
"记录当前库房 `${sampleWhName}` 与右侧首行库区编号(若有)",
"点选另一库房节点(仅 1 个则 skip)",
"断言「共 N 条」可变化;若有行则库区编号/名称属于新库房",
],
"切换库房后右侧列表刷新,不残留上一库房库区。",
),
case(
7, "左侧库房树", "异常验证点", "P1", "无匹配库房空树(mock反例)", 15,
[
"库房名称填入 `__NOT_EXIST_ZONE__99999`",
"断言树为空或明确无数据,右侧不展示上一库房库区为成功态",
],
"无匹配不报错;不残留上一库房列表当成功。",
),
case(
8, "右侧库区列表与分页", "功能验证点", "P0", "列表核心列完整", 18,
[
"断言表头含库区编号、库区名称、库区地址、是否专项库区、操作",
"若有数据行,断言操作列可见「编辑」「人员管理」「货位管理」及「停用」或「启用」",
],
"核心列完整;行内操入口可见。",
),
case(
9, "右侧库区列表与分页", "功能验证点", "P0", "默认库区以标签展示", 19,
[
"在当前库房列表中查找「默认库区」标签",
"断言该标签出现在库区名称旁,不是独立列",
"断言当前库房至多 1 个默认库区标签(无默认则本条只断言不是独立列)",
],
"默认库区为名称旁标签;同一库房至多一个。",
),
case(
10, "库区搜索", "功能验证点", "P0", "按库区编号搜索", 29,
[
"从首行读取 `${sampleZoneNo}`",
"在「库区编号/名称」填入该编号并触发搜索",
"断言结果包含该编号行,且均属于当前选中库房",
],
"搜索命中该编号;不串其它库房。",
pre="当前库房至少 1 条库区且编号非空",
),
case(
11, "库区搜索", "功能验证点", "P0", "按库区名称搜索", 30,
[
"从首行读取 `${sampleZoneName}` 取关键字",
"在「库区编号/名称」填入关键字并搜索",
"断言结果行名称匹配",
],
"名称关键字命中;无未捕获异常。",
pre="当前库房至少 1 条且名称非空",
),
case(
12, "库区搜索", "异常验证点", "P1", "无匹配库区空态(mock反例)", 34,
[
"「库区编号/名称」填入 `__NOT_EXIST_ZONE__99999` 并搜索",
"断言列表空且分页为 0",
],
"无匹配不报错;总数为 0。",
),
case(
13, "新增库区", "功能验证点", "P0", "打开新增库区弹窗", 36,
[
"点击「新 增」",
"断言弹出「新增库区」,可见库区编号、库区名称、库区地址、联系人、联系方式及「取 消」「确 定」",
],
"新增弹窗可见;关键字段与按钮齐全。",
),
case(
14, "新增库区", "功能验证点", "P1", "新增取消不落库", 38,
[
"打开新增,编号填 `AUTO${timestamp}`,名称填任意合法值",
"点击「取 消」",
"按该编号搜索,断言无该记录",
],
"弹窗关闭;列表不出现该编号。",
),
case(
15, "新增库区", "异常验证点", "P0", "必填为空拦截", 41,
[
"打开新增,库区编号或名称留空后点击「确 定」",
"断言出现必填提示,弹窗仍打开",
"点「取 消」",
],
"确定被拦截;不落库;弹窗不关闭。",
),
case(
16, "编辑库区", "功能验证点", "P0", "打开编辑并回填", 46,
[
"点击首行「编辑」",
"断言弹窗标题含「编辑库区信息」",
"断言库区编号、名称与该行 `${sampleZoneNo}`、`${sampleZoneName}` 一致(允许规范化空白)",
"点「取 消」",
],
"编辑弹窗可见;回填与列表行一致。",
pre="当前库房至少 1 条库区",
),
case(
17, "编辑库区", "功能验证点", "P1", "编辑取消不保存", 48,
[
"打开首行编辑,记录原名称",
"将名称改为临时值后点「取 消」",
"再次打开编辑,断言名称仍为原值",
],
"取消后列表与回看均为修改前名称。",
pre="当前库房至少 1 条库区",
),
case(
18, "编辑库区", "异常验证点", "P0", "编辑清空必填拦截", 52,
[
"打开首行编辑,清空库区名称后点「确 定」",
"断言被拦截并提示,弹窗仍开",
"点「取 消」,断言列表名称未被写成空",
],
"清空必填不能保存;原记录名称保持。",
pre="当前库房至少 1 条库区",
),
case(
19, "人员管理", "功能验证点", "P0", "打开人员管理后关闭", 61,
[
"点击首行「人员管理」",
"断言打开弹层或子页,标题含人员管理或对应当前库区",
"关闭后断言仍在库区设置,当前库房选中保持",
],
"人员管理可打开;关闭后回到当前库房列表。本条不改人员。",
pre="当前库房至少 1 条库区",
),
case(
20, "货位管理", "功能验证点", "P0", "打开货位管理后关闭", 66,
[
"点击首行「货位管理」",
"断言打开弹层或子页,标题含货位且对应当前库区",
"关闭后断言库房选中与库区列表保持",
],
"货位管理可打开;关闭后列表状态保持。本条不改货位。",
pre="当前库房至少 1 条库区",
),
],
),
page(
code="WH-PRB",
tp_prefix="TP_WH_PRB",
group="库房管理",
name="探针绑定",
path="/spd/basic/storehouse/storehouse/probe",
scan="warehouse-probe",
count=78,
spec="UI-SPEC-SPD-WH-002",
spec_file="warehouse-probe.spec.ts",
tp_total=49,
nav_note=(
"菜单「基础设置 → 库房管理 → 探针绑定」;建议 "
"`openPath('/spd/basic/storehouse/storehouse/probe')` 并断言 URL。"
),
write_note="绑定货位只打开弹窗后取消,不点确定保存;不改绑定关系。",
samples=(
"查询后从首行读取 `${sampleProbeId}`、`${sampleHostCode}`、`${sampleBindFlag}`、`${sampleBindCount}`;"
"mock:`__NOT_EXIST_PROBE__99999`。"
),
skip="真实勾选确定绑定、换绑/占用规则落库、接口失败、会话过期、无菜单权限",
cases=[
case(
1, "页面进入", "功能验证点", "P0", "菜单进入探针绑定页", 1,
[
"按菜单「基础设置 → 库房管理 → 探针绑定」进入,或 `openPath` 打开 `/spd/basic/storehouse/storehouse/probe`",
"断言 URL 含 `/spd/basic/storehouse/storehouse/probe`",
"断言探针ID、主机编码、是否绑定货位、查询、重置、列表与分页可见",
],
"进入探针绑定页;不误入库区设置。",
),
case(
2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
[
"清除登录态后直接打开 `/spd/basic/storehouse/storehouse/probe`",
"断言跳转登录或不展示探针业务列表",
],
"未登录不可见探针数据。",
),
case(
3, "筛选与查询", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
[
"点击「查 询」并等待列表稳定",
"断言出现数据行,或「暂无数据」且分页总数为 0",
],
"查询无报错;有数据时「共 N 条」N≥1。",
),
case(
4, "筛选与查询", "功能验证点", "P0", "按探针ID查询", 10,
[
"从首行读取 `${sampleProbeId}`",
"探针ID填入该值,点击「查 询」",
"断言结果探针ID匹配,精确命中时行数合理",
],
"探针ID条件生效。",
pre="默认查询至少 1 条且探针ID非空",
),
case(
5, "筛选与查询", "功能验证点", "P0", "按主机编码查询", 11,
[
"从首行读取 `${sampleHostCode}`",
"主机编码填入该值,点击「查 询」",
"断言结果行「主机编码」与条件一致",
],
"主机编码条件生效;允许多探针共用同一主机。",
pre="默认查询至少 1 条且主机编码非空",
),
case(
6, "筛选与查询", "功能验证点", "P0", "按是否绑定货位查询", 12,
[
"是否绑定货位选择「已绑定」,点击「查 询」",
"断言结果「是否绑定货位」均为已绑定,且「绑定的货位」>0",
"再选「未绑定」查询,断言均为未绑定且数量为 0(无数据则空态)",
],
"已绑定数量大于 0;未绑定数量为 0。",
),
case(
7, "筛选与查询", "功能验证点", "P0", "探针ID+主机编码组合查询", 13,
[
"填写 `${sampleProbeId}` 与 `${sampleHostCode}` 后点击「查 询」",
"断言结果同时满足已填条件",
],
"组合条件同时生效。",
pre="默认查询至少 1 条",
),
case(
8, "筛选与查询", "功能验证点", "P1", "重置恢复默认", 14,
[
"填写探针ID后点击「重 置」",
"断言条件恢复默认,列表按默认规则刷新",
],
"条件恢复默认;列表刷新。",
),
case(
9, "筛选与查询", "异常验证点", "P1", "无匹配空态(mock反例)", 18,
[
"探针ID填入 `__NOT_EXIST_PROBE__99999`,点击「查 询」",
"断言空态且分页总数为 0",
],
"无匹配不报错;总数为 0。",
),
case(
10, "列表与分页", "功能验证点", "P0", "列表核心列与数量规则", 20,
[
"断言表头含序号、探针ID、主机编码、是否绑定货位、绑定的货位、操作",
"抽样已绑定行:绑定数量为数字且 >0;抽样未绑定行:数量为 0",
"断言「绑定的货位」列不是货位号格式",
],
"核心列完整;数量列是数字不是货位号。",
),
case(
11, "打开绑定货位弹窗", "功能验证点", "P0", "行内打开货位绑定探针", 30,
[
"点击首行「绑定货位」",
"断言弹出「货位绑定探针」,可见当前库房/库区/货位查询及货位列表",
],
"弹窗标题正确;筛选与列表可见。",
pre="列表至少 1 条",
),
case(
12, "打开绑定货位弹窗", "功能验证点", "P1", "取消关闭不改主列表", 31,
[
"记录主列表首行绑定数量与当前筛选",
"打开绑定弹窗后点「取 消」",
"断言主列表绑定数量与筛选条件保持",
],
"取消不改绑定关系。",
pre="列表至少 1 条",
),
case(
13, "打开绑定货位弹窗", "边界验证点", "P1", "未选货位时确定计数为 0", 32,
[
"打开绑定弹窗,不勾选货位",
"断言底部按钮文案为「确定(0)」或等价",
"点「取 消」,不点确定保存",
],
"未选时计数为 0;本条不保存绑定。",
pre="列表至少 1 条",
),
case(
14, "打开绑定货位弹窗", "集成验证点", "P1", "弹窗分页独立于主列表", 34,
[
"记录主列表「共 N 条」",
"打开绑定弹窗,记录弹窗分页「共 M 条」",
"断言不得用 M 断言主列表 N(货位量级可远大于探针条数)",
"点「取 消」",
],
"弹窗总条数与主列表探针条数相互独立。",
pre="列表至少 1 条",
),
case(
15, "弹窗筛选货位", "功能验证点", "P0", "按当前库房筛选货位", 35,
[
"打开绑定弹窗,记录默认 `${sampleModalWh}`",
"点「查 询」",
"断言可见行「当前库房」与所选库房一致(有数据时)",
"点「取 消」",
],
"弹窗库房条件生效。",
pre="列表至少 1 条",
),
case(
16, "弹窗筛选货位", "功能验证点", "P1", "弹窗内重置", 38,
[
"打开绑定弹窗,填写货位查询关键字后点「重 置」",
"断言货位查询条件恢复,列表按弹窗默认规则刷新",
"点「取 消」",
],
"弹窗重置不影响主列表筛选。",
pre="列表至少 1 条",
),
case(
17, "勾选货位并保存绑定", "功能验证点", "P1", "勾选后确定计数增加再取消勾选", 43,
[
"打开绑定弹窗,勾选 1 个货位",
"断言确定按钮计数为 1",
"取消该勾选,断言计数回到 0",
"点「取 消」,不保存",
],
"计数随勾选变化;取消勾选不落库。",
pre="弹窗货位列表至少 1 条",
),
],
),
page(
code="FLOW-DEF",
tp_prefix="TP_FLOW_DEF",
group="流程设置",
name="审批流程设置",
path="/spd/basic/flowSet/workflow/flowSet",
scan="flow-def",
count=43,
spec="UI-SPEC-SPD-FLOW-001",
spec_file="flow-def.spec.ts",
tp_total=41,
nav_note=(
"菜单「基础设置 → 流程设置 → 审批流程设置」;建议 "
"`openPath('/spd/basic/flowSet/workflow/flowSet')`,与「审批流实例」用 exact 区分。"
),
write_note=(
"禁止点「保 存」提交线上审批路由。新增流程/改节点后必须「重 置」或取消条件配置;"
"必填拦截若需点保存,仅空行拦截成功后立刻重置。"
),
samples=(
"从左侧读取 `${sampleBizType}`(如采购计划);自定义行读取 `${sampleFlowName}`、`${sampleCondText}`;"
"mock:`__NOT_EXIST_FLOW__99999`。"
),
skip="真实保存审批链、自定义流持久化、条件保存落库、历史实例快照、接口失败、会话过期",
cases=[
case(
1, "页面进入", "功能验证点", "P0", "菜单进入审批流程设置页", 1,
[
"按菜单「基础设置 → 流程设置 → 审批流程设置」进入,或 `openPath` 打开 `/spd/basic/flowSet/workflow/flowSet`",
"断言 URL 含 `/spd/basic/flowSet/workflow/flowSet`",
"断言左侧业务类型、右侧默认审批流与自定义审批流、「保 存」「重 置」可见",
],
"进入审批流程设置;不误入审批流实例。",
),
case(
2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
[
"清除登录态后直接打开 `/spd/basic/flowSet/workflow/flowSet`",
"断言跳转登录或不展示审批流配置",
],
"未登录不可见审批流配置。",
),
case(
3, "左侧业务类型", "功能验证点", "P0", "业务类型列表可见", 9,
[
"断言左侧可见采购计划等类型且有当前高亮项",
"读取 `${sampleBizType}`",
],
"类型列表可见;当前项高亮。",
),
case(
4, "左侧业务类型", "功能验证点", "P0", "按流程名称过滤类型", 10,
[
"在「流程名称」输入 `${sampleBizType}` 关键字",
"断言可见类型名称均匹配关键字",
],
"类型过滤与关键字匹配。",
),
case(
5, "左侧业务类型", "功能验证点", "P1", "清空关键字恢复全量", 11,
[
"记录过滤前类型数量",
"输入关键字后再清空",
"断言数量恢复",
],
"清空后类型列表恢复全量。",
),
case(
6, "左侧业务类型", "功能验证点", "P0", "切换类型刷新右侧配置", 12,
[
"记录当前类型默认流节点文案",
"点选另一业务类型(仅 1 个则 skip)",
"断言右侧默认流/自定义流已刷新,不残留上一类型节点人员",
],
"切换类型后右侧与该类型已保存配置一致。",
),
case(
7, "左侧业务类型", "异常验证点", "P1", "无匹配类型(mock反例)", 15,
[
"流程名称填入 `__NOT_EXIST_FLOW__99999`",
"断言左侧无匹配或空,右侧不把上一类型配置展示为当前成功配置",
],
"无匹配不报错。",
),
case(
8, "默认审批流", "功能验证点", "P0", "默认审批流节点可见", 17,
[
"选中「采购计划」(或当前默认类型)",
"断言「默认审批流」区域可见节点卡片,含节点/账号/人员三行信息(按卡片容器取文本)",
],
"默认流节点结构可见;不点保存。",
),
case(
9, "自定义审批流", "功能验证点", "P0", "新增流程增加一行后重置", 22,
[
"记录当前自定义流行数",
"点击「新增流程」",
"断言行数 +1,新行含流程名称、条件、审批流程、删除",
"点击「重 置」,断言行数回到操作前,不点「保 存」",
],
"可新增行;重置放弃未保存行。",
),
case(
10, "自定义审批流", "异常验证点", "P0", "空行保存被拦截后重置", 28,
[
"点击「新增流程」得到空行",
"点击「保 存」",
"断言出现必填拦截提示,配置未按成功态关闭",
"点击「重 置」,放弃该空行",
],
"空名称/条件/节点不能落库;拦截后重置恢复。",
),
case(
11, "条件配置", "功能验证点", "P0", "打开条件配置后取消", 31,
[
"若无自定义行则 skip",
"点击首条自定义流条件列「编辑」",
"断言打开「条件配置」",
"点「取 消」,断言行内条件文案与打开前一致",
],
"条件配置可打开;取消不改行内条件。",
pre="当前类型至少 1 条自定义流",
),
case(
12, "保存与重置", "功能验证点", "P0", "重置放弃未保存改动", 38,
[
"点击「新增流程」增加一行",
"点击「重 置」",
"断言自定义流行回到上次保存结果",
],
"重置不提交;未保存行消失。",
),
case(
13, "左侧业务类型", "集成验证点", "P1", "类型与审批流实例业务类型同源", 16,
[
"记录本页左侧类型名称集合(可见范围)",
"打开审批流实例,展开「业务类型」下拉",
"断言选项与本页类型同源(允许按权限裁剪,本页有的关键类型如采购计划应能对应)",
],
"两页业务类型同源。",
),
],
),
page(
code="FLOW-INS",
tp_prefix="TP_FLOW_INS",
group="流程设置",
name="审批流实例",
path="/spd/basic/flowSet/workflow/flowCase",
scan="flow-inst",
count=62,
spec="UI-SPEC-SPD-FLOW-002",
spec_file="flow-inst.spec.ts",
tp_total=41,
nav_note=(
"菜单「基础设置 → 流程设置 → 审批流实例」;建议 "
"`openPath('/spd/basic/flowSet/workflow/flowCase')`,不要点到审批流程设置。"
),
write_note="本页只读。详情只打开后返回,不改审批人。断言「共 N 条」前先收窄日期。",
samples=(
"查询后从首行读取 `${sampleOrderNo}`、`${sampleBizType}`、`${sampleStatus}`、`${sampleSummary}`;"
"mock:`__NOT_EXIST_FLOW__99999`。"
),
skip="改审批人、接口失败、会话过期、无菜单权限、详情与流程设置节点逐条对照(可另开集成专项)",
cases=[
case(
1, "页面进入", "功能验证点", "P0", "菜单进入审批流实例页", 1,
[
"按菜单「基础设置 → 流程设置 → 审批流实例」进入,或 `openPath` 打开 `/spd/basic/flowSet/workflow/flowCase`",
"断言 URL 含 `/spd/basic/flowSet/workflow/flowCase`",
"断言单据编号、审批状态、发起时间、业务类型、摘要、查询、重置、列表与分页可见",
],
"进入审批流实例;不误入审批流程设置。",
),
case(
2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
[
"清除登录态后直接打开 `/spd/basic/flowSet/workflow/flowCase`",
"断言跳转登录或不展示审批单据列表",
],
"未登录不可见审批实例。",
),
case(
3, "筛选与查询", "功能验证点", "P0", "默认日期区间查询", 9,
[
"点击「查 询」并等待列表稳定",
"断言出现数据行或空态明确,「共 N 条」与结果逻辑一致",
],
"默认查询无报错;有数据时 N≥1。",
),
case(
4, "筛选与查询", "功能验证点", "P0", "单据编号精确查询", 10,
[
"从首行读取 `${sampleOrderNo}`",
"单据编号填入完整单号,点击「查 询」",
"断言至多 1 条,且编号、业务类型与该行一致",
],
"精确单号命中当前行。",
pre="默认查询至少 1 条且单据编号非空",
),
case(
5, "筛选与查询", "功能验证点", "P0", "按审批状态筛选", 11,
[
"审批状态下拉选择「审批通过」(无该选项则选列表中已出现的状态)",
"点击「查 询」",
"断言结果「审批状态」列与所选一致",
],
"状态筛选生效。",
),
case(
6, "筛选与查询", "功能验证点", "P0", "按业务类型筛选", 12,
[
"从首行读取 `${sampleBizType}`",
"业务类型选择该值,点击「查 询」",
"断言结果「业务类型」均为该类型",
],
"业务类型筛选生效。",
pre="默认查询至少 1 条",
),
case(
7, "筛选与查询", "功能验证点", "P0", "按发起时间区间筛选", 13,
[
"记录当前默认起止日期",
"点击「查 询」",
"抽样结果行「发起时间」落在起止日内(含边界)",
],
"发起时间落在区间内。",
),
case(
8, "筛选与查询", "功能验证点", "P1", "按摘要关键字筛选", 14,
[
"找到摘要非 `--` 的行读取 `${sampleSummary}` 关键字;若全为 `--` 则填 mock 关键字",
"点击「查 询」",
"有关键字命中时摘要包含该关键字;mock 无命中时空态且 `--` 行不被命中",
],
"非空摘要可检索;`--` 不被非空关键字命中。",
),
case(
9, "筛选与查询", "功能验证点", "P0", "状态+类型组合查询", 15,
[
"填写 `${sampleStatus}` 与 `${sampleBizType}` 后点击「查 询」",
"断言抽样行同时满足全部已填条件",
],
"组合条件同时生效。",
pre="默认查询至少 1 条",
),
case(
10, "筛选与查询", "功能验证点", "P1", "重置恢复默认", 16,
[
"填写单据编号等条件后点击「重 置」",
"断言日期回到默认区间,其余条件清空,列表刷新",
],
"重置后日期与其它条件恢复默认。",
),
case(
11, "筛选与查询", "异常验证点", "P1", "无匹配空态(mock反例)", 21,
[
"单据编号填入 `__NOT_EXIST_FLOW__99999`,点击「查 询」",
"断言空态且分页总数为 0",
],
"无匹配不报错;总数为 0。",
),
case(
12, "列表与分页", "功能验证点", "P0", "列表核心列完整", 24,
[
"断言表头含序号、单据编号、业务类型、发起人、发起部门、发起时间、摘要、审批状态、操作",
"断言行内可见「详情」",
],
"核心列完整;详情入口可见。",
),
case(
13, "列表与分页", "功能验证点", "P1", "单据编号前缀与业务类型对应", 25,
[
"抽样 `PLAN`/`DB`/`GC`/`BUY` 前缀行(有则断言)",
"断言 PLAN→采购计划、DB→二级库调拨、GC→产品信息变更、BUY→请领单(以现网枚举为准,缺前缀 skip 该项)",
],
"前缀与类型对应。",
pre="默认查询有数据",
),
case(
14, "列表与分页", "功能验证点", "P1", "分页翻至第 2 页", 28,
[
"记录第 1 页首行单据编号",
"翻到第 2 页",
"断言数据更换;筛选条件保持",
],
"翻页成功;筛选不丢失。",
pre="总条数大于一页,否则 skip",
),
case(
15, "审批详情", "功能验证点", "P0", "打开详情对应当前行", 36,
[
"记录首行 `${sampleOrderNo}`",
"点击「详情」",
"断言打开该单审批过程,标题或单号含 `${sampleOrderNo}`",
],
"详情单号与当前行一致。",
pre="列表至少 1 条",
),
case(
16, "审批详情", "功能验证点", "P1", "关闭详情保留列表状态", 38,
[
"填写单据编号查询后打开详情",
"点返回/关闭",
"断言筛选条件与列表仍是打开前的查询结果",
],
"返回后条件与页码保持。",
pre="列表至少 1 条",
),
case(
17, "审批详情", "功能验证点", "P1", "详情只读", 37,
[
"打开首行详情",
"断言无保存审批人/改条件的提交按钮,或此类控件不可用",
"返回列表",
],
"详情只读;改配置须到审批流程设置。",
pre="列表至少 1 条",
),
],
),
]
def tp_id(p: dict, c: dict) -> str:
return f"{p['tp_prefix']}_{c['tp']:03d}"
def prio_csv(prio: str) -> str:
return {"P0": "高", "P1": "中", "P2": "低"}[prio]
def is_unlogin(c: dict) -> bool:
return "未登录" in c["title"]
def render_csv_rows(p: dict) -> list[dict]:
logged = (
f"测试环境可访问;正例账号 {POSITIVE_USER} 已通过 auth.setup 写入 storageState 登录"
f"(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → {p['group']} → {p['name']}」权限"
)
unlogin = (
f"测试环境可访问;使用独立 context,storageState 为空(不加载 {POSITIVE_USER} 登录态)"
)
rows: list[dict] = []
for c in p["cases"]:
case_id = f"{p['code']}-{c['seq']:03d}"
if is_unlogin(c):
pre_parts = [unlogin]
if c["pre"]:
pre_parts.append(c["pre"])
steps = "\n".join(
["1. 新建独立 BrowserContext,不加载 auth/user.json"]
+ [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)]
)
else:
pre_parts = [logged]
if c["seq"] > 1:
pre_parts.append(f"已进入{p['name']}页({p['path']},无 iframe)")
if c["pre"]:
pre_parts.append(c["pre"])
steps = "\n".join(
[
f"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 {POSITIVE_USER}),无需在用例内重复登录"
]
+ [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)]
)
rows.append(
{
"用例编号": case_id,
"功能模块": f"基础设置 · {p['group']}",
"功能测试点": f"{p['name']} · {c['h3']}",
"用例标题": c["title"],
"优先级": prio_csv(c["prio"]),
"前置条件": ";".join(pre_parts),
"测试步骤": steps,
"预期结果": c["expect"],
"实际结果": "",
}
)
return rows
def render_md(p: dict) -> str:
n = len(p["cases"])
cnt = Counter(c["prio"] for c in p["cases"])
prio_txt = " / ".join(f"{k}={cnt[k]}" for k in ("P0", "P1", "P2") if cnt[k])
lines = [
f"# 基础管理-{p['group']}-{p['name']} UI 自动化测试用例",
"",
f"**来源测试点**:`testpoint/基础管理-{p['group']}-{p['name']}-测试点分析-01.md` ",
f"**执行 CSV**:`testcase/基础管理-{p['group']}-{p['name']}-UI自动化测试用例-01.csv` ",
f"**页面 URL**:`{p['path']}` ",
f"**定位器 YAML**:`locators/spd/{p['scan']}.locator.yaml` ",
f"**元素扫描**:`data/scan/{p['scan']}-elements.json`({p['count']} 项) ",
f"**关联规范**:`knowledge-base/08-UI专项规范库/{p['spec']}.md` ",
f"**版本**:{TODAY} ",
f"**自动化框架**:Playwright + TypeScript(规划 `tests/spd/{p['spec_file']}`,依赖 `auth.setup` 登录态)",
"",
f"**通用前置**:正例账号 `{POSITIVE_USER}` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → {p['group']} → {p['name']}」权限。反例筛选用 mock。 ",
"**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 ",
f"**菜单导航**:{p['nav_note']} ",
f"**写操作**:{p['write_note']} ",
f"**动态取数**:{p['samples']}",
"",
"---",
"",
f"## 基础设置 · {p['group']}",
"",
]
current_h3 = ""
current_h4 = ""
for c in p["cases"]:
h3 = f"{p['name']} · {c['h3']}"
if h3 != current_h3:
current_h3 = h3
current_h4 = ""
lines += [f"### {h3}", ""]
if c["h4"] != current_h4:
current_h4 = c["h4"]
lines += [f"#### {current_h4}", ""]
case_id = f"{p['code']}-{c['seq']:03d}"
tc = f"TC-UI-{case_id}"
lines += [
f"##### 【{c['prio']}】【{tc}】【{tp_id(p, c)}】{c['title']}",
"",
f"- 前置条件:{c['pre'] or ('已进入' + p['name'] + '页')}",
"- 操作步骤:",
]
for i, step in enumerate(c["steps"], 1):
lines.append(f" {i}. {step}")
lines.append(f"- 自动化断言:规划与 CSV `{case_id}` 及 `tests/spd/{p['spec_file']}` 一致")
lines += ["", "###### 预期结果", "", c["expect"], "", "---", ""]
while lines and lines[-1] in ("", "---"):
lines.pop()
lines += [
"",
"",
"## 质量与覆盖说明",
"",
"| 项 | 说明 |",
"|----|------|",
f"| 用例数 | **{n}** 条({prio_txt}) |",
f"| 来源 | 从 {p['tp_total']} 条测试点选取可稳定自动化的主路径与关键反向 |",
f"| 未纳入自动化 | {p['skip']} |",
"| 单一职责 | 每条只断言一个核心业务结果 |",
"| 独立性 | 每条独立 `open()`,不复用 BrowserContext;不依赖上一条写结果 |",
"",
]
return "\n".join(lines)
def main() -> None:
MD_DIR.mkdir(parents=True, exist_ok=True)
CSV_DIR.mkdir(parents=True, exist_ok=True)
total = 0
for p in PAGES:
seqs = [c["seq"] for c in p["cases"]]
if seqs != list(range(1, len(seqs) + 1)):
raise SystemExit(f"{p['code']} seq not consecutive: {seqs}")
md_path = MD_DIR / f"基础管理-{p['group']}-{p['name']}-UI自动化测试用例-01.md"
csv_path = CSV_DIR / f"基础管理-{p['group']}-{p['name']}-UI自动化测试用例-01.csv"
md_path.write_text(render_md(p), encoding="utf-8")
rows = render_csv_rows(p)
with csv_path.open("w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=CSV_FIELDS)
w.writeheader()
w.writerows(rows)
total += len(rows)
print(f"OK {p['code']}\t{len(rows):2d} cases\t{md_path.name}\t{csv_path.name}")
print(f"done pages={len(PAGES)} cases={total}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Generate 库房管理 / 流程设置 testpoint MD and patch UI-SPEC / INDEX."""
from __future__ import annotations
import json
import re
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCAN_DIR = ROOT / "data" / "scan"
OUT_DIR = ROOT / "testpoint"
KB = ROOT / "knowledge-base" / "08-UI专项规范库"
INDEX = KB / "INDEX.md"
TODAY = date.today().isoformat()
PAGES = [
{
"pageKey": "warehouse-zone",
"specId": "WH-001",
"group": "库房管理",
"name": "库区设置",
"prefix": "TP_WH_ZON",
"file": "基础管理-库房管理-库区设置-测试点分析-01.md",
"kind": "zone",
"path": "/spd/basic/storehouse/platform/storehouse/area",
"po": "pages/spd/warehouse-zone.page.ts",
"scan_cmd": "discover:scan-warehouse",
},
{
"pageKey": "warehouse-probe",
"specId": "WH-002",
"group": "库房管理",
"name": "探针绑定",
"prefix": "TP_WH_PRB",
"file": "基础管理-库房管理-探针绑定-测试点分析-01.md",
"kind": "probe",
"path": "/spd/basic/storehouse/storehouse/probe",
"po": "pages/spd/warehouse-probe.page.ts",
"scan_cmd": "discover:scan-warehouse",
},
{
"pageKey": "flow-def",
"specId": "FLOW-001",
"group": "流程设置",
"name": "审批流程设置",
"prefix": "TP_FLOW_DEF",
"file": "基础管理-流程设置-审批流程设置-测试点分析-01.md",
"kind": "def",
"path": "/spd/basic/flowSet/workflow/flowSet",
"po": "pages/spd/flow-def.page.ts",
"scan_cmd": "discover:scan-flow",
},
{
"pageKey": "flow-inst",
"specId": "FLOW-002",
"group": "流程设置",
"name": "审批流实例",
"prefix": "TP_FLOW_INS",
"file": "基础管理-流程设置-审批流实例-测试点分析-01.md",
"kind": "inst",
"path": "/spd/basic/flowSet/workflow/flowCase",
"po": "pages/spd/flow-inst.page.ts",
"scan_cmd": "discover:scan-flow",
},
]
def load_scan(page_key: str) -> dict:
path = SCAN_DIR / f"{page_key}-elements.json"
data = json.loads(path.read_text(encoding="utf-8"))
return {"url": data["url"].split("?")[0], "elementCount": data["elementCount"]}
def tp(prefix: str, n: int, prio: str, title: str, check: str, spec: str) -> str:
return f"- **{prefix}_{n:03d}**({prio})|{title}|验证要点:{check}|关联需求:{spec}"
def section(title: str, blocks: list[tuple[str, list[str]]]) -> str:
parts = [f"### {title}", ""]
for h4, lines in blocks:
parts.append(f"#### {h4}")
parts.append("")
parts.extend(lines or ["- 无(本功能点无独立该类场景,规则已归入其它功能点)"])
parts.append("")
return "\n".join(parts)
class Seq:
def __init__(self, prefix: str, spec: str):
self.prefix = prefix
self.spec = spec
self.n = 0
def add(self, prio: str, title: str, check: str) -> str:
self.n += 1
return tp(self.prefix, self.n, prio, title, check, self.spec)
def nav_section(p: dict, seq: Seq) -> str:
name = p["name"]
group = p["group"]
path = p["path"]
if p["kind"] in ("zone", "probe"):
peers = "库区设置 / 探针绑定"
ready = (
"左右分栏:左侧「库房信息」树、右侧库区列表、新增与分页可见,无报错遮罩"
if p["kind"] == "zone"
else "整页单表:探针ID/主机编码/是否绑定货位筛选、查询重置、列表与分页可见,无报错遮罩"
)
ctx = "顶栏当前库房/院区与本页可维护库房范围不矛盾"
else:
peers = "审批流程设置 / 审批流实例"
ready = (
"左右分栏:左侧业务类型、右侧默认审批流与自定义审批流、保存/重置可见,无报错遮罩"
if p["kind"] == "def"
else "整页单表:单据编号/审批状态/发起时间/业务类型/摘要筛选、查询重置、列表与分页可见,无报错遮罩"
)
ctx = "顶栏登录身份与本页可查看的审批单据范围不矛盾"
return section(
"页面访问与导航",
[
(
"功能验证点",
[
seq.add(
"高",
f"菜单路径可达{name}",
f"登录后通过「基础设置 → {group} → {name}」进入 `{path}`,左侧菜单与顶栏 Tab 高亮为{name},不串到同组其它页",
),
seq.add("高", "页面主区加载完成", ready),
seq.add(
"中",
f"同组{group}菜单可切换",
f"在 {peers} 间切换后 URL 与页面内容对应,再返回{name}状态正确",
),
],
),
(
"边界验证点",
[
seq.add("低", "已登录直接打开 URL", f"携带有效登录态打开 `{path}`,页面正常渲染"),
],
),
(
"异常验证点",
[
seq.add("高", "未登录访问拦截", "无登录态访问 URL 跳转登录或不展示业务数据"),
seq.add("中", "登录态过期", "会话失效后查询/写操作被拦截并提示重新登录,不误写"),
seq.add("中", "无菜单权限", f"无{name}权限的账号不可见菜单或 URL 被拒绝"),
],
),
(
"集成验证点",
[
seq.add("中", "顶栏身份与可管理范围一致", ctx),
],
),
],
)
def zone_body(seq: Seq) -> str:
chunks = [
section(
"左侧库房树",
[
(
"功能验证点",
[
seq.add("高", "库房树默认有选中项", "进入后左侧「库房信息」有节点且一项处于选中,右侧为该库房的库区"),
seq.add("高", "按库房名称过滤树", "在左侧「库房名称」输入关键字后,仅保留名称匹配的库房节点"),
seq.add("中", "清空库房名称恢复全量树", "清空关键字后树恢复为过滤前的全量节点"),
seq.add("高", "切换库房刷新右侧列表", "点选另一库房后,右侧库区编号/名称/地址属于该库房,「共 N 条」同步变化"),
],
),
(
"边界验证点",
[
seq.add("中", "超长库房名称关键字", "输入达到或超过允许长度,过滤被接受或截断,页面不崩溃"),
seq.add("低", "仅空格库房名称", "按空过滤处理或提示,不报错"),
],
),
(
"异常验证点",
[
seq.add("低", "无匹配库房空树", "关键字无匹配时树为空或明确无数据,右侧列表不残留上一库房库区"),
seq.add("中", "库房树加载失败", "库房接口失败有错误提示,不把过期树展示为成功态"),
],
),
(
"集成验证点",
[
seq.add("中", "库房节点与院区主数据同源", "树中库房名称与院区/库房主数据一致(允许按权限裁剪)"),
],
),
],
),
section(
"右侧库区列表与分页",
[
(
"功能验证点",
[
seq.add(
"高",
"列表核心列完整",
"主表展示库区编号、库区名称、库区地址、是否专项库区、操作(编辑/人员管理/货位管理/停用或启用)",
),
seq.add("高", "默认库区以标签展示", "默认库区在名称旁显示「默认库区」标签,不是独立列;同一库房至多一个默认库区"),
seq.add("中", "专项库区取值", "「是否专项库区」仅为「是」或「否」"),
seq.add("中", "分页翻页", "总条数大于一页时可翻至第 2 页,数据更换且页码高亮正确"),
seq.add("中", "每页条数切换", "切换「50 条/页」等后当前页行数不超过所选值,总数不变"),
],
),
(
"边界验证点",
[
seq.add("中", "首页上一页不可用", "第 1 页时「上一页」禁用或点击无效"),
seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"),
seq.add("低", "库房无库区时分页为 0", "选中尚无库区的库房时列表空态明确,「共 0 条」"),
],
),
(
"异常验证点",
[
seq.add("中", "库区列表加载失败", "失败有错误提示,不白屏、不把上一库房数据展示为当前库房成功态"),
],
),
(
"集成验证点",
[
seq.add("中", "翻页不串库房", "带当前库房选中翻页后,新页库区仍属于该库房"),
seq.add("中", "列表条数与共 N 条一致", "可见行逻辑与底部分页「共 N 条」一致"),
],
),
],
),
section(
"库区搜索",
[
(
"功能验证点",
[
seq.add("高", "按库区编号搜索", "在「库区编号/名称」输入已存在编号,结果包含该编号行"),
seq.add("高", "按库区名称搜索", "输入已存在名称关键字,结果名称匹配"),
seq.add("中", "搜索范围限于当前库房", "搜索结果全部属于左侧当前选中库房"),
],
),
(
"边界验证点",
[
seq.add("中", "超长库区关键字", "超长输入可检索或被截断,页面不崩溃"),
seq.add("低", "仅空格库区关键字", "按空搜索处理或提示,不报错"),
],
),
(
"异常验证点",
[
seq.add("低", "无匹配库区空态", "无命中时列表为空且分页为 0"),
],
),
(
"集成验证点",
[
seq.add("中", "切换库房后搜索条件不串结果", "换库房后结果属于新库房;若保留关键字则在新库房范围内过滤"),
],
),
],
),
section(
"新增库区",
[
(
"功能验证点",
[
seq.add("高", "打开新增库区弹窗", "点「新增」弹出「新增库区」,含库区编号、库区名称、库区地址、联系人、联系方式及取消/确定"),
seq.add("高", "合法必填保存成功", "填写必填后确定,弹窗关闭,当前库房列表出现新记录且字段与提交一致"),
seq.add("中", "取消不落库", "填写后点「取消」或关闭,列表不新增对应库区"),
],
),
(
"边界验证点",
[
seq.add("中", "编号名称达最大允许长度", "库区编号/名称取最大允许长度可保存成功"),
seq.add("低", "地址可与编号或名称相同", "库区地址填写与编号或名称相同的文本可保存"),
],
),
(
"异常验证点",
[
seq.add("高", "必填为空拦截", "库区编号或名称为空点确定被拦截并提示,不关闭弹窗、不落库"),
seq.add("高", "同一库房库区编号重复拦截", "使用当前库房已存在编号保存失败并提示,不覆盖原库区"),
seq.add("中", "保存接口失败", "失败有错误提示,弹窗仍在,列表不出现半成品成功态"),
seq.add("中", "重复点击确定", "连续快速点击只产生一条记录或第二次被防重"),
],
),
(
"集成验证点",
[
seq.add("中", "新增归属当前选中库房", "新增成功后该库区仅出现在当前库房列表,切换其它库房不可见"),
],
),
],
),
section(
"编辑库区",
[
(
"功能验证点",
[
seq.add("高", "打开编辑并回填", "行内「编辑」打开「编辑库区信息」,回填库区编号、名称、地址、联系人、联系方式"),
seq.add("高", "修改后确定落库", "修改名称或地址后确定,列表与再次编辑回看均为新值"),
seq.add("中", "编辑取消不保存", "修改后点「取消」,列表与再次打开仍为修改前值"),
seq.add("中", "专项库区回填一致", "列表「是否专项库区」与编辑页回填一致(若编辑页含该字段)"),
],
),
(
"边界验证点",
[
seq.add("低", "不改字段直接确定", "打开编辑不改内容点确定,记录保持原值且不报错"),
seq.add("中", "默认库区编辑后仍唯一", "编辑保存后该库房仍至多一个「默认库区」标签"),
],
),
(
"异常验证点",
[
seq.add("高", "编辑清空必填拦截", "清空库区名称后确定被拦截,原记录不被写成空"),
seq.add("中", "编辑已停用库区", "停用行仍可打开编辑;若禁止编辑则入口禁用并提示,不以成功态改数"),
],
),
(
"集成验证点",
[
seq.add("中", "编辑不影响其它库房同名库区", "仅更新当前库房该行,其它库房同编号/名称库区保持原值"),
],
),
],
),
section(
"停用与启用",
[
(
"功能验证点",
[
seq.add("高", "停用后操作变为启用", "启用行点「停用」后该行操作变为「启用」,状态持久化"),
seq.add("高", "启用后操作变为停用", "停用行点「启用」后该行操作变为「停用」,再次进入仍保持"),
],
),
(
"边界验证点",
[
seq.add("中", "默认库区停用限制", "默认库区若不允许停用则被拦截并提示;若允许停用则该库房须仍满足「至多一个默认库区」"),
],
),
(
"异常验证点",
[
seq.add("中", "停用/启用失败有提示", "接口失败有错误提示,行操作文案不翻转"),
seq.add("中", "停用须确认才生效", "若有确认框,取消后行仍为启用、操作仍为「停用」"),
],
),
(
"集成验证点",
[
seq.add("中", "停用库区不可再被探针绑定为新货位", "停用后探针绑定弹窗按货位状态/启用过滤时不应再选出该库区下可绑定货位(以现网过滤规则为准)"),
],
),
],
),
section(
"人员管理",
[
(
"功能验证点",
[
seq.add("高", "打开人员管理", "行内「人员管理」打开弹层或子页,标题表明为人员管理且对应当前库区"),
seq.add("中", "关闭后回到库区列表", "关闭人员管理后仍停留在当前库房的库区列表,选中库房不变"),
],
),
(
"边界验证点",
[
seq.add("低", "无人员时列表空态", "未配置人员时空态明确,不报错"),
],
),
(
"异常验证点",
[
seq.add("中", "人员管理加载失败", "失败有错误提示,不把其它库区人员展示为当前库区"),
],
),
(
"集成验证点",
[
seq.add("中", "人员与用户管理账号同源", "可选人员来自用户/角色主数据(允许按权限裁剪)"),
],
),
],
),
section(
"货位管理",
[
(
"功能验证点",
[
seq.add("高", "打开货位管理", "行内「货位管理」打开弹层或子页,标题表明为货位且对应当前库区"),
seq.add("中", "关闭后回到库区列表", "关闭货位管理后当前库房选中与库区列表保持"),
],
),
(
"边界验证点",
[
seq.add("低", "无货位时空态", "库区无货位时列表空态明确"),
],
),
(
"异常验证点",
[
seq.add("中", "货位管理加载失败", "失败有提示,不串其它库区货位"),
],
),
(
"集成验证点",
[
seq.add("高", "货位可被探针绑定弹窗检索到", "本库区启用货位应出现在探针绑定「货位绑定探针」弹窗对应库房/库区筛选结果中"),
],
),
],
),
section(
"同步库房信息",
[
(
"功能验证点",
[
seq.add("中", "同步刷新库房树", "点「同步」后树与列表按最新主数据刷新,不丢当前可维护库房"),
],
),
(
"边界验证点",
[
seq.add("低", "同步期间不可重复提交", "同步进行中按钮禁用或再次点击被忽略,不并发写坏树"),
],
),
(
"异常验证点",
[
seq.add("中", "同步失败有提示", "失败有错误提示,树不空白、不把失败展示为成功"),
],
),
(
"集成验证点",
[
seq.add("中", "同步结果与院区库房主数据一致", "同步后新增/更名的库房节点与主数据一致"),
],
),
],
),
]
return "\n---\n\n".join(chunks)
def probe_body(seq: Seq) -> str:
chunks = [
section(
"筛选与查询",
[
(
"功能验证点",
[
seq.add("高", "默认条件查询", "进入后点「查询」,列表有数据或空态明确,底部分页「共 N 条」与行数逻辑一致"),
seq.add("高", "按探针ID查询", "输入已存在探针ID后结果探针ID匹配,精确命中时至多对应行"),
seq.add("高", "按主机编码查询", "输入主机编码后结果「主机编码」与条件一致(多探针可共用同一主机)"),
seq.add("高", "按是否绑定货位查询", "选择「已绑定」仅保留已绑定行;选择「未绑定」仅保留未绑定行"),
seq.add("高", "探针ID+主机编码+是否绑定组合查询", "多个已填条件同时生效,结果同时满足全部条件"),
seq.add("中", "重置恢复默认", "填写筛选后点「重置」,条件恢复默认,列表按默认规则刷新"),
],
),
(
"边界验证点",
[
seq.add("中", "超长探针ID或主机编码", "超长输入可查询或被截断,页面不崩溃"),
seq.add("低", "仅空格关键字", "按空查询处理或提示,不报错"),
],
),
(
"异常验证点",
[
seq.add("中", "查询接口失败", "失败有错误提示,不白屏、不把过期数据展示为成功态"),
seq.add("低", "无匹配结果空态", "条件无匹配时「暂无数据」明确,分页总数为 0"),
],
),
(
"集成验证点",
[
seq.add("中", "筛选结果与分页总数一致", "查询后可见行逻辑与「共 N 条」一致"),
],
),
],
),
section(
"列表与分页",
[
(
"功能验证点",
[
seq.add(
"高",
"列表核心列完整",
"主表展示序号、探针ID、主机编码、是否绑定货位、绑定的货位、操作(绑定货位)",
),
seq.add("高", "已绑定行数量大于 0", "「是否绑定货位」为已绑定时,「绑定的货位」数量大于 0"),
seq.add("高", "未绑定行数量为 0", "未绑定行「绑定的货位」为 `0`"),
seq.add("中", "绑定的货位列为数量不是货位号", "主表该列为数字;货位号只在弹窗「货位号」列"),
seq.add("中", "分页翻页", "总条数大于一页时可翻页,数据更换且页码正确"),
seq.add("中", "每页条数切换", "切换每页条数后当前页行数不超过所选值,总数不变"),
],
),
(
"边界验证点",
[
seq.add("中", "首页上一页不可用", "第 1 页时「上一页」禁用或点击无效"),
seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"),
],
),
(
"异常验证点",
[
seq.add("低", "跳至非数字页码", "非法页码不跳转异常页"),
],
),
(
"集成验证点",
[
seq.add("中", "翻页不丢失当前筛选", "带筛选翻页后条件保持,新页仍满足原筛选"),
],
),
],
),
section(
"打开绑定货位弹窗",
[
(
"功能验证点",
[
seq.add("高", "行内打开货位绑定探针", "点「绑定货位」弹出「货位绑定探针」,含库房/库区/货位筛选与货位列表"),
seq.add("中", "取消关闭不改主列表", "未确定即点「取消」或关闭,主列表绑定数量与筛选条件保持"),
],
),
(
"边界验证点",
[
seq.add("低", "未选货位时确定计数为 0", "底部按钮文案为「确定(0)」或等价,未选时确定不新增绑定"),
],
),
(
"异常验证点",
[
seq.add("中", "弹窗加载失败", "货位列表失败有提示,不把主列表「共 N 条」与弹窗总条数混用"),
],
),
(
"集成验证点",
[
seq.add("中", "弹窗分页独立于主列表", "弹窗「共 N 条」(货位量级)不得用于断言主列表探针条数"),
],
),
],
),
section(
"弹窗筛选货位",
[
(
"功能验证点",
[
seq.add("高", "按当前库房筛选", "选择库房后列表「当前库房」与所选一致"),
seq.add("高", "按当前库区筛选", "选择库区后列表「当前库区」与所选一致"),
seq.add("高", "按货位号查询", "「货位查询」输入货位号关键字后结果货位号匹配"),
seq.add("中", "弹窗内重置", "填写弹窗筛选后点「重置」,条件恢复,列表按弹窗默认规则刷新"),
],
),
(
"边界验证点",
[
seq.add("中", "超长货位号关键字", "超长输入可查询或被截断,弹窗不崩溃"),
seq.add("低", "库区下拉随库房收敛", "切换库房后库区选项属于该库房,不残留上一库房库区"),
],
),
(
"异常验证点",
[
seq.add("低", "弹窗无匹配空态", "条件无命中时货位列表空、弹窗分页为 0"),
],
),
(
"集成验证点",
[
seq.add("高", "库房库区与库区设置同源", "弹窗库房/库区选项来自库区设置中已维护且可用的库房与库区"),
],
),
],
),
section(
"勾选货位并保存绑定",
[
(
"功能验证点",
[
seq.add("高", "勾选后确定计数增加", "勾选 N 个货位后确定按钮计数为 N"),
seq.add("高", "确定后主列表绑定数量增加", "为未绑定探针勾选货位并确定后,该行变为已绑定,数量等于本次保存的货位数(或按产品规则累加)"),
seq.add("中", "已绑货位行展示探针ID", "弹窗中已被该探针绑定的货位「探针ID」列显示该探针号,未绑定为 `--`"),
],
),
(
"边界验证点",
[
seq.add("中", "取消勾选后计数回退", "勾选后再取消,确定计数回到实际已选数"),
],
),
(
"异常验证点",
[
seq.add("中", "绑定保存失败", "失败有提示,主列表绑定数量不增加,再次打开不是半成功勾选"),
seq.add("中", "货位已被其它探针占用", "占用行再绑定时按换绑或拒绝执行;以保存后主列表数量与弹窗探针ID列为准,不得静默双绑"),
],
),
(
"集成验证点",
[
seq.add("高", "绑定结果与货位管理一致", "保存后在对应库区货位侧能看到该探针占用;解绑/换绑后两侧同步"),
],
),
],
),
]
return "\n---\n\n".join(chunks)
def def_body(seq: Seq) -> str:
chunks = [
section(
"左侧业务类型",
[
(
"功能验证点",
[
seq.add("高", "业务类型列表可见", "左侧列出采购计划、请领单、产品准入等类型,当前项高亮"),
seq.add("高", "按流程名称过滤类型", "输入关键字后仅保留名称匹配的业务类型"),
seq.add("中", "清空关键字恢复全量", "清空后类型列表恢复过滤前全量"),
seq.add("高", "切换类型刷新右侧配置", "点选另一类型后,默认审批流与自定义审批流与该类型已保存配置一致,不残留上一类型节点"),
],
),
(
"边界验证点",
[
seq.add("中", "超长流程名称关键字", "超长输入可过滤或被截断,页面不崩溃"),
seq.add("低", "仅空格关键字", "按空过滤处理或提示,不报错"),
],
),
(
"异常验证点",
[
seq.add("低", "无匹配类型", "无命中时左侧为空或明确无数据,右侧不展示上一类型配置为当前配置"),
],
),
(
"集成验证点",
[
seq.add("高", "类型与审批流实例业务类型同源", "左侧类型集合与审批流实例「业务类型」下拉选项一致(允许按权限裁剪)"),
],
),
],
),
section(
"默认审批流",
[
(
"功能验证点",
[
seq.add("高", "默认审批流节点可见", "右侧「默认审批流」展示节点卡片,含节点类型、账号、人员"),
seq.add("中", "可追加默认流节点", "点节点旁「+」可增加审批节点(保存前仅在当前页可见)"),
],
),
(
"边界验证点",
[
seq.add("低", "未配置自定义流时走默认链", "该类型无自定义流时,新单据按默认审批人链流转(以审批流实例抽样为准)"),
],
),
(
"异常验证点",
[
seq.add("中", "默认流节点人员为空拦截", "保存时默认链缺少人员被拦截并提示,不写入空节点"),
],
),
(
"集成验证点",
[
seq.add("中", "只改自定义流时默认链不变", "仅增删自定义行并保存后,默认审批人节点保持原值"),
],
),
],
),
section(
"自定义审批流",
[
(
"功能验证点",
[
seq.add("高", "新增流程增加一行", "点「新增流程」自定义流增加一行,含流程名称、条件、审批流程、删除"),
seq.add("中", "删除行未保存不落库", "删除自定义行后不点保存,重新进入该类型行仍在"),
seq.add("高", "保存后自定义流持久化", "填写名称/条件/节点后保存,重新进入该类型配置与保存时一致"),
],
),
(
"边界验证点",
[
seq.add("中", "流程名称必填", "名称空时保存被拦截"),
seq.add("中", "条件必填", "条件空时保存被拦截"),
seq.add("中", "审批流程必填", "无审批节点时保存被拦截"),
],
),
(
"异常验证点",
[
seq.add("高", "必填为空拦截", "流程名称/条件/审批流程任一为空点保存被拦截并提示,不关闭、不落库"),
seq.add("中", "保存接口失败", "失败有提示,再次进入不是半成功配置"),
],
),
(
"集成验证点",
[
seq.add("高", "自定义条件命中后新单走该链", "保存后新产生的符合条件单据在审批流实例中按该自定义链流转,而非默认链"),
],
),
],
),
section(
"条件配置",
[
(
"功能验证点",
[
seq.add("高", "打开条件配置", "条件列「编辑」打开「条件配置」,可添加条件行并保存/取消"),
seq.add("高", "保存条件回写行内文案", "配置如「是否超限:是」或「科室:中心库(洪山)」后保存,行内条件展示与配置一致"),
seq.add("中", "取消不改行内条件", "修改后点取消,行内条件仍为打开前的值"),
],
),
(
"边界验证点",
[
seq.add("低", "可删除条件行", "条件配置内「删除」去掉一行,保存后不再出现该条件"),
],
),
(
"异常验证点",
[
seq.add("中", "条件不完整拦截", "条件类型或取值空时保存被拦截"),
],
),
(
"集成验证点",
[
seq.add("中", "科室条件与库房/科室主数据同源", "条件选科室时选项来自主数据,与库区设置/用户所属科室不矛盾"),
],
),
],
),
section(
"保存与重置",
[
(
"功能验证点",
[
seq.add("高", "保存写入当前业务类型", "点「保存」只持久化当前选中类型的默认流与自定义流"),
seq.add("高", "重置放弃未保存改动", "改节点或自定义行后点「重置」,页面回到上次保存结果"),
],
),
(
"边界验证点",
[
seq.add("低", "无改动点保存不报错", "未修改直接保存成功或提示无变更,配置不变"),
],
),
(
"异常验证点",
[
seq.add("中", "未保存切换类型", "有未保存改动时切换业务类型须提示后丢弃或不允许切换,不得把上一类型的改动写入下一类型"),
],
),
(
"集成验证点",
[
seq.add("中", "保存不影响历史审批实例", "改审批人后,已存在的审批流实例详情仍为发起时的快照或按产品规则说明;不得无说明地改写历史已通过单"),
],
),
],
),
]
return "\n---\n\n".join(chunks)
def inst_body(seq: Seq) -> str:
chunks = [
section(
"筛选与查询",
[
(
"功能验证点",
[
seq.add("高", "默认日期区间查询", "进入后带默认发起时间区间,点「查询」列表有数据或空态明确,「共 N 条」与结果逻辑一致"),
seq.add("高", "单据编号精确查询", "输入完整单号后至多一条,且编号、业务类型与该行一致"),
seq.add("高", "按审批状态筛选", "选择「审批通过」或「审批中」后列表状态列与所选一致"),
seq.add("高", "按业务类型筛选", "选择某类型后列表「业务类型」均为该类型"),
seq.add("高", "按发起时间区间筛选", "结果行「发起时间」落在起止日期内(含边界日)"),
seq.add("中", "按摘要关键字筛选", "输入摘要关键字后结果摘要包含该关键字;摘要为 `--` 的行不被非空关键字命中"),
seq.add("高", "多条件组合查询", "状态、业务类型、发起时间、摘要同时生效,抽样行满足全部已填条件"),
seq.add("中", "重置恢复默认", "填写后点「重置」,日期回到默认区间,其余条件清空,「共 N 条」恢复"),
],
),
(
"边界验证点",
[
seq.add("中", "超长单据编号或摘要", "超长输入可查询或被截断,页面不崩溃"),
seq.add("中", "开始日期晚于结束日期", "非法区间被拒绝或自动纠正,不返回错乱时间结果"),
seq.add("低", "仅空格摘要", "按空查询处理或提示,不报错"),
],
),
(
"异常验证点",
[
seq.add("中", "查询接口失败", "失败有错误提示,不白屏、不把过期列表展示为成功态"),
seq.add("低", "无匹配空态", "总数为 0,空态明确"),
],
),
(
"集成验证点",
[
seq.add("中", "筛选结果与分页总数一致", "查询后可见行逻辑与「共 N 条」一致"),
seq.add("高", "业务类型与审批流程设置同源", "「业务类型」下拉与审批流程设置左侧类型一致(允许按权限裁剪)"),
],
),
],
),
section(
"列表与分页",
[
(
"功能验证点",
[
seq.add(
"高",
"列表核心列完整",
"主表展示序号、单据编号、业务类型、发起人、发起部门、发起时间、摘要、审批状态、操作(详情)",
),
seq.add("中", "单据编号前缀与业务类型对应", "`PLAN` 对应采购计划、`DB` 对应二级库调拨、`GC` 对应产品信息变更、`BUY` 对应请领单(以现网枚举为准)"),
seq.add("中", "空摘要展示为 --", "无摘要行显示 `--`,长摘要可截断"),
seq.add("中", "窄列截断不影响查询", "发起人/部门/时间截断展示时,按完整单号搜索仍能命中"),
seq.add("中", "分页翻页", "总条数大于一页时可翻页,数据更换"),
seq.add("中", "每页条数切换", "切换后当前页行数不超过所选值,总数不变"),
seq.add("中", "跳至指定页", "输入合法页码后跳到对应页"),
],
),
(
"边界验证点",
[
seq.add("中", "首页上一页不可用", "第 1 页时「上一页」禁用或点击无效"),
seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"),
seq.add("低", "跳至超出总页数", "大于总页数时被拒绝、纠正到末页或提示"),
],
),
(
"异常验证点",
[
seq.add("低", "跳至非数字页码", "非法输入不跳转异常页"),
],
),
(
"集成验证点",
[
seq.add("中", "翻页不丢失当前筛选", "带筛选翻页后条件保持,新页数据仍满足原筛选"),
],
),
],
),
section(
"审批详情",
[
(
"功能验证点",
[
seq.add("高", "打开详情对应当前行", "点「详情」打开该单据审批过程,标题/单号与当前行单据编号一致"),
seq.add("中", "详情只读", "详情内不能改审批人或条件;改配置须回到审批流程设置"),
seq.add("中", "关闭详情保留列表状态", "返回后筛选条件与页码保持,列表仍是打开前的查询结果"),
],
),
(
"边界验证点",
[
seq.add("低", "审批中与已通过均可打开详情", "两种状态行都能进入详情,节点展示与状态相符"),
],
),
(
"异常验证点",
[
seq.add("中", "详情加载失败", "失败有提示,不把其它单据内容展示为当前单"),
],
),
(
"集成验证点",
[
seq.add("高", "详情审批链与流程设置一致", "新单据详情中的节点/人员与该业务类型在审批流程设置中命中的默认或自定义链一致"),
],
),
],
),
]
return "\n---\n\n".join(chunks)
def quality(p: dict, el_count: int, tp_count: int) -> str:
kind = p["kind"]
if kind == "zone":
cover = "导航、库房树过滤/切换、库区列表分页、库区搜索、新增/编辑弹窗、停用启用、人员/货位入口、同步"
pending = (
"① 人员管理/货位管理扫描未打开面板,入口以行内按钮为准,子页字段待补扫 "
"② 默认库区如何指定(编辑字段或单独接口)以现网为准 "
"③ 停用默认库区是否允许待产品确认 "
"④ 新增弹窗扫描未见「是否专项库区」控件,列表有该列,编辑是否可改为待评审"
)
elif kind == "probe":
cover = "导航、探针筛选查询、列表数量规则、绑定弹窗筛选分页、勾选确定/取消、与库区货位集成"
pending = (
"① 同一货位已有其它探针ID时是换绑还是拒绝,以保存后主列表数量与弹窗探针ID列为准 "
"② 确定按钮文案为「确定(N)」,未选为 0 "
"③ 主表无新增探针入口,探针主数据来源不在本页维护"
)
elif kind == "def":
cover = "导航、业务类型过滤/切换、默认审批流、自定义流增删、条件配置、保存重置、与审批流实例类型同源"
pending = (
"① 保存会改线上审批路由,自动化冒烟只读结构、不提交 "
"② 未保存切类型按「提示后丢弃、不串写」写点,若自动保存需改判定 "
"③ 历史已通过实例是否快照冻结待产品确认"
)
else:
cover = "导航、单据编号/状态/类型/时间/摘要筛选、列表列与前缀规则、分页、只读详情、与流程设置类型同源"
pending = (
"① 默认发起时间跨度大,断言「共 N 条」须先收窄日期 "
"② 本页无新增/导出/改审批人;写配置只在审批流程设置 "
"③ 详情扫描打开「采购计划详情」,其它业务类型详情标题以现网为准"
)
return f"""## 质量自检摘要
| 项 | 结论 |
|----|------|
| 覆盖 | 已覆盖:{cover};共 **{tp_count}** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) |
| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 |
| 去重 | 登录拦截、会话过期与同模块其它页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `{p["prefix"]}` |
| 待评审项 | {pending} |
| 下游 | 可转结构化 MD/CSV;扫描 `npm run {p["scan_cmd"]}`;自动化建议 `{p["po"]}` |
"""
def render_testpoint(p: dict, scan: dict) -> tuple[str, int]:
spec = f"UI-SPEC-SPD-{p['specId']}"
seq = Seq(p["prefix"], spec)
nav = nav_section(p, seq)
if p["kind"] == "zone":
body = zone_body(seq)
elif p["kind"] == "probe":
body = probe_body(seq)
elif p["kind"] == "def":
body = def_body(seq)
else:
body = inst_body(seq)
md = f"""# 基础管理-{p['group']}-{p['name']} — 测试点分析
**关联需求**:`knowledge-base/08-UI专项规范库/{spec}.md`
**页面 URL**:`{scan["url"]}`
**版本 / 定稿日期**:{TODAY}
**元素扫描**:`data/scan/{p["pageKey"]}-elements.json`({scan["elementCount"]} 项)
**定位器**:`locators/spd/{p["pageKey"]}.locator.yaml`
---
## 基础设置 · {p['group']} · {p['name']}
{nav}
---
{body}
---
{quality(p, scan["elementCount"], seq.n)}
"""
return md, seq.n
def patch_ui_spec(p: dict, scan: dict, tp_file: str) -> None:
path = KB / f"UI-SPEC-SPD-{p['specId']}.md"
text = path.read_text(encoding="utf-8")
text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1)
text = re.sub(r'related_tc: "[^"]*"', f'related_tc: "testpoint/{tp_file}"', text, count=1)
url = scan["url"]
count = scan["elementCount"]
page_key = p["pageKey"]
extra = f"| 浏览器页签标题 | `{p['name']}` |\n"
table = f"""| 项 | 值 |
|----|-----|
| 主框架 URL | `{url}` |
{extra}| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
| 元素扫描 JSON | `data/scan/{page_key}-elements.json`({count} 项) |
| 定位器 YAML | `locators/spd/{page_key}.locator.yaml` |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |"""
text = re.sub(
r"\| 项 \| 值 \|\n\|----\|-----\|\n(?:\| .+\|\n)+",
table + "\n",
text,
count=1,
)
path.write_text(text, encoding="utf-8")
def patch_index(counts: dict[str, int], scans: dict[str, dict]) -> None:
text = INDEX.read_text(encoding="utf-8")
text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1)
rows = {
"WH-001": (
"| 库区设置 | WH-001 | `/spd/basic/storehouse/platform/storehouse/area` | "
f"已扫描 · `warehouse-zone` · **{scans['WH-001']['elementCount']}** 项 · "
f"测试点 **{counts['WH-001']}** 条 · `TP_WH_ZON` |"
),
"WH-002": (
"| 探针绑定 | WH-002 | `/spd/basic/storehouse/storehouse/probe` | "
f"已扫描 · `warehouse-probe` · **{scans['WH-002']['elementCount']}** 项 · "
f"测试点 **{counts['WH-002']}** 条 · `TP_WH_PRB` |"
),
"FLOW-001": (
"| 审批流程设置 | FLOW-001 | `/spd/basic/flowSet/workflow/flowSet` | "
f"已扫描 · `flow-def` · **{scans['FLOW-001']['elementCount']}** 项 · "
f"测试点 **{counts['FLOW-001']}** 条 · `TP_FLOW_DEF` |"
),
"FLOW-002": (
"| 审批流实例 | FLOW-002 | `/spd/basic/flowSet/workflow/flowCase` | "
f"已扫描 · `flow-inst` · **{scans['FLOW-002']['elementCount']}** 项 · "
f"测试点 **{counts['FLOW-002']}** 条 · `TP_FLOW_INS` |"
),
}
text = re.sub(
r"\| 库区设置 \| WH-001 \| `/spd/basic/storehouse/platform/storehouse/area` \| .+ \|",
rows["WH-001"],
text,
count=1,
)
text = re.sub(
r"\| 探针绑定 \| WH-002 \| `/spd/basic/storehouse/storehouse/probe` \| .+ \|",
rows["WH-002"],
text,
count=1,
)
text = re.sub(
r"\| 审批流程设置 \| FLOW-001 \| `/spd/basic/flowSet/workflow/flowSet` \| .+ \|",
rows["FLOW-001"],
text,
count=1,
)
text = re.sub(
r"\| 审批流实例 \| FLOW-002 \| `/spd/basic/flowSet/workflow/flowCase` \| .+ \|",
rows["FLOW-002"],
text,
count=1,
)
if "generate:testpoints-warehouse" not in text:
text = text.replace(
"npm run discover:scan-warehouse\n",
"npm run discover:scan-warehouse\nnpm run generate:testpoints-warehouse\n",
)
if "generate:testpoints-flow" not in text:
text = text.replace(
"npm run discover:scan-flow\n",
"npm run discover:scan-flow\nnpm run generate:testpoints-flow\n",
)
INDEX.write_text(text, encoding="utf-8")
def main() -> None:
import sys
filter_keys = set(sys.argv[1:])
pages = [p for p in PAGES if not filter_keys or p["pageKey"] in filter_keys]
if not pages:
raise SystemExit("No pages matched. Available: " + ", ".join(p["pageKey"] for p in PAGES))
OUT_DIR.mkdir(parents=True, exist_ok=True)
counts: dict[str, int] = {}
scans: dict[str, dict] = {}
for p in pages:
scan = load_scan(p["pageKey"])
md, n = render_testpoint(p, scan)
out = OUT_DIR / p["file"]
out.write_text(md, encoding="utf-8")
patch_ui_spec(p, scan, p["file"])
counts[p["specId"]] = n
scans[p["specId"]] = scan
print(f"OK {p['specId']}\t{p['name']}\t{scan['elementCount']} el\t{n} TP\t{out.name}")
# INDEX 四行一起补全:缺页时用已落盘测试点文件行数占位,避免半更新
if len(counts) < len(PAGES):
for p in PAGES:
if p["specId"] in counts:
continue
scans[p["specId"]] = load_scan(p["pageKey"])
tp_file = OUT_DIR / p["file"]
n = 0
if tp_file.exists():
n = len(re.findall(r"\*\*TP_[A-Z0-9_]+_\d{3}\*\*", tp_file.read_text(encoding="utf-8")))
counts[p["specId"]] = n
patch_index(counts, scans)
print("done", counts)
if __name__ == "__main__":
main()
......@@ -5,6 +5,8 @@
* Usage:
* node scripts/json-to-locator-yaml.mjs data/scan/warn-strand-elements.json
* node scripts/json-to-locator-yaml.mjs --all-extended
* node scripts/json-to-locator-yaml.mjs --all-basic
* node scripts/json-to-locator-yaml.mjs --all-new
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
......@@ -13,6 +15,8 @@ import { writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SCAN_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
const EXTENDED_KEYS = [
'warn-strand',
......@@ -35,23 +39,106 @@ const EXTENDED_KEYS = [
'reag-entry',
];
const NEW_PAGES = {
'basic-notice': { pageTitle: '基础设置 > 医院资料 > 公告管理', module: 'spd-basic' },
'basic-hospital-info': { pageTitle: '基础设置 > 医院资料 > 医院信息登记', module: 'spd-basic' },
'basic-finance-month': { pageTitle: '基础设置 > 医院资料 > 财务月份设置', module: 'spd-basic' },
'basic-branch': { pageTitle: '基础设置 > 医院资料 > 院区管理', module: 'spd-basic' },
'basic-user': { pageTitle: '基础设置 > 用户管理 > 用户管理', module: 'spd-basic' },
'basic-user-union': { pageTitle: '基础设置 > 用户管理 > 医共体用户管理', module: 'spd-basic' },
'basic-role': { pageTitle: '基础设置 > 用户管理 > 角色管理', module: 'spd-basic' },
'admit-material': { pageTitle: '基础设置 > 准入管理 > 物料准入管理', module: 'spd-basic' },
'admit-product-apply': { pageTitle: '基础设置 > 准入管理 > 产品准入申请', module: 'spd-basic' },
'admit-material-list': { pageTitle: '基础设置 > 准入管理 > 物料准入单列表', module: 'spd-basic' },
'admit-product-list': { pageTitle: '基础设置 > 准入管理 > 产品准入单列表', module: 'spd-basic' },
'admit-supply': { pageTitle: '基础设置 > 准入管理 > 供货目录申请', module: 'spd-basic' },
'admit-task': { pageTitle: '基础设置 > 准入管理 > 准入任务分配', module: 'spd-basic' },
'vendor-query': { pageTitle: '基础设置 > 厂商管理 > 厂商查询', module: 'spd-basic' },
'vendor-qual': { pageTitle: '基础设置 > 厂商管理 > 供应商资质管理', module: 'spd-basic' },
'vendor-auth-first': { pageTitle: '基础设置 > 厂商管理 > 授权变更初审', module: 'spd-basic' },
'vendor-auth-final': { pageTitle: '基础设置 > 厂商管理 > 授权变更终审', module: 'spd-basic' },
'vendor-cert-first': { pageTitle: '基础设置 > 厂商管理 > 注册证变更初审', module: 'spd-basic' },
'vendor-cert-final': { pageTitle: '基础设置 > 厂商管理 > 注册证变更终审', module: 'spd-basic' },
'vendor-cert-diff': { pageTitle: '基础设置 > 厂商管理 > 注册证差异对比', module: 'spd-basic' },
'vendor-eval-scheme': { pageTitle: '基础设置 > 厂商管理 > 评价方案维护', module: 'spd-basic' },
'vendor-eval-indicator': { pageTitle: '基础设置 > 厂商管理 > 评价指标维护', module: 'spd-basic' },
'vendor-holiday': { pageTitle: '基础设置 > 厂商管理 > 供应商节假日管理', module: 'spd-basic' },
'surgery-base-pkg': { pageTitle: '基础设置 > 手术套包设置 > 基础包维护', module: 'spd-basic' },
'surgery-oper-type': { pageTitle: '基础设置 > 手术套包设置 > 术种维护', module: 'spd-basic' },
'surgery-std-pkg': { pageTitle: '基础设置 > 手术套包设置 > 标准套包维护', module: 'spd-basic' },
'warehouse-zone': { pageTitle: '基础设置 > 库房管理 > 库区设置', module: 'spd-basic' },
'warehouse-probe': { pageTitle: '基础设置 > 库房管理 > 探针绑定', module: 'spd-basic' },
'flow-def': { pageTitle: '基础设置 > 流程设置 > 审批流程设置', module: 'spd-basic' },
'flow-inst': { pageTitle: '基础设置 > 流程设置 > 审批流实例', module: 'spd-basic' },
};
const GROUPS = {
'--all-extended': EXTENDED_KEYS,
'--all-basic': ['basic-notice', 'basic-hospital-info', 'basic-finance-month', 'basic-branch'],
'--all-user': ['basic-user', 'basic-user-union', 'basic-role'],
'--all-admit': [
'admit-material',
'admit-product-apply',
'admit-material-list',
'admit-product-list',
'admit-supply',
'admit-task',
],
'--all-vendor': [
'vendor-query',
'vendor-qual',
'vendor-auth-first',
'vendor-auth-final',
'vendor-cert-first',
'vendor-cert-final',
'vendor-cert-diff',
'vendor-eval-scheme',
'vendor-eval-indicator',
'vendor-holiday',
],
'--all-surgery': ['surgery-base-pkg', 'surgery-oper-type', 'surgery-std-pkg'],
'--all-warehouse': ['warehouse-zone', 'warehouse-probe'],
'--all-flow': ['flow-def', 'flow-inst'],
'--all-new': Object.keys(NEW_PAGES),
};
function jsonPathFor(key) {
return path.join(SCAN_DIR, `${key}-elements.json`);
}
function writeOne(jsonPath, pageKey) {
const meta = NEW_PAGES[pageKey] || {};
const result = writeYamlFromScanJson(jsonPath, {
pageKey: pageKey || path.basename(jsonPath, '-elements.json'),
pageTitle: meta.pageTitle,
module: meta.module || (pageKey && NEW_PAGES[pageKey] ? 'spd-basic' : undefined),
jsonSourceName: path.basename(jsonPath),
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${pageKey || path.basename(jsonPath, '-elements.json')}.locator.yaml`),
});
console.log(`YAML: ${result.yamlOut} (${result.elementCount} elements)`);
return result;
}
async function main() {
const args = process.argv.slice(2);
const targets =
args[0] === '--all-extended'
? EXTENDED_KEYS.map((key) => path.join(ROOT, 'data/scan', `${key}-elements.json`))
: args.length
? [path.resolve(args[0])]
: [];
if (!targets.length) {
console.error('Usage: node scripts/json-to-locator-yaml.mjs <json-path|--all-extended>');
if (!args.length) {
console.error(
'Usage: node scripts/json-to-locator-yaml.mjs <json-path|--all-extended|--all-basic|--all-user|--all-admit|--all-vendor|--all-surgery|--all-warehouse|--all-flow|--all-new>',
);
process.exit(1);
}
for (const jsonPath of targets) {
const result = writeYamlFromScanJson(jsonPath);
console.log(`YAML: ${result.yamlOut} (${result.elementCount} elements)`);
for (const arg of args) {
if (GROUPS[arg]) {
for (const key of GROUPS[arg]) {
writeOne(jsonPathFor(key), key);
}
continue;
}
const jsonPath = path.resolve(arg);
const pageKey = path.basename(jsonPath).replace(/-elements\.json$/, '');
writeOne(jsonPath, pageKey);
}
}
......
......@@ -20,6 +20,8 @@ import {
readdirSync,
readFileSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import path from 'node:path';
......@@ -30,6 +32,9 @@ import { formatSuiteList, getSuite } from '../config/suites.config.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PLAYWRIGHT_CLI = path.join(ROOT, 'node_modules/playwright/cli.js');
/** --keep-archives=N 的命令行值;undefined 时退回环境变量 / 默认全留。 */
let KEEP_ARCHIVES_ARG;
function parseArgs(argv) {
const out = {
suite: process.env.SUITE || 'full',
......@@ -40,6 +45,7 @@ function parseArgs(argv) {
skipTypecheck: process.env.SKIP_TYPECHECK === '1',
shards: Number(process.env.SHARDS || 1),
shard: process.env.PW_SHARD || '',
keepArchives: undefined,
};
for (const arg of argv) {
if (arg === '--list') out.list = true;
......@@ -49,13 +55,14 @@ function parseArgs(argv) {
else if (arg.startsWith('--suite=')) out.suite = arg.slice(8);
else if (arg.startsWith('--shards=')) out.shards = Math.max(1, Number(arg.slice(9)) || 1);
else if (arg.startsWith('--shard=')) out.shard = arg.slice(8);
else if (arg.startsWith('--keep-archives=')) out.keepArchives = arg.slice(16);
else out.extra.push(arg);
}
return out;
}
function printHelp() {
console.log(`Usage: node scripts/run-ci.mjs [--suite=smoke] [--shards=2] [--shard=1/2] [--skip-preflight] [--list]
console.log(`Usage: node scripts/run-ci.mjs [--suite=smoke] [--shards=2] [--shard=1/2] [--keep-archives=N] [--skip-preflight] [--list]
Suites:
${formatSuiteList()}
......@@ -64,6 +71,7 @@ ${formatSuiteList()}
默认按「文件」并行(PW_WORKERS),同一 spec 内 serial 组不拆开。
--shards=N 本机拉起 N 个 Playwright 进程(先登录一次再分片)
--shard=i/N 只跑第 i 片(Jenkins 多 Agent 矩阵用)
--keep-archives=N 只保留最近 N 份 reports/ci-*(0=跑完即删本次归档,等价 PW_KEEP_ARCHIVES=0)
Env:
SUITE / PW_WORKERS / PW_RETRIES / RUN_FULL / BASE_URL / TEST_USER / TEST_PASS
......@@ -71,7 +79,11 @@ Env:
RUN_QUARANTINE=1 只跑 config/quarantine.json 隔离项
SKIP_PREFLIGHT=1 跳过站点探活
SKIP_TYPECHECK=1 跳过 tsc --noEmit
ALLURE=0 关闭 Allure
ALLURE=1 额外生成 Allure(默认关,避免与 HTML 重复保存 trace/截图)
PW_JUNIT_COMPACT=0 保留 JUnit 完整堆栈(默认 1:只留 message)
PW_KEEP_TRACES=N 归档内只留最大 N 个 trace/截图附件(默认全留)
PW_KEEP_ARCHIVES=N 本机只保留最近 N 份 ci-<buildId>(默认全留;0=跑完即删)
PW_KEEP_INTERMEDIATE=1 保留分片目录与 blob-report(默认合并后删除)
INCLUDE_DEBUG_SPECS=1 纳入 *.stat-*.spec.ts 调试用例
`);
}
......@@ -105,6 +117,161 @@ function appendFileIfExists(src, dest) {
return true;
}
/** 旧归档保留份数:-1=全留(默认,保持原行为),0=跑完即删,N=保留最新 N 份。 */
function resolveKeepArchives() {
const raw = process.env.PW_KEEP_ARCHIVES ?? KEEP_ARCHIVES_ARG;
if (raw === undefined || raw === '') return -1;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : -1;
}
function dirSizeBytes(dir) {
let total = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) total += dirSizeBytes(full);
else if (entry.isFile()) {
try {
total += statSync(full).size;
} catch {
// 文件被占用/已删除则跳过
}
}
}
return total;
}
/** 删除本次归档内的中间目录(分片产物、blob),它们只在合并阶段有用。 */
function dropIntermediateDirs(archiveDir, blobDir) {
if (process.env.PW_KEEP_INTERMEDIATE === '1') return;
rmSync(blobDir, { recursive: true, force: true });
for (const item of readdirSync(archiveDir, { withFileTypes: true })) {
if (item.isDirectory() && /^shard-\d+$/.test(item.name)) {
rmSync(path.join(archiveDir, item.name), { recursive: true, force: true });
}
}
}
function pruneArchives(currentDir, keep) {
if (keep === 0) {
rmSync(currentDir, { recursive: true, force: true });
console.log(`[ci] PW_KEEP_ARCHIVES=0,已删除本次归档(latest/ 仍保留最近一次)`);
return;
}
const base = path.join(ROOT, 'reports');
const archives = readdirSync(base, { withFileTypes: true })
.filter((item) => item.isDirectory() && /^ci-/.test(item.name))
.map((item) => {
const full = path.join(base, item.name);
return { full, name: item.name, mtime: statSync(full).mtimeMs };
})
.sort((a, b) => b.mtime - a.mtime);
for (const item of archives.slice(Math.max(keep, 1))) {
try {
rmSync(item.full, { recursive: true, force: true });
console.log(`[ci] 清理旧归档 ${item.name}`);
} catch (err) {
console.warn(`[ci] 清理 ${item.name} 失败(可能被占用):${err.message}`);
}
}
}
/** 附件保留份数:-1=全留(默认),0=全部删除,N=仅留体积最大的 N 个。 */
function resolveKeepTraces() {
const raw = process.env.PW_KEEP_TRACES;
if (raw === undefined || raw === '') return -1;
const n = Number(raw);
return Number.isFinite(n) && n >= -1 ? Math.trunc(n) : -1;
}
/** mergeAllureResults 会给文件名加 `<分片>-` 前缀,但 result.json 里仍是原始 uuid,比对时要剥掉前缀。 */
function attachmentUuid(fileName) {
return fileName.replace(/^shard-\d+-/, '');
}
/**
* Allure 与 HTML 各自保存一份 trace.zip / 截图(同一失败用例 2 份)。
* 保留一份 HTML 报告即可:默认不再把二进制附件复制进 allure-results,
* 并同步删掉 result.json 里的附件条目,避免 Allure 页面出现坏链接。
*/
function pruneAllureBinaryAttachments(resultsDir, keep) {
if (!existsSync(resultsDir)) return;
const binaries = readdirSync(resultsDir, { withFileTypes: true })
.filter((item) => item.isFile() && /-attachment\.(zip|png|jpg|jpeg|webm|mp4)$/i.test(item.name))
.map((item) => {
const full = path.join(resultsDir, item.name);
return { name: item.name, uuid: attachmentUuid(item.name), full, size: statSync(full).size };
});
if (!binaries.length) return;
binaries.sort((a, b) => b.size - a.size);
const dropped = new Set();
let freed = 0;
for (const item of binaries.slice(Math.max(keep, 0))) {
try {
unlinkSync(item.full);
dropped.add(item.uuid);
freed += item.size;
} catch {
// 删除失败则保留引用
}
}
if (!dropped.size) return;
for (const item of readdirSync(resultsDir, { withFileTypes: true })) {
if (!item.isFile() || !item.name.endsWith('-result.json')) continue;
const full = path.join(resultsDir, item.name);
try {
writeFileSync(full, stripAttachmentRefs(readFileSync(full, 'utf8'), dropped));
} catch {
// 单个 result 解析失败不影响其余文件
}
}
console.log(
`[ci] allure 二进制附件瘦身:删除 ${dropped.size} 个,释放 ${(freed / 1024 / 1024).toFixed(0)} MB`,
);
}
function stripAttachmentRefs(raw, dropped) {
const isMarked = (ref) => typeof ref === 'string' && dropped.has(ref);
const clean = (node) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (let i = node.length - 1; i >= 0; i -= 1) {
if (isMarked(node[i]?.source)) node.splice(i, 1);
else clean(node[i]);
}
return;
}
if (Array.isArray(node.attachments)) {
node.attachments = node.attachments.filter((entry) => !isMarked(entry?.source));
}
for (const value of Object.values(node)) clean(value);
};
const json = JSON.parse(raw);
clean(json);
return JSON.stringify(json);
}
/** 紧凑 JUnit:去掉每条 failure/error 的整段 <system-err>/堆栈,只留 message 属性。 */
function compactJunit(file) {
const xml = readFileSync(file, 'utf8');
const compact = xml.replace(/<(failure|error)\b([^>]*)>[\s\S]*?<\/\1>/g, (match, tag, attrs) =>
attrs.includes('message=') ? `<${tag}${attrs} />` : match,
);
writeFileSync(file, compact);
}
/** 归档瘦身:产物只供 Jenkins 浏览,可重建的中间文件与重复附件不再全量落盘。 */
function slimArchive({ archiveDir, htmlDir }) {
if (process.env.PW_JUNIT_COMPACT === '1' && existsSync(path.join(archiveDir, 'junit.xml'))) {
compactJunit(path.join(archiveDir, 'junit.xml'));
}
const resultsDir = path.join(archiveDir, 'allure-results');
pruneAllureBinaryAttachments(resultsDir, resolveKeepTraces());
const htmlBytes = existsSync(htmlDir) ? dirSizeBytes(htmlDir) : 0;
if (htmlBytes > 0) console.log(`[ci] HTML 体积 ${(htmlBytes / 1024 / 1024).toFixed(0)} MB`);
}
function mergeFlakeDirs(parts, dest) {
mkdirSync(dest, { recursive: true });
for (const dir of parts) {
......@@ -263,7 +430,11 @@ function buildBaseEnv(workers, retries, runFull, dirs) {
PW_RETRIES: retries,
RUN_FULL: runFull,
RUN_QUARANTINE: process.env.RUN_QUARANTINE || '0',
ALLURE: process.env.ALLURE === '0' ? '0' : '1',
// Allure 默认关:Allure 与 HTML 保存同一份 trace/截图,且 Allure 插件只读 latest/allure-results,
// 多出的归档副本没有消费方。需要时 ALLURE=1 打开。
ALLURE: process.env.ALLURE === '1' ? '1' : '0',
// JUnit 默认用紧凑版:Jenkins recordIssues 只读 message,不需要整段堆栈。
PW_JUNIT_COMPACT: process.env.PW_JUNIT_COMPACT ?? '1',
JUNIT: '1',
PLAYWRIGHT_BROWSERS_PATH:
process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(ROOT, '.playwright-browsers'),
......@@ -281,6 +452,7 @@ async function main() {
console.log(formatSuiteList());
process.exit(0);
}
KEEP_ARCHIVES_ARG = opts.keepArchives;
const suite = getSuite(opts.suite);
const buildId = resolveBuildId();
......@@ -452,7 +624,11 @@ async function main() {
finishFlakeReport(flakeDir);
const allureOk = process.env.ALLURE !== '0' && tryGenerateAllure(allureResultsDir, allureReportDir);
// 只保留一份 HTML + JUnit + summary 等文本产物;中间目录先删,再生成 Allure,避免附件重复落盘。
dropIntermediateDirs(archiveDir, blobDir);
slimArchive({ archiveDir, htmlDir });
const allureOk = process.env.ALLURE === '1' && tryGenerateAllure(allureResultsDir, allureReportDir);
const summary = {
suite: suite.name,
......@@ -482,7 +658,8 @@ async function main() {
mkdirSync(latestDir, { recursive: true });
copyIfExists(junitFile, path.join(latestDir, 'junit.xml'));
copyIfExists(htmlDir, path.join(latestDir, 'playwright-report'));
copyIfExists(allureResultsDir, path.join(latestDir, 'allure-results'));
// latest 只需要供 Jenkins 插件消费的报告文件;默认不开 Allure,也就不复制 allure-results。
if (allureOk) copyIfExists(allureResultsDir, path.join(latestDir, 'allure-results'));
if (allureOk) copyIfExists(allureReportDir, path.join(latestDir, 'allure-report'));
copyIfExists(path.join(archiveDir, 'summary.json'), path.join(latestDir, 'summary.json'));
copyIfExists(path.join(archiveDir, 'meta.txt'), path.join(latestDir, 'meta.txt'));
......@@ -501,6 +678,10 @@ async function main() {
console.log(`[ci] JUnit: ${path.relative(ROOT, junitFile)}`);
if (allureOk) console.log(`[ci] Allure: ${path.relative(ROOT, allureReportDir)}`);
console.log(`[ci] latest: reports/latest/`);
if (existsSync(archiveDir)) {
console.log(`[ci] 归档体积 ${(dirSizeBytes(archiveDir) / 1024 / 1024).toFixed(0)} MB`);
}
pruneArchives(archiveDir, resolveKeepArchives());
process.exit(exitCode);
}
......
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 准入管理 (6 pages).
* Saves data/scan/*-elements.json, then regenerates locators/spd/*.locator.yaml from that JSON.
*
* Usage:
* node scripts/scan-admit-batch.mjs
* node scripts/scan-admit-batch.mjs admit-material admit-supply
*/
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const MENUS = [
{ pageKey: 'admit-material', menu: '物料准入管理', pageTitle: '基础设置 > 准入管理 > 物料准入管理' },
{ pageKey: 'admit-product-apply', menu: '产品准入申请', pageTitle: '基础设置 > 准入管理 > 产品准入申请' },
{ pageKey: 'admit-material-list', menu: '物料准入单列表', pageTitle: '基础设置 > 准入管理 > 物料准入单列表' },
{ pageKey: 'admit-product-list', menu: '产品准入单列表', pageTitle: '基础设置 > 准入管理 > 产品准入单列表' },
{ pageKey: 'admit-supply', menu: '供货目录申请', pageTitle: '基础设置 > 准入管理 > 供货目录申请' },
{ pageKey: 'admit-task', menu: '准入任务分配', pageTitle: '基础设置 > 准入管理 > 准入任务分配' },
];
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await page.getByRole('button', { name: /登\s*录/ }).click();
await page.waitForURL(/\/spd\/home/, { timeout: 120_000 });
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
async function discoverMenuUrls(menus) {
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
storageState: AUTH_STATE,
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
});
const page = await context.newPage();
const urls = {};
for (const item of menus) {
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(1500);
if (page.url().includes('/login')) {
throw new Error('登录态失效,无法打开准入管理菜单');
}
await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 });
await page.waitForTimeout(500);
const admitTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^准入管理$/ }).first();
if (await admitTitle.isVisible().catch(() => false)) {
const expanded = await admitTitle.locator('xpath=..').evaluate((el) => el.classList.contains('ant-menu-submenu-open')).catch(() => false);
if (!expanded) await admitTitle.click({ timeout: 10_000 });
}
await page.locator('.ant-menu-item').filter({ hasText: new RegExp(`^\\s*${item.menu}\\s*$`) }).first().click({ timeout: 20_000 });
await page.waitForTimeout(2000);
const url = page.url().split('?')[0];
if (url.includes('/login') || url.endsWith('/spd/home/index')) {
throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`);
}
urls[item.pageKey] = url;
console.log(`URL ${item.menu}: ${url}`);
}
await browser.close();
return urls;
}
async function openFirstDropdown(page) {
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').first().waitFor({
state: 'visible',
timeout: 8_000,
}).catch(() => undefined);
await page.waitForTimeout(400);
return { dropdownOpened: true };
}
async function layerOpened(page) {
const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
if (modal) return true;
return page.locator('.put-page').first().isVisible().catch(() => false);
}
async function openNamedButton(page, pattern, flag) {
const btn = page.getByRole('button', { name: pattern }).first();
if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
await btn.click({ timeout: 12_000, force: true });
await page.locator('.ant-modal, .ant-drawer-open, .put-page').first().waitFor({ state: 'visible', timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(500);
const opened = await layerOpened(page);
return { [flag]: opened, skipRescan: !opened };
}
async function openRowAction(page, pattern, flag) {
const row = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
let action = row.getByRole('button', { name: pattern }).first();
if (!(await action.isVisible().catch(() => false))) {
action = row.locator('a,button,span').filter({ hasText: pattern }).first();
}
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
const before = page.url();
const hadDetail = await page.locator('.put-page').first().isVisible().catch(() => false);
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(800);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { [flag]: true, navigated: true, returnUrl: before };
const opened = (await layerOpened(page)) && (!hadDetail || await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false) || await page.locator('.put-page').first().isVisible().catch(() => false));
return { [flag]: Boolean(opened), skipRescan: !opened };
}
function pageCfg(targetUrl, item) {
return {
targetUrl,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-basic',
pageKey: item.pageKey,
pageTitle: item.pageTitle,
jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
jsonSourceName: `${item.pageKey}-elements.json`,
waitSelectors: ['.ant-form-item input', '.ant-form', '.ant-table', '.ant-btn'],
waitSelector: '.ant-form, .ant-table, .ant-btn',
waitUntil: 'domcontentloaded',
expandFirstRow: false,
openAdvancedFilter: false,
interactivePhases: [
openFirstDropdown,
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openNamedButton(page, /关联产品/, 'dialogOpened'),
(page) => openRowAction(page, /详\s*情/, 'detailOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /日\s*志/, 'logOpened'),
],
};
}
async function main() {
const filter = process.argv.slice(2);
const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS;
if (!menus.length) {
console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const urls = await discoverMenuUrls(menus);
const summary = [];
for (const item of menus) {
const targetUrl = urls[item.pageKey];
console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`);
try {
const result = await runScan(pageCfg(targetUrl, item));
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: item.pageKey,
pageTitle: item.pageTitle,
module: 'spd-basic',
jsonSourceName: `${item.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
});
summary.push({ page: item.pageKey, ok: true, ...result, yamlOut: yaml.yamlOut });
} catch (err) {
console.error(`FAILED ${item.pageKey}:`, err);
summary.push({ page: item.pageKey, ok: false, error: String(err) });
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 医院资料 (4 pages) from UI-SPEC BASIC-001~004.
* Multi-phase: list + dropdown/dialog/edit/confirm/upload/richtext.
*
* Usage:
* node scripts/scan-basic-batch.mjs
* node scripts/scan-basic-batch.mjs basic-notice basic-branch
*/
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const BASIC_BASE = `${BASE}/spd/basic/hospital`;
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const COMMON_WAIT = [
'.ant-form-item input',
'.ant-form',
'.ant-table',
'.ant-btn',
'#form_item_title',
'#form_item_settleMonthDate',
];
async function clickButton(content, pattern) {
const btn = content.getByRole('button', { name: pattern }).first();
if (await btn.isVisible().catch(() => false)) {
await btn.click({ timeout: 15_000 });
return true;
}
return false;
}
async function closeOverlays(page) {
const footerCancel = page.locator('.ant-modal .ant-modal-footer button').filter({ hasText: /取\s*消/ }).last();
if (await footerCancel.isVisible().catch(() => false)) {
await footerCancel.click({ timeout: 8_000 }).catch(() => undefined);
await page.locator('.ant-modal-wrap').first().waitFor({ state: 'hidden', timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(400);
}
for (let i = 0; i < 3; i += 1) {
const popCancel = page
.locator('.ant-popconfirm button, .ant-popover-open button, .ant-modal-confirm button')
.filter({ hasText: /取\s*消/ })
.last();
if (await popCancel.isVisible().catch(() => false)) {
await popCancel.click({ timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(300);
continue;
}
const modalClose = page.locator('.ant-modal-wrap .ant-modal-close').last();
if (await modalClose.isVisible().catch(() => false)) {
await modalClose.click({ timeout: 5_000 }).catch(() => undefined);
await page.locator('.ant-modal-wrap').first().waitFor({ state: 'hidden', timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(400);
continue;
}
break;
}
}
function firstDataRow(page) {
return page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
}
async function waitModal(content, titleRe, timeout = 15_000) {
const loc = titleRe
? content.locator('.ant-modal').filter({ has: content.locator('.ant-modal-title', { hasText: titleRe }) })
: content.locator('.ant-modal-wrap .ant-modal, .ant-modal').first();
await loc.first().waitFor({ state: 'visible', timeout }).catch(() => undefined);
return loc.first();
}
async function openAddModal(content, titleRe) {
const clicked = await clickButton(content, /新\s*增/);
if (!clicked) return { dialogOpened: false };
await waitModal(content, titleRe);
return { dialogOpened: true };
}
async function openPublishScopeDropdown(page) {
const select = page.locator('.ant-form-item').filter({ hasText: '发布范围' }).locator('.ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false };
await select.click({ timeout: 10_000 });
await page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').first().waitFor({
state: 'visible',
timeout: 10_000,
}).catch(() => undefined);
await page.waitForTimeout(400);
return { dropdownOpened: true };
}
async function openNoticeAddWithEditor(page) {
await closeOverlays(page);
const clicked = await clickButton(page, /新\s*增/);
if (!clicked) return { dialogOpened: false, richTextOpened: false };
await waitModal(page, /新增公告/);
await page.locator('.ant-modal .ql-editor, .ant-modal [contenteditable="true"]').first().waitFor({
state: 'visible',
timeout: 10_000,
}).catch(() => undefined);
await page.waitForTimeout(500);
const rich = await page.locator('.ant-modal .ql-editor').first().isVisible().catch(() => false);
return { dialogOpened: true, richTextOpened: Boolean(rich) };
}
async function openNoticeDeleteConfirm(page) {
await closeOverlays(page);
const del = firstDataRow(page).getByRole('button', { name: /删\s*除/ });
if (!(await del.isVisible().catch(() => false))) {
const fallback = page.getByRole('button', { name: /删\s*除/ }).first();
if (!(await fallback.isVisible().catch(() => false))) return { deleteConfirmOpened: false };
await fallback.click({ timeout: 10_000 });
} else {
await del.click({ timeout: 10_000 });
}
await page
.locator('.ant-popconfirm, .ant-popover-open, .ant-modal-confirm, .ant-modal')
.first()
.waitFor({ state: 'visible', timeout: 10_000 })
.catch(() => undefined);
await page.waitForTimeout(400);
return { deleteConfirmOpened: true };
}
async function openNoticeEditModal(page) {
await closeOverlays(page);
const edit = firstDataRow(page).getByRole('button', { name: /编\s*辑/ });
if (await edit.isVisible().catch(() => false)) {
await edit.click({ timeout: 10_000 });
} else {
const fallback = page.getByRole('button', { name: /编\s*辑/ }).first();
if (!(await fallback.isVisible().catch(() => false))) return { editDialogOpened: false };
await fallback.click({ timeout: 10_000 });
}
await waitModal(page, /编辑公告|新增公告|公告/);
await page.locator('.ant-modal .ql-editor, .ant-modal [contenteditable="true"]').first().waitFor({
state: 'visible',
timeout: 8_000,
}).catch(() => undefined);
await page.waitForTimeout(500);
return { editDialogOpened: true };
}
async function openHospitalEdit(page) {
const clicked = await clickButton(page, /编\s*辑/);
if (!clicked) return { editModeOpened: false };
await page.getByRole('button', { name: /保\s*存/ }).first().waitFor({ state: 'visible', timeout: 15_000 }).catch(() => undefined);
await page.waitForTimeout(400);
return { editModeOpened: true };
}
async function openFinanceMonthEdit(page) {
const clicked = await clickButton(page, /修\s*改/);
if (!clicked) return { editModeOpened: false };
await page
.locator('.ant-table-tbody .ant-picker, .ant-table-tbody input, .ant-table-tbody button')
.first()
.waitFor({ state: 'visible', timeout: 10_000 })
.catch(() => undefined);
await page.waitForTimeout(600);
return { editModeOpened: true };
}
async function openBranchEditModal(page) {
await closeOverlays(page);
const edit = firstDataRow(page).getByRole('button', { name: /编\s*辑/ });
if (await edit.isVisible().catch(() => false)) {
await edit.click({ timeout: 10_000 });
} else {
const fallback = page.getByRole('button', { name: /编\s*辑/ }).first();
if (!(await fallback.isVisible().catch(() => false))) return { editDialogOpened: false };
await fallback.click({ timeout: 10_000 });
}
await waitModal(page, /编辑院区/);
await page.waitForTimeout(500);
return { editDialogOpened: true };
}
function pageCfg(reportPath, rest) {
return {
targetUrl: `${BASIC_BASE}${reportPath}`,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-basic',
jsonOut: path.join(OUT_DIR, `${rest.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${rest.pageKey}.locator.yaml`),
jsonSourceName: `${rest.pageKey}-elements.json`,
waitSelectors: COMMON_WAIT,
waitSelector: '.ant-form-item input, .ant-table, .ant-btn, .ant-form',
expandFirstRow: false,
openAdvancedFilter: false,
skipQueryClick: rest.skipQueryClick ?? false,
...rest,
};
}
const PAGES = [
pageCfg('/paltform/notice', {
pageKey: 'basic-notice',
pageTitle: '基础设置 > 医院资料 > 公告管理',
interactivePhases: [
openPublishScopeDropdown,
openNoticeAddWithEditor,
openNoticeDeleteConfirm,
openNoticeEditModal,
],
}),
pageCfg('/paltform/info', {
pageKey: 'basic-hospital-info',
pageTitle: '基础设置 > 医院资料 > 医院信息登记',
skipQueryClick: true,
interactivePhases: [openHospitalEdit],
}),
pageCfg('/balance/month', {
pageKey: 'basic-finance-month',
pageTitle: '基础设置 > 医院资料 > 财务月份设置',
interactivePhases: [openFinanceMonthEdit],
}),
pageCfg('/paltform/area', {
pageKey: 'basic-branch',
pageTitle: '基础设置 > 医院资料 > 院区管理',
skipQueryClick: true,
interactivePhases: [
async (page) => {
await closeOverlays(page);
return openAddModal(page, /新增院区/);
},
openBranchEditModal,
],
}),
];
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await page.getByRole('button', { name: /登\s*录/ }).click();
await page.waitForURL(/\/spd\/home/, { timeout: 120_000 });
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
async function main() {
const filter = process.argv.slice(2);
const pages = filter.length ? PAGES.filter((p) => filter.includes(p.pageKey)) : PAGES;
if (!pages.length) {
console.error('No pages matched. Available:', PAGES.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const summary = [];
for (const page of pages) {
console.log(`\n=== Scanning ${page.pageKey} ===`);
try {
const result = await runScan(page);
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: page.pageKey,
pageTitle: page.pageTitle,
module: 'spd-basic',
jsonSourceName: `${page.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${page.pageKey}.locator.yaml`),
});
summary.push({ page: page.pageKey, ok: true, ...result, yamlOut: yaml.yamlOut });
} catch (err) {
console.error(`FAILED ${page.pageKey}:`, err);
summary.push({ page: page.pageKey, ok: false, error: String(err) });
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
......@@ -52,7 +52,9 @@ const SCAN_SCRIPT = `(() => {
'.ant-pagination-options-size-changer,.ant-pagination-options-quick-jumper input,' +
'.ant-table-column-sorter,.ant-dropdown-trigger,.ant-tabs-tab,.ant-drawer-close,' +
'.ant-modal-close,.advance-filter,.ant-cascader,.ant-drawer-footer .ant-btn,' +
'.ant-table-row-expand-icon,.ant-table-row-expand-icon-cell';
'.ant-table-row-expand-icon,.ant-table-row-expand-icon-cell,' +
// antd Tree:节点无 tabindex/role,checkbox 不是原生 input,需显式纳入
'.ant-tree-checkbox,.ant-tree-switcher,.ant-tree-title';
function isVisible(el) {
const style = window.getComputedStyle(el);
......@@ -68,6 +70,9 @@ const SCAN_SCRIPT = `(() => {
}
function labelFor(el) {
// 树节点(权限树 / 分类树)的语义来自自身文本,不能回溯到外层表单标签,
// 否则所有节点都会被打上同一个 label(如「角色名称」)而无法区分。
if (el.closest('.ant-tree')) return '';
if (el.id) {
const lbl = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
if (lbl) return norm(lbl.textContent);
......@@ -91,6 +96,9 @@ const SCAN_SCRIPT = `(() => {
if (el.classList.contains('ant-picker')) return 'datepicker';
if (el.classList.contains('ant-btn')) return 'button';
if (el.classList.contains('ant-checkbox-wrapper')) return 'checkbox';
if (el.classList.contains('ant-tree-checkbox')) return 'checkbox';
if (el.classList.contains('ant-tree-switcher')) return 'tree_switcher';
if (el.classList.contains('ant-tree-title')) return 'tree_title';
if (el.classList.contains('ant-radio-wrapper')) return 'radio';
if (el.classList.contains('ant-tabs-tab')) return 'tab';
if (el.classList.contains('advance-filter')) return 'filter_trigger';
......@@ -122,6 +130,35 @@ const SCAN_SCRIPT = `(() => {
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\\]^ \`{|}~])/g, '\\\\$1');
}
// 树节点祖先路径:用于生成可读且唯一的树定位器键(如 基础设置 > 医院资料 > 院区管理)。
// 注意:antd Tree 的 DOM 是扁平的(靠 level 缩进表示层级),不能靠 parentElement 回溯。
function treePathOf(el) {
const tree = el.closest('.ant-tree');
if (!tree) return null;
const titleNode = el.closest('.ant-tree-treenode') || (el.classList.contains('ant-tree-title') ? el.parentElement?.closest('.ant-tree-treenode') : null);
if (!titleNode) return null;
const all = [...tree.querySelectorAll('.ant-tree-treenode')];
const idx = all.indexOf(titleNode);
if (idx < 0) return null;
const levelOf = (n) => Number((n.className.match(/ant-tree-treenode-level-(\\d+)/) || [])[1] ?? 0);
const targetLevel = levelOf(titleNode);
const path = [];
const self = titleNode.querySelector('.ant-tree-title');
const selfText = self ? norm(self.textContent) : '';
if (!selfText) return null;
path.unshift(selfText);
let need = targetLevel - 1;
for (let i = idx - 1; i >= 0 && need >= 0; i -= 1) {
if (levelOf(all[i]) === need) {
const t = all[i].querySelector('.ant-tree-title');
const txt = t ? norm(t.textContent) : '';
if (txt) path.unshift(txt);
need -= 1;
}
}
return path;
}
function buildLocators(el, label, text, type) {
const locators = [];
const testId = el.getAttribute('data-testid') || el.getAttribute('data-test');
......@@ -164,18 +201,64 @@ const SCAN_SCRIPT = `(() => {
if (name && name.length <= 40) {
locators.push({ strategy: 'text', value: name, priority: 8 });
}
// 树节点:优先用树容器 + 节点文本精确定位,避免与页面同名菜单/行混淆
if (el.closest('.ant-tree') && text) {
const treeCls = el.closest('.ant-tree')?.className || '';
const treeSel = String(treeCls).includes('menu-role-tree') ? '.menu-role-tree' : '.ant-tree';
if (el.classList.contains('ant-tree-title')) {
locators.unshift({
strategy: 'tree_node',
value: text,
tree: treeSel,
priority: 1,
});
} else if (el.classList.contains('ant-tree-checkbox')) {
const nodeText = norm(el.closest('.ant-tree-treenode')?.querySelector('.ant-tree-title')?.textContent || '');
if (nodeText) {
locators.unshift({
strategy: 'tree_checkbox',
value: nodeText,
tree: treeSel,
priority: 1,
});
}
}
}
locators.sort((a, b) => a.priority - b.priority);
return locators;
}
function sectionOf(el) {
if (el.closest('.ant-pagination')) return 'pagination';
const drawer = el.closest('.ant-drawer-open, .ant-drawer');
const detail = el.closest('.put-page');
if (detail) {
const title = detail.querySelector('.ant-descriptions-title, .ant-page-header-heading-title');
const t = title ? norm(title.textContent) : '';
return t ? 'detail:' + t : 'detail';
}
const modal = el.closest('.ant-modal');
if (modal) {
const wrap = modal.closest('.ant-modal-wrap');
const hidden = wrap && (wrap.style.display === 'none' || wrap.classList.contains('ant-modal-wrap-hidden'));
if (!hidden) {
const title = modal.querySelector('.ant-modal-title');
const t = title ? norm(title.textContent) : '';
return t ? 'modal:' + t : 'modal';
}
}
const pop = el.closest('.ant-popconfirm, .ant-modal-confirm');
if (pop) return 'confirm';
const dd = el.closest('.ant-select-dropdown, .ant-picker-dropdown, .ant-dropdown');
if (dd && !dd.classList.contains('ant-select-dropdown-hidden') && !dd.classList.contains('ant-picker-dropdown-hidden') && !dd.classList.contains('ant-dropdown-hidden')) {
return 'dropdown';
}
const drawer = el.closest('.ant-drawer');
if (drawer && drawer.classList.contains('ant-drawer-open')) {
if (el.closest('.ant-drawer-footer')) return 'panel:高级筛选:footer';
const title = drawer.querySelector('.ant-drawer-title, .ant-modal-title');
if (title) return 'panel:' + norm(title.textContent);
if (el.closest('.ant-drawer-footer')) return 'panel:drawer:footer';
const title = drawer.querySelector('.ant-drawer-title');
if (title && norm(title.textContent)) return 'panel:' + norm(title.textContent);
return 'panel:drawer';
}
if (el.closest('.ant-pagination')) return 'pagination';
const tab = el.closest('.ant-tabs-tabpane-active');
if (tab) {
const tabBtn = document.querySelector('.ant-tabs-tab-active');
......@@ -231,6 +314,7 @@ const SCAN_SCRIPT = `(() => {
},
locators,
recommended: best,
treePath: treePathOf(el),
});
});
......@@ -291,6 +375,16 @@ function toPlaywrightExpr(loc, frameLabel) {
return `${prefix}.getByText('${loc.value.replace(/'/g, "\\'")}', { exact: true })`;
case 'text':
return `${prefix}.getByText('${loc.value.replace(/'/g, "\\'")}')`;
case 'tree_node': {
const tree = loc.tree || '.ant-tree';
const t = loc.value.replace(/'/g, "\\'");
return `${prefix}.locator('${tree} .ant-tree-title').filter({ hasText: /^\\s*${t}\\s*$/ })`;
}
case 'tree_checkbox': {
const tree = loc.tree || '.ant-tree';
const t = loc.value.replace(/'/g, "\\'");
return `${prefix}.locator('${tree} .ant-tree-treenode').filter({ hasText: /^\\s*${t}\\s*$/ }).locator('.ant-tree-checkbox')`;
}
case 'css':
return `${prefix}.locator('${loc.value.replace(/'/g, "\\'")}')`;
default:
......@@ -328,6 +422,49 @@ const FORM_ID_KEY_MAP = {
form_item_billDate: 'documentDateStart',
form_item_statPeriod: 'statPeriodStart',
form_item_goodsKind: 'consumableType',
// 手术套包设置(基础包维护 / 术种维护 / 标准套包维护)
table_search_form_keyword: 'productName',
table_search_form_goodsSpec: 'specModel',
table_search_form_goodsMfrsName: 'manufacturer',
table_search_form_operationName: 'operationName',
table_search_form_disease: 'diseaseName',
form_item_pkgName: 'basePkgName',
form_item_pkgCode: 'basePkgCode',
form_item_kindId: 'basePkgCategory',
form_item_kindName: 'basePkgCategoryName',
form_item_remark: 'remark',
basic_kindName: 'operTypeCategoryName',
// 医院资料 / 厂商 / 准入 / 用户
form_item_title: 'noticeTitle',
form_item_expireDate: 'expireDate',
form_item_provId: 'supplierId',
form_item_subProvId: 'subSupplierId',
table_search_form_provName: 'supplierName',
table_search_form_provId: 'supplierId',
table_search_form_dateValue: 'dateRange',
table_search_form_orderNo: 'documentNo',
table_search_form_purModes: 'purchaseMode',
form_item_goodsMfrsId: 'manufacturer',
form_item_goodsSpec: 'specModel',
form_item_twoHourFlag: 'twoHourArrival',
form_item_daysOfStockout: 'stockoutDays',
};
// 仅有 placeholder、无 label/id 的输入框(树搜索、弹窗内搜索等)
const PLACEHOLDER_KEY_MAP = {
请输入分类名称: 'categorySearchInput',
请输入术种分类名称: 'operTypeCategorySearchInput',
'请输入术种分类名称、术种名称、标准套包名称': 'stdPkgTreeSearchInput',
'请输入分类/基础包名称': 'addBasePkgSearchInput',
厂商名称: 'mfrsNameInput',
公告标题: 'noticeTitleInput',
院区名称: 'campusNameInput',
请输入院区名称: 'campusNameInput',
请输入方案编码: 'schemeCodeInput',
请输入指标编码: 'indicatorCodeInput',
请输入指标名称: 'indicatorNameInput',
'请输入名称/年度': 'nameOrYearInput',
'请输入产品编码、名称': 'productCodeNameInput',
};
function slugify(s) {
......@@ -402,12 +539,152 @@ function slugify(s) {
科室名称: 'dimensionDeptName',
产品编号名称: 'productName',
'产品编号/名称': 'productName',
// 手术套包设置
基础包维护: 'basePkgMaintenance',
术种维护: 'operTypeMaintenance',
标准套包维护: 'stdPkgMaintenance',
基础包名称: 'basePkgName',
包编码: 'basePkgCode',
所属分类: 'basePkgCategory',
分类名称: 'categoryName',
术种分类名称: 'operTypeCategoryName',
术种分类: 'operTypeCategory',
术种名称: 'operationName',
病种名称: 'diseaseName',
诊断编码: 'diagnosisCode',
诊断名称: 'diagnosisName',
操作编码: 'operationCode',
操作名称: 'operationName',
新增分类: 'addCategory',
新增基础包: 'addBasePkg',
新增术种: 'addOperType',
添加基础包: 'addBasePkg',
添加散件: 'addLoosePart',
添加产品: 'addProduct',
新增: 'add',
'新 增': 'add',
编辑: 'edit',
移除: 'remove',
停用: 'disable',
启用: 'enable',
保存: 'save',
'保 存': 'save',
取消: 'cancel',
'取 消': 'cancel',
确认: 'confirm',
'确 认': 'confirm',
确定: 'confirm',
'确定(0)': 'confirm',
关闭: 'close',
'关 闭': 'close',
返回: 'back',
'返 回': 'back',
展开: 'expandFilter',
收起: 'collapseFilter',
产品概览: 'productOverview',
停用Tab: 'tabDisabled',
未建分类: 'tabUncategorized',
详情: 'detail',
日志: 'log',
提交: 'submit',
'提 交': 'submit',
操作: 'action',
状态: 'status',
备注: 'remark',
内容: 'content',
年度: 'year',
变更类型: 'changeType',
截止日期: 'expireDate',
新增日期: 'createDate',
公告标题: 'noticeTitle',
发布日期: 'publishDate',
发布范围: 'publishScope',
厂商名称: 'mfrsName',
器械注册人: 'registrant',
经销商: 'distributor',
方案编码: 'schemeCode',
指标编号: 'indicatorCode',
二级供应商: 'subSupplier',
是否2小时到货: 'twoHourArrival',
授权链路效期预警: 'authExpiryWarn',
一致: 'matched',
不一致: 'mismatched',
// 基础设置子菜单
医院资料: 'hospitalInfo',
院区管理: 'branchManage',
公告管理: 'noticeManage',
财务月份设置: 'financeMonthSetting',
医院信息登记: 'hospitalInfoRegister',
厂商管理: 'vendorManage',
厂商查询: 'vendorQuery',
供应商资质管理: 'vendorQual',
授权变更初审: 'authFirst',
授权变更终审: 'authFinal',
注册证变更初审: 'certFirst',
注册证变更终审: 'certFinal',
注册证差异对比: 'certDiff',
评价方案维护: 'evalScheme',
评价指标维护: 'evalIndicator',
供应商节假日管理: 'vendorHoliday',
准入管理: 'admitManage',
物料准入管理: 'admitMaterial',
产品准入申请: 'admitProductApply',
物料准入单列表: 'admitMaterialList',
产品准入单列表: 'admitProductList',
供货目录申请: 'admitSupply',
准入任务分配: 'admitTask',
手术套包设置: 'surgerySetting',
用户管理: 'userManage',
医共体用户管理: 'unionUserManage',
角色管理: 'roleManage',
// 顶部主导航
首页: 'home',
基础设置: 'basicSetting',
采购管理: 'purchase',
期初管理: 'opening',
中心库管理: 'centerStock',
科室业务管理: 'deptBusiness',
财务管理: 'finance',
骨科带量: 'orthoVolume',
综合管理: 'general',
阳采管理: 'yangcai',
流程中心: 'processCenter',
后勤物资管理: 'logistics',
统计报表: 'statReport',
数据可视化: 'dataViz',
};
if (map[s]) return map[s];
if (map[normalized]) return map[normalized];
const ascii = s.match(/[a-zA-Z0-9_]+/g);
if (ascii?.length) return ascii.join('_').toLowerCase();
return `el_${Buffer.from(s).toString('hex').slice(0, 8)}`;
// 含汉字时不要抽中间的数字当键(否则「是否2小时到货」会塌成 Select/Input)
if (!/[\u4e00-\u9fff]/.test(normalized)) {
const ascii = s.match(/[a-zA-Z0-9_]+/g);
if (ascii?.length) return ascii.join('_').toLowerCase();
}
// 中文兜底:用完整 hex 而非前 8 位,避免「基础设置/医院资料…」等前缀相同导致键塌缩
return `el_${Buffer.from(normalized).toString('hex')}`;
}
function isNoiseElement(el) {
const rec = el.recommended || {};
const recVal = String(rec.value || el.label || '').trim();
const recText = String(el.text || '').trim();
// 图标类 aria-label(more / remove / Close 等):不稳定且无业务语义
if (rec.strategy === 'label' && /^(more|remove|close|delete|edit|info-circle|question-circle|down|up|left|right|increase value|decrease value)$/i.test(String(rec.value || ''))) {
return true;
}
// 纯图标按钮:无文案、无 placeholder、无 id
const meaninglessText = !el.text || /^(more|remove|close|delete)$/i.test(recText);
if (rec.strategy === 'label' && meaninglessText) return true;
if (/^(Increase Value|Decrease Value)$/i.test(recText)) return true;
// 表头排序器只保留一个(已在循环内处理);此处过滤无文案的排序 span
if (el.className?.includes('ant-table-column-sorter') && !el.text) return true;
// 顶部用户栏信息块(院区切换 / 消息数 / 用户名)- 非页面业务元素
if (/^中心库[((]/.test(recVal) && (rec.strategy === 'label_text' || el.className?.includes('ant-dropdown-trigger'))) {
return true;
}
if (rec.strategy === 'label_text' && /^(中心库|99\+|\d+)$/.test(recVal)) return true;
if (/^(99\+)$/.test(recText) && el.className?.includes('ant-dropdown-trigger')) return true;
return false;
}
function buildYaml(scan) {
......@@ -418,9 +695,14 @@ function buildYaml(scan) {
lines.push(`# Page: ${CONFIG.pageTitle}`);
if (scan.advancedFilterOpened) lines.push('# Note: advanced filter panel was opened during scan');
if (scan.rowExpanded) lines.push('# Note: first table row was expanded during scan');
if (scan.dropdownOpened) lines.push('# Note: filter dropdown was opened during scan');
if (scan.dialogOpened) lines.push('# Note: dialog/modal was opened during scan');
if (scan.editDialogOpened) lines.push('# Note: edit dialog was opened during scan');
if (scan.detailOpened) lines.push('# Note: detail view was opened during scan');
if (scan.logOpened) lines.push('# Note: log view was opened during scan');
lines.push('');
lines.push(`page: ${CONFIG.pageKey}`);
lines.push('module: spd-stat');
lines.push(`module: ${CONFIG.module || 'spd-stat'}`);
lines.push(`url: ${scan.url}`);
lines.push('');
lines.push('locators:');
......@@ -444,9 +726,17 @@ function buildYaml(scan) {
el.recommended = { strategy: 'css', value: '.ant-table-column-sorter', priority: 9 };
}
if (!el.recommended) continue;
// 过滤无业务价值的噪声元素(图标 aria-label、行内重复操作按钮等)
if (isNoiseElement(el)) continue;
const frameLabel = frameLabelFromUrl(el.frame);
let key = el.id && FORM_ID_KEY_MAP[el.id] ? FORM_ID_KEY_MAP[el.id] : null;
// 树节点:用祖先路径生成可读且唯一的键,避免所有节点塌缩成同一个 hex 前缀
const isTree = el.type === 'tree_title' || el.type === 'tree_switcher' || (el.type === 'checkbox' && el.treePath);
let key = isTree && el.treePath ? 'tree_' + el.treePath.map(slugify).join('_') : null;
if (!key && el.id && FORM_ID_KEY_MAP[el.id]) key = FORM_ID_KEY_MAP[el.id];
if (!key && el.placeholder && !el.label && !el.id) {
key = PLACEHOLDER_KEY_MAP[el.placeholder] || null;
}
const baseName =
el.label ||
el.text ||
......@@ -491,7 +781,24 @@ function buildYaml(scan) {
if (section.includes('panel:高级筛选')) key = `drawer_${key}`;
if (section.includes('pagination')) key = `pagination_${key}`;
let finalKey = key.replace(/^[^a-zA-Z_]/, '').replace(/[^a-zA-Z0-9_]/g, '_') || `el_${el.index}`;
let finalKey;
if (isTree && el.treePath?.length) {
// 树节点:键必须包含完整祖先路径,否则不同模块下的同名菜单(如「库存查询」)会撞键。
// 叶子若是英文菜单则用可读键,中文叶子用短 hex 后缀保证唯一且稳定。
const leaf = slugify(el.treePath[el.treePath.length - 1]);
const leafReadable = /^[a-z][a-z0-9_]*$/i.test(leaf) && !leaf.startsWith('el_');
const kind = el.type === 'checkbox' ? 'cb' : el.type === 'tree_switcher' ? 'sw' : 'node';
const pathHash = Buffer.from(el.treePath.join('>')).toString('hex').slice(0, 12);
finalKey = leafReadable ? `tree_${kind}_${leaf}_${pathHash}` : `tree_${kind}_${pathHash}`;
} else {
finalKey = key.replace(/^[^a-zA-Z_]/, '').replace(/[^a-zA-Z0-9_]/g, '_') || `el_${el.index}`;
}
// 表格行内重复的行操作按钮(编辑/移除/详情等)只保留一个,避免 editBtn_2/3...
const isRowAction =
(el.type === 'button' || el.tag === 'button' || el.className?.includes('ant-btn')) &&
/Btn$/.test(finalKey) &&
(el.section || '').includes('main');
if (isRowAction && usedKeys.has(finalKey)) continue;
let n = 2;
while (usedKeys.has(finalKey)) {
finalKey = `${key}_${n++}`;
......@@ -513,6 +820,10 @@ function buildYaml(scan) {
if (rec.role) lines.push(` role: ${rec.role}`);
if (rec.name) lines.push(` name: ${JSON.stringify(rec.name)}`);
if (rec.value) lines.push(` value: ${JSON.stringify(rec.value)}`);
if (rec.tree) lines.push(` tree: ${JSON.stringify(rec.tree)}`);
}
if (el.treePath?.length > 1) {
lines.push(` tree_path: ${JSON.stringify(el.treePath.join(' > '))}`);
}
if (pw) lines.push(` playwright: ${JSON.stringify(pw)}`);
lines.push('');
......@@ -659,10 +970,65 @@ async function clickQueryIfPresent(reportFrame) {
return false;
}
function fingerprint(el) {
return [el.section, el.type, el.id || '', el.role || '', el.label || '', el.text || '', el.placeholder || ''].join('|');
}
async function closeScanOverlays(page) {
for (let i = 0; i < 6; i += 1) {
const back = page
.locator('.put-page, .ant-drawer-open, .ant-modal-wrap, .ant-modal')
.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ })
.first();
if (await back.isVisible().catch(() => false)) {
await back.click({ timeout: 4_000, force: true }).catch(() => undefined);
await page.waitForTimeout(400);
continue;
}
const backText = page.locator('a,button,span').filter({ hasText: /^返\s*回$/ }).last();
if (await backText.isVisible().catch(() => false)) {
await backText.click({ timeout: 4_000, force: true }).catch(() => undefined);
await page.waitForTimeout(400);
continue;
}
const cancel = page
.locator('.ant-modal-footer button, .ant-modal-confirm button, .ant-popconfirm button, .ant-drawer-footer button')
.filter({ hasText: /取\s*消|关\s*闭/ })
.last();
if (await cancel.isVisible().catch(() => false)) {
await cancel.click({ timeout: 4_000, force: true }).catch(() => undefined);
await page.waitForTimeout(300);
continue;
}
const closer = page.locator('.ant-modal-wrap .ant-modal-close, .ant-drawer-close').last();
if (await closer.isVisible().catch(() => false)) {
await closer.click({ timeout: 4_000, force: true }).catch(() => undefined);
await page.waitForTimeout(300);
continue;
}
const closedByScript = await page.evaluate(() => {
const layer = document.querySelector('.put-page');
if (!layer) return false;
const btn = [...layer.querySelectorAll('button,a,span')].find((el) => /返\s*回|关\s*闭/.test((el.textContent || '').trim()));
if (!btn) return false;
btn.click();
return true;
}).catch(() => false);
if (closedByScript) {
await page.waitForTimeout(400);
continue;
}
await page.keyboard.press('Escape').catch(() => undefined);
break;
}
}
async function prepareReportPage(page) {
const reportFrame = page.frameLocator(`iframe[src*="${CONFIG.iframeMatch}"]`);
const reportFrame = CONFIG.useIframe === false
? page
: page.frameLocator(`iframe[src*="${CONFIG.iframeMatch}"]`);
const readySelector = await waitReportReady(reportFrame);
console.log(` iframe ready (${readySelector})`);
console.log(` content ready (${readySelector})`);
const meta = { queryExecuted: false, advancedFilterOpened: false, rowExpanded: false };
if (CONFIG.skipQueryClick !== true) {
......@@ -723,7 +1089,10 @@ export async function runScan(overrides = {}) {
const page = await context.newPage();
console.log('Navigating to', CONFIG.targetUrl);
await page.goto(CONFIG.targetUrl, { waitUntil: 'networkidle', timeout: 120_000 });
await page.goto(CONFIG.targetUrl, {
waitUntil: CONFIG.waitUntil || 'networkidle',
timeout: 120_000,
});
await page.waitForTimeout(5000);
const prepMeta = await prepareReportPage(page);
......@@ -731,6 +1100,43 @@ export async function runScan(overrides = {}) {
let scan = await scanPageOrFrames(page);
Object.assign(scan, prepMeta);
const seen = new Set(scan.elements.map(fingerprint));
const phases = Array.isArray(CONFIG.interactivePhases) ? CONFIG.interactivePhases : [];
for (const phase of phases) {
let meta = {};
try {
meta = (await phase(page)) || {};
} catch (err) {
console.warn(' phase failed:', err);
meta = {};
}
Object.assign(scan, meta);
if (!meta.skipRescan) {
const extra = await scanPageOrFrames(page);
for (const el of extra.elements) {
const key = fingerprint(el);
if (seen.has(key)) continue;
seen.add(key);
scan.elements.push(el);
}
scan.frames.push(...extra.frames);
}
if (meta.navigated && meta.returnUrl) {
await page.goto(meta.returnUrl, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(1500);
} else {
await closeScanOverlays(page);
}
}
scan.elements.forEach((el, i) => {
el.index = i + 1;
});
scan.elementCount = scan.elements.length;
delete scan.skipRescan;
delete scan.navigated;
delete scan.returnUrl;
scan.url = sanitizeUrl(scan.url);
for (const el of scan.elements) {
if (el.frame) el.frame = frameLabelFromUrl(el.frame);
......
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 手术套包设置 (3 pages) from UI-SPEC SURG-001~003.
* Saves data/scan/*-elements.json, then regenerates locators/spd/*.locator.yaml.
*
* Usage:
* node scripts/scan-surgery-batch.mjs
* node scripts/scan-surgery-batch.mjs surgery-base-pkg surgery-oper-type
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
const URL_MAP_OUT = path.join(OUT_DIR, 'surgery-menu-urls.json');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const MENUS = [
{ pageKey: 'surgery-base-pkg', menu: '基础包维护', pageTitle: '基础设置 > 手术套包设置 > 基础包维护', specId: 'SURG-001' },
{ pageKey: 'surgery-oper-type', menu: '术种维护', pageTitle: '基础设置 > 手术套包设置 > 术种维护', specId: 'SURG-002' },
{ pageKey: 'surgery-std-pkg', menu: '标准套包维护', pageTitle: '基础设置 > 手术套包设置 > 标准套包维护', specId: 'SURG-003' },
];
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await Promise.all([
page.waitForURL((url) => url.pathname.includes('/spd/home'), { timeout: 120_000 }),
page.getByRole('button', { name: /登\s*录/ }).click(),
]).catch(async () => {
for (let i = 0; i < 60; i += 1) {
if (page.url().includes('/spd/home')) return;
await page.waitForTimeout(1000);
}
throw new Error(`登录后未进入首页,当前 URL: ${page.url()}`);
});
await page.waitForTimeout(1500);
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
/** 展开 基础设置 > 手术套包设置 子菜单 */
async function expandSurgeryMenu(page) {
await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 });
await page.waitForTimeout(500);
const surgTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^手术套包设置$/ }).first();
if (await surgTitle.isVisible().catch(() => false)) {
const expanded = await surgTitle
.locator('xpath=..')
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!expanded) await surgTitle.click({ timeout: 10_000 });
await page.waitForTimeout(400);
}
}
async function discoverMenuUrls(menus) {
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
storageState: AUTH_STATE,
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
});
const page = await context.newPage();
const urls = {};
for (const item of menus) {
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(1500);
if (page.url().includes('/login')) {
throw new Error('登录态失效,无法打开手术套包设置菜单');
}
await expandSurgeryMenu(page);
await page
.locator('.ant-menu-item')
.filter({ hasText: new RegExp(`^\\s*${item.menu}\\s*$`) })
.first()
.click({ timeout: 20_000 });
await page.waitForTimeout(2000);
const url = page.url().split('?')[0];
if (url.includes('/login') || url.endsWith('/spd/home/index')) {
throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`);
}
urls[item.pageKey] = url;
console.log(`URL ${item.menu}: ${url}`);
}
await browser.close();
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(URL_MAP_OUT, JSON.stringify({ base: BASE, scannedAt: new Date().toISOString(), urls }, null, 2), 'utf8');
console.log('Wrote URL map:', URL_MAP_OUT);
return urls;
}
async function openFirstDropdown(page) {
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(400);
return { dropdownOpened: true };
}
async function layerOpened(page) {
const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
if (modal) return true;
return page.locator('.put-page').first().isVisible().catch(() => false);
}
async function openNamedButton(page, pattern, flag) {
const btn = page.getByRole('button', { name: pattern }).first();
if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
await btn.click({ timeout: 12_000, force: true });
await page
.locator('.ant-modal, .ant-drawer-open, .put-page')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(500);
const opened = await layerOpened(page);
return { [flag]: opened, skipRescan: !opened };
}
async function openRowAction(page, pattern, flag) {
const row = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
let action = row.getByRole('button', { name: pattern }).first();
if (!(await action.isVisible().catch(() => false))) {
action = row.locator('a,button,span').filter({ hasText: pattern }).first();
}
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
const before = page.url();
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(800);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { [flag]: true, navigated: true, returnUrl: before };
const opened = await layerOpened(page);
return { [flag]: Boolean(opened), skipRescan: !opened };
}
async function clickTab(page, name) {
const tab = page.locator('.ant-tabs-tab').filter({ hasText: new RegExp(name) }).first();
if (!(await tab.isVisible().catch(() => false))) return { tabOpened: false, skipRescan: true };
await tab.click({ timeout: 8_000 });
await page.waitForTimeout(600);
return { tabOpened: true };
}
/** 展开左侧分类树首个可展开节点,采集树中元素 */
async function expandFirstTreeNode(page) {
const switcher = page
.locator('.ant-tree-switcher:not(.ant-tree-switcher-noop), .ant-tree-treenode .ant-tree-switcher_open')
.first();
if (!(await switcher.isVisible().catch(() => false))) return { treeExpanded: false, skipRescan: true };
await switcher.click({ timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(600);
return { treeExpanded: true };
}
function pageCfg(targetUrl, item) {
return {
targetUrl,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-basic',
pageKey: item.pageKey,
pageTitle: item.pageTitle,
jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
jsonSourceName: `${item.pageKey}-elements.json`,
waitSelectors: ['.ant-tree', '.ant-form-item input', '.ant-form', '.ant-table', '.ant-btn', '.ant-tabs'],
waitSelector: '.ant-tree, .ant-form, .ant-table, .ant-btn, .ant-tabs',
waitUntil: 'domcontentloaded',
expandFirstRow: false,
openAdvancedFilter: false,
interactivePhases: [
openFirstDropdown,
expandFirstTreeNode,
(page) => clickTab(page, '停用'),
(page) => clickTab(page, '未建分类'),
(page) => openNamedButton(page, /新增基础包/, 'addBasePkgOpened'),
(page) => openNamedButton(page, /添加基础包/, 'addBasePkgOpened'),
(page) => openNamedButton(page, /添加散件/, 'addLoosePartOpened'),
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /详\s*情/, 'detailOpened'),
(page) => openRowAction(page, /停\s*用/, 'disableDialogOpened'),
],
};
}
async function main() {
const filter = process.argv.slice(2);
const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS;
if (!menus.length) {
console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const urls = await discoverMenuUrls(menus);
const summary = [];
for (const item of menus) {
const targetUrl = urls[item.pageKey];
console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`);
try {
const result = await runScan(pageCfg(targetUrl, item));
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: item.pageKey,
pageTitle: item.pageTitle,
module: 'spd-basic',
jsonSourceName: `${item.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
});
summary.push({
page: item.pageKey,
menu: item.menu,
specId: item.specId,
ok: true,
url: targetUrl,
...result,
yamlOut: yaml.yamlOut,
});
} catch (err) {
console.error(`FAILED ${item.pageKey}:`, err);
summary.push({ page: item.pageKey, menu: item.menu, specId: item.specId, ok: false, error: String(err) });
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements ${row.url}`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
writeFileSync(
path.join(OUT_DIR, 'surgery-scan-summary.json'),
JSON.stringify(summary, null, 2),
'utf8',
);
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 用户管理 (3 pages) from UI-SPEC USER-001~003.
* 直接按 UI-SPEC 中已确认的页面路径打开(无需从菜单点进去,避免同名菜单歧义)。
*
* 输出:
* data/scan/basic-user-elements.json 用户管理
* data/scan/basic-user-union-elements.json 医共体用户管理
* data/scan/basic-role-elements.json 角色管理
* locators/spd/{pageKey}.locator.yaml 由 JSON 提炼的 Playwright 定位器
*
* Usage:
* node scripts/scan-user-batch.mjs
* node scripts/scan-user-batch.mjs basic-user basic-role
*
* 只重算 YAML(不重新扫描):
* node scripts/json-to-locator-yaml.mjs data/scan/basic-user-elements.json
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
const SUMMARY_OUT = path.join(OUT_DIR, 'user-scan-summary.json');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const COMMON_WAIT = [
'.ant-form-item input',
'.ant-form-item .ant-select',
'.ant-form',
'.ant-table',
'.ant-btn',
'.ant-pagination',
];
async function closeOverlays(page) {
const footerCancel = page
.locator('.ant-modal .ant-modal-footer button, .ant-drawer .ant-drawer-footer button')
.filter({ hasText: /取\s*消/ })
.last();
if (await footerCancel.isVisible().catch(() => false)) {
await footerCancel.click({ timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(400);
}
for (let i = 0; i < 3; i += 1) {
const popCancel = page
.locator('.ant-popconfirm button, .ant-popover-open button, .ant-modal-confirm button')
.filter({ hasText: /取\s*消/ })
.last();
if (await popCancel.isVisible().catch(() => false)) {
await popCancel.click({ timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(300);
continue;
}
const modalClose = page
.locator('.ant-modal-wrap .ant-modal-close, .ant-drawer-close')
.last();
if (await modalClose.isVisible().catch(() => false)) {
await modalClose.click({ timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(400);
continue;
}
break;
}
}
/** 打开页面上第一个筛选下拉,采集下拉面板内的选项元素 */
async function openFirstFilterDropdown(page) {
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(500);
return { dropdownOpened: true };
}
/** 展开「高级筛选」面板(用户管理 / 医共体用户管理) */
async function openAdvancedFilter(page) {
await closeOverlays(page);
const btn = page.getByText(/高级筛选/).first();
if (!(await btn.isVisible().catch(() => false))) return { advancedFilterOpened: false, skipRescan: true };
await btn.click({ timeout: 10_000 }).catch(() => undefined);
await page.waitForTimeout(600);
return { advancedFilterOpened: true };
}
/** 打开「新增」弹窗(按标题断言,避免采到别的弹窗) */
async function openAddModal(content, titleRe) {
await closeOverlays(content);
const btn = content.getByRole('button', { name: /新\s*增/ }).first();
if (!(await btn.isVisible().catch(() => false))) return { dialogOpened: false, skipRescan: true };
await btn.click({ timeout: 15_000, force: true });
await content
.locator('.ant-modal, .ant-drawer-open')
.filter({ hasText: titleRe })
.first()
.waitFor({ state: 'visible', timeout: 15_000 })
.catch(() => undefined);
await content.waitForTimeout(800);
const opened = await content.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
return { dialogOpened: opened, skipRescan: !opened };
}
function firstDataRow(page) {
return page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
}
/** 点击首行的某个行操作,记录是弹窗还是跳转 */
async function openRowAction(page, pattern, flag) {
await closeOverlays(page);
const row = firstDataRow(page);
if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
let action = row.getByRole('button', { name: pattern }).first();
if (!(await action.isVisible().catch(() => false))) {
action = row.locator('a,button,span').filter({ hasText: pattern }).first();
}
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
const before = page.url();
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(900);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { [flag]: true, navigated: true, returnUrl: before };
const opened = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
return { [flag]: Boolean(opened), skipRescan: !opened };
}
/** 角色管理:点击左侧角色列表的首个角色,采集右侧权限树 */
async function selectFirstRole(page) {
await closeOverlays(page);
const row = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
const target = (await row.isVisible().catch(() => false))
? row.locator('td').first()
: page.locator('.ant-list-item, .role-list li').first();
if (!(await target.isVisible().catch(() => false))) return { roleSelected: false, skipRescan: true };
await target.click({ timeout: 10_000 }).catch(() => undefined);
await page.waitForTimeout(900);
return { roleSelected: true };
}
/** 角色管理:展开权限树的一个二级模块 */
async function expandFirstPermissionNode(page) {
const switcher = page
.locator('.ant-tree-switcher:not(.ant-tree-switcher-noop), .ant-tree-switcher_open')
.first();
if (!(await switcher.isVisible().catch(() => false))) return { treeExpanded: false, skipRescan: true };
await switcher.click({ timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(700);
return { treeExpanded: true };
}
function pageCfg(uiPath, rest) {
return {
targetUrl: `${BASE}${uiPath}`,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-basic',
jsonOut: path.join(OUT_DIR, `${rest.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${rest.pageKey}.locator.yaml`),
jsonSourceName: `${rest.pageKey}-elements.json`,
waitSelectors: COMMON_WAIT,
waitSelector: '.ant-form-item input, .ant-table, .ant-btn, .ant-form',
waitUntil: 'domcontentloaded',
expandFirstRow: false,
openAdvancedFilter: false,
skipQueryClick: true,
...rest,
};
}
const PAGES = [
pageCfg('/spd/basic/user/platform/person', {
pageKey: 'basic-user',
pageTitle: '基础设置 > 用户管理 > 用户管理',
specId: 'USER-001',
interactivePhases: [
openFirstFilterDropdown,
openAdvancedFilter,
(page) => openAddModal(page, /新增用户|新增/),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /授\s*权/, 'authDialogOpened'),
],
}),
pageCfg('/spd/basic/user/platform/personCom', {
pageKey: 'basic-user-union',
pageTitle: '基础设置 > 用户管理 > 医共体用户管理',
specId: 'USER-002',
interactivePhases: [
openFirstFilterDropdown,
openAdvancedFilter,
(page) => openAddModal(page, /新增/),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /授\s*权/, 'authDialogOpened'),
],
}),
pageCfg('/spd/basic/user/platform/role', {
pageKey: 'basic-role',
pageTitle: '基础设置 > 用户管理 > 角色管理',
specId: 'USER-003',
interactivePhases: [
selectFirstRole,
expandFirstPermissionNode,
(page) => openAddModal(page, /新增/),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
],
}),
];
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const { chromium } = await import('playwright');
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await page.getByRole('button', { name: /登\s*录/ }).click();
await page.waitForURL(/\/spd\/home/, { timeout: 120_000 });
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
async function main() {
const filter = process.argv.slice(2);
const pages = filter.length ? PAGES.filter((p) => filter.includes(p.pageKey)) : PAGES;
if (!pages.length) {
console.error('No pages matched. Available:', PAGES.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const summary = [];
for (const item of pages) {
console.log(`\n=== Scanning ${item.pageKey} ${item.targetUrl} ===`);
try {
const result = await runScan(item);
// 扫描阶段会顺带写一份 YAML;这里再按落盘 JSON 提炼一次,确保 YAML 只由 JSON 决定
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: item.pageKey,
pageTitle: item.pageTitle,
module: 'spd-basic',
jsonSourceName: `${item.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
});
summary.push({
page: item.pageKey,
specId: item.specId,
ok: true,
url: item.targetUrl,
elementCount: result.elementCount,
jsonOut: result.jsonOut,
yamlOut: yaml.yamlOut,
});
} catch (err) {
console.error(`FAILED ${item.pageKey}:`, err);
summary.push({ page: item.pageKey, specId: item.specId, ok: false, error: String(err) });
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(SUMMARY_OUT, JSON.stringify(summary, null, 2), 'utf8');
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 厂商管理 (10 pages) from UI-SPEC VENDOR-001~010.
* Saves data/scan/*-elements.json, then regenerates locators/spd/*.locator.yaml.
*
* Usage:
* node scripts/scan-vendor-batch.mjs
* node scripts/scan-vendor-batch.mjs vendor-query vendor-qual
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
const URL_MAP_OUT = path.join(OUT_DIR, 'vendor-menu-urls.json');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const MENUS = [
{ pageKey: 'vendor-query', menu: '厂商查询', pageTitle: '基础设置 > 厂商管理 > 厂商查询', specId: 'VENDOR-001' },
{ pageKey: 'vendor-qual', menu: '供应商资质管理', pageTitle: '基础设置 > 厂商管理 > 供应商资质管理', specId: 'VENDOR-002' },
{ pageKey: 'vendor-auth-first', menu: '授权变更初审', pageTitle: '基础设置 > 厂商管理 > 授权变更初审', specId: 'VENDOR-003' },
{ pageKey: 'vendor-auth-final', menu: '授权变更终审', pageTitle: '基础设置 > 厂商管理 > 授权变更终审', specId: 'VENDOR-004' },
{ pageKey: 'vendor-cert-first', menu: '注册证变更初审', pageTitle: '基础设置 > 厂商管理 > 注册证变更初审', specId: 'VENDOR-005' },
{ pageKey: 'vendor-cert-final', menu: '注册证变更终审', pageTitle: '基础设置 > 厂商管理 > 注册证变更终审', specId: 'VENDOR-006' },
{ pageKey: 'vendor-cert-diff', menu: '注册证差异对比', pageTitle: '基础设置 > 厂商管理 > 注册证差异对比', specId: 'VENDOR-007' },
{ pageKey: 'vendor-eval-scheme', menu: '评价方案维护', pageTitle: '基础设置 > 厂商管理 > 评价方案维护', specId: 'VENDOR-008' },
{ pageKey: 'vendor-eval-indicator', menu: '评价指标维护', pageTitle: '基础设置 > 厂商管理 > 评价指标维护', specId: 'VENDOR-009' },
{ pageKey: 'vendor-holiday', menu: '供应商节假日管理', pageTitle: '基础设置 > 厂商管理 > 供应商节假日管理', specId: 'VENDOR-010' },
];
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await Promise.all([
page.waitForURL((url) => url.pathname.includes('/spd/home'), { timeout: 120_000 }),
page.getByRole('button', { name: /登\s*录/ }).click(),
]).catch(async () => {
// 部分环境导航事件不触发 waitForURL,轮询兜底
for (let i = 0; i < 60; i += 1) {
if (page.url().includes('/spd/home')) return;
await page.waitForTimeout(1000);
}
throw new Error(`登录后未进入首页,当前 URL: ${page.url()}`);
});
await page.waitForTimeout(1500);
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
async function expandVendorMenu(page) {
await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 });
await page.waitForTimeout(500);
const vendorTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^厂商管理$/ }).first();
if (await vendorTitle.isVisible().catch(() => false)) {
const expanded = await vendorTitle
.locator('xpath=..')
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!expanded) await vendorTitle.click({ timeout: 10_000 });
}
}
async function discoverMenuUrls(menus) {
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
storageState: AUTH_STATE,
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
});
const page = await context.newPage();
const urls = {};
for (const item of menus) {
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(1500);
if (page.url().includes('/login')) {
throw new Error('登录态失效,无法打开厂商管理菜单');
}
await expandVendorMenu(page);
await page
.locator('.ant-menu-item')
.filter({ hasText: new RegExp(`^\\s*${item.menu}\\s*$`) })
.first()
.click({ timeout: 20_000 });
await page.waitForTimeout(2000);
const url = page.url().split('?')[0];
if (url.includes('/login') || url.endsWith('/spd/home/index')) {
throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`);
}
urls[item.pageKey] = url;
console.log(`URL ${item.menu}: ${url}`);
}
await browser.close();
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(URL_MAP_OUT, JSON.stringify({ base: BASE, scannedAt: new Date().toISOString(), urls }, null, 2), 'utf8');
console.log('Wrote URL map:', URL_MAP_OUT);
return urls;
}
async function openFirstDropdown(page) {
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(400);
return { dropdownOpened: true };
}
async function layerOpened(page) {
const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
if (modal) return true;
return page.locator('.put-page').first().isVisible().catch(() => false);
}
async function openNamedButton(page, pattern, flag) {
const btn = page.getByRole('button', { name: pattern }).first();
if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
await btn.click({ timeout: 12_000, force: true });
await page
.locator('.ant-modal, .ant-drawer-open, .put-page')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(500);
const opened = await layerOpened(page);
return { [flag]: opened, skipRescan: !opened };
}
async function openRowAction(page, pattern, flag) {
const row = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
let action = row.getByRole('button', { name: pattern }).first();
if (!(await action.isVisible().catch(() => false))) {
action = row.locator('a,button,span').filter({ hasText: pattern }).first();
}
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
const before = page.url();
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(800);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { [flag]: true, navigated: true, returnUrl: before };
const opened = await layerOpened(page);
return { [flag]: Boolean(opened), skipRescan: !opened };
}
async function clickTab(page, name) {
const tab = page.locator('.ant-tabs-tab').filter({ hasText: new RegExp(name) }).first();
if (!(await tab.isVisible().catch(() => false))) return { tabOpened: false, skipRescan: true };
await tab.click({ timeout: 8_000 });
await page.waitForTimeout(600);
return { tabOpened: true };
}
function pageCfg(targetUrl, item) {
return {
targetUrl,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-basic',
pageKey: item.pageKey,
pageTitle: item.pageTitle,
jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
jsonSourceName: `${item.pageKey}-elements.json`,
waitSelectors: ['.ant-form-item input', '.ant-form', '.ant-table', '.ant-btn', '.ant-tabs'],
waitSelector: '.ant-form, .ant-table, .ant-btn, .ant-tabs',
waitUntil: 'domcontentloaded',
expandFirstRow: false,
openAdvancedFilter: false,
interactivePhases: [
openFirstDropdown,
(page) => clickTab(page, '经销商'),
(page) => clickTab(page, '授权链路效期预警'),
(page) => clickTab(page, '不一致'),
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openNamedButton(page, /导\s*出/, 'exportOpened'),
(page) => openRowAction(page, /详\s*情/, 'detailOpened'),
(page) => openRowAction(page, /查\s*看/, 'viewOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /日\s*志/, 'logOpened'),
],
};
}
async function main() {
const filter = process.argv.slice(2);
const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS;
if (!menus.length) {
console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const urls = await discoverMenuUrls(menus);
const summary = [];
for (const item of menus) {
const targetUrl = urls[item.pageKey];
console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`);
try {
const result = await runScan(pageCfg(targetUrl, item));
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: item.pageKey,
pageTitle: item.pageTitle,
module: 'spd-basic',
jsonSourceName: `${item.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
});
summary.push({
page: item.pageKey,
menu: item.menu,
specId: item.specId,
ok: true,
url: targetUrl,
...result,
yamlOut: yaml.yamlOut,
});
} catch (err) {
console.error(`FAILED ${item.pageKey}:`, err);
summary.push({ page: item.pageKey, menu: item.menu, specId: item.specId, ok: false, error: String(err) });
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements ${row.url}`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
writeFileSync(
path.join(OUT_DIR, 'vendor-scan-summary.json'),
JSON.stringify(summary, null, 2),
'utf8',
);
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 库房管理 / 流程设置 (UI-SPEC WH-001~002, FLOW-001~002).
* 先按菜单点击解析真实 URL,再扫描交互元素。
*
* 输出:
* data/scan/warehouse-zone-elements.json 库区设置
* data/scan/warehouse-probe-elements.json 探针绑定
* data/scan/flow-def-elements.json 审批流程设置
* data/scan/flow-inst-elements.json 审批流实例
* locators/spd/{pageKey}.locator.yaml
*
* Usage:
* node scripts/scan-wh-flow-batch.mjs
* node scripts/scan-wh-flow-batch.mjs warehouse-zone warehouse-probe
* node scripts/scan-wh-flow-batch.mjs flow-def flow-inst
*
* 只重算 YAML(不重新扫描):
* node scripts/json-to-locator-yaml.mjs --all-warehouse --all-flow
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
const URL_MAP_OUT = path.join(OUT_DIR, 'wh-flow-menu-urls.json');
const SUMMARY_OUT = path.join(OUT_DIR, 'wh-flow-scan-summary.json');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const COMMON_WAIT = [
'.ant-form-item input',
'.ant-form-item .ant-select',
'.ant-form',
'.ant-table',
'.ant-btn',
'.ant-pagination',
'.ant-list-item',
'.ant-menu-item',
];
const MENUS = [
{
pageKey: 'warehouse-zone',
group: '库房管理',
menu: '库区设置',
pageTitle: '基础设置 > 库房管理 > 库区设置',
specId: 'WH-001',
skipQueryClick: true,
interactivePhases: [
selectLeftFirstItem,
openFirstFilterDropdown,
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /人员管理/, 'staffDialogOpened'),
(page) => openRowAction(page, /货位管理/, 'locationDialogOpened'),
],
},
{
pageKey: 'warehouse-probe',
group: '库房管理',
menu: '探针绑定',
pageTitle: '基础设置 > 库房管理 > 探针绑定',
specId: 'WH-002',
skipQueryClick: false,
interactivePhases: [
openFirstFilterDropdown,
(page) => openRowAction(page, /绑定货位/, 'bindDialogOpened'),
openModalFirstDropdown,
],
},
{
pageKey: 'flow-def',
group: '流程设置',
menu: '审批流程设置',
pageTitle: '基础设置 > 流程设置 > 审批流程设置',
specId: 'FLOW-001',
skipQueryClick: true,
interactivePhases: [
selectLeftFirstItem,
openFirstFilterDropdown,
(page) => openNamedButton(page, /新增流程/, 'addFlowClicked'),
(page) => openInlineEdit(page, /编\s*辑/, 'conditionEditorOpened'),
],
},
{
pageKey: 'flow-inst',
group: '流程设置',
menu: '审批流实例',
pageTitle: '基础设置 > 流程设置 > 审批流实例',
specId: 'FLOW-002',
skipQueryClick: false,
interactivePhases: [
openFirstFilterDropdown,
openSecondFilterDropdown,
openFlowDetail,
],
},
];
async function closeOverlays(page) {
await page.keyboard.press('Escape').catch(() => undefined);
await page.waitForTimeout(200);
const footerCancel = page
.locator('.ant-modal .ant-modal-footer button, .ant-drawer .ant-drawer-footer button')
.filter({ hasText: /取\s*消|关\s*闭/ })
.last();
if (await footerCancel.isVisible().catch(() => false)) {
await footerCancel.click({ timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(400);
}
for (let i = 0; i < 3; i += 1) {
const popCancel = page
.locator('.ant-popconfirm button, .ant-popover-open button, .ant-modal-confirm button')
.filter({ hasText: /取\s*消/ })
.last();
if (await popCancel.isVisible().catch(() => false)) {
await popCancel.click({ timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(300);
continue;
}
const modalClose = page.locator('.ant-modal-wrap .ant-modal-close, .ant-drawer-close').last();
if (await modalClose.isVisible().catch(() => false)) {
await modalClose.click({ timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(400);
continue;
}
break;
}
}
function firstDataRow(page) {
return page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
}
async function layerOpened(page) {
const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
if (modal) return true;
return page.locator('.put-page').first().isVisible().catch(() => false);
}
async function openFirstFilterDropdown(page) {
await closeOverlays(page);
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(500);
return { dropdownOpened: true };
}
async function openSecondFilterDropdown(page) {
await closeOverlays(page);
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').nth(1);
if (!(await select.isVisible().catch(() => false))) return { secondDropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(400);
return { secondDropdownOpened: true };
}
async function openModalFirstDropdown(page) {
const modal = page.locator('.ant-modal:visible, .ant-drawer-open').first();
if (!(await modal.isVisible().catch(() => false))) return { modalDropdownOpened: false, skipRescan: true };
const select = modal.locator('.ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { modalDropdownOpened: false, skipRescan: true };
await select.click({ timeout: 8_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(400);
return { modalDropdownOpened: true };
}
async function selectLeftFirstItem(page) {
await closeOverlays(page);
const candidates = [
page.locator('.ant-tree .ant-tree-title').first(),
page.locator('.ant-menu-inline .ant-menu-item:not(.ant-menu-item-selected)').first(),
page.locator('.ant-list-item').first(),
page.locator('.ant-table-tbody tr.ant-table-row td').first(),
];
for (const loc of candidates) {
if (await loc.isVisible().catch(() => false)) {
await loc.click({ timeout: 10_000 }).catch(() => undefined);
await page.waitForTimeout(800);
return { leftItemSelected: true };
}
}
return { leftItemSelected: false, skipRescan: true };
}
async function openNamedButton(page, pattern, flag) {
await closeOverlays(page);
const btn = page.getByRole('button', { name: pattern }).first();
if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
await btn.click({ timeout: 12_000, force: true });
await page
.locator('.ant-modal, .ant-drawer-open, .put-page')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(600);
const opened = await layerOpened(page);
return { [flag]: opened, skipRescan: !opened };
}
async function openRowAction(page, pattern, flag) {
await closeOverlays(page);
const row = firstDataRow(page);
if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
let action = row.getByRole('button', { name: pattern }).first();
if (!(await action.isVisible().catch(() => false))) {
action = row.locator('a,button,span').filter({ hasText: pattern }).first();
}
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
const before = page.url();
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(1600);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { [flag]: true, navigated: true, returnUrl: before };
const opened = await layerOpened(page);
return { [flag]: Boolean(opened), skipRescan: !opened };
}
/** 审批流实例「详情」多为链接/文字按钮,且可能被下拉遮挡 */
async function openFlowDetail(page) {
await closeOverlays(page);
const row = firstDataRow(page);
const candidates = [
row.getByRole('button', { name: /详\s*情/ }).first(),
row.getByRole('link', { name: /详\s*情/ }).first(),
row.locator('a,button,span').filter({ hasText: /详\s*情/ }).first(),
page.locator('.ant-table-tbody a, .ant-table-tbody button, .ant-table-tbody span').filter({ hasText: /详\s*情/ }).first(),
];
let action = null;
for (const loc of candidates) {
if (await loc.isVisible().catch(() => false)) {
action = loc;
break;
}
}
if (!action) return { detailOpened: false, skipRescan: true };
const before = page.url();
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(1600);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { detailOpened: true, navigated: true, returnUrl: before };
const opened = await layerOpened(page);
return { detailOpened: Boolean(opened), skipRescan: !opened };
}
async function openInlineEdit(page, pattern, flag) {
await closeOverlays(page);
const action = page.locator('a,button,span').filter({ hasText: pattern }).first();
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(700);
const opened = await layerOpened(page);
const picker = await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden), .ant-picker-dropdown, .ant-popover-open')
.first()
.isVisible()
.catch(() => false);
return { [flag]: opened || picker, skipRescan: !(opened || picker) };
}
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await Promise.all([
page.waitForURL((url) => url.pathname.includes('/spd/home'), { timeout: 120_000 }),
page.getByRole('button', { name: /登\s*录/ }).click(),
]).catch(async () => {
for (let i = 0; i < 60; i += 1) {
if (page.url().includes('/spd/home')) return;
await page.waitForTimeout(1000);
}
throw new Error(`登录后未进入首页,当前 URL: ${page.url()}`);
});
await page.waitForTimeout(1500);
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
async function expandGroupMenu(page, groupName) {
await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 });
await page.waitForTimeout(500);
const title = page.locator('.ant-menu-submenu-title').filter({ hasText: new RegExp(`^${groupName}$`) }).first();
if (await title.isVisible().catch(() => false)) {
const expanded = await title
.locator('xpath=..')
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!expanded) await title.click({ timeout: 10_000 });
await page.waitForTimeout(400);
}
}
async function discoverMenuUrls(menus) {
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
storageState: AUTH_STATE,
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
});
const page = await context.newPage();
const urls = {};
for (const item of menus) {
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(1500);
if (page.url().includes('/login')) {
throw new Error(`登录态失效,无法打开${item.group}菜单`);
}
await expandGroupMenu(page, item.group);
await page
.locator('.ant-menu-item')
.filter({ hasText: new RegExp(`^\\s*${item.menu}\\s*$`) })
.first()
.click({ timeout: 20_000 });
await page.waitForTimeout(2000);
const url = page.url().split('?')[0];
if (url.includes('/login') || url.endsWith('/spd/home/index')) {
throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`);
}
urls[item.pageKey] = url;
console.log(`URL ${item.menu}: ${url}`);
}
await browser.close();
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(URL_MAP_OUT, JSON.stringify({ base: BASE, scannedAt: new Date().toISOString(), urls }, null, 2), 'utf8');
console.log('Wrote URL map:', URL_MAP_OUT);
return urls;
}
function pageCfg(targetUrl, item) {
return {
targetUrl,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-basic',
pageKey: item.pageKey,
pageTitle: item.pageTitle,
jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
jsonSourceName: `${item.pageKey}-elements.json`,
waitSelectors: COMMON_WAIT,
waitSelector: '.ant-form-item input, .ant-table, .ant-btn, .ant-form, .ant-list-item',
waitUntil: 'domcontentloaded',
expandFirstRow: false,
openAdvancedFilter: false,
skipQueryClick: item.skipQueryClick === true,
interactivePhases: item.interactivePhases,
};
}
async function main() {
const filter = process.argv.slice(2);
const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS;
if (!menus.length) {
console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const urls = await discoverMenuUrls(menus);
const summary = [];
for (const item of menus) {
const targetUrl = urls[item.pageKey];
console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`);
try {
const result = await runScan(pageCfg(targetUrl, item));
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: item.pageKey,
pageTitle: item.pageTitle,
module: 'spd-basic',
jsonSourceName: `${item.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
});
summary.push({
page: item.pageKey,
menu: item.menu,
specId: item.specId,
ok: true,
url: targetUrl,
elementCount: result.elementCount,
jsonOut: result.jsonOut,
yamlOut: yaml.yamlOut,
});
} catch (err) {
console.error(`FAILED ${item.pageKey}:`, err);
summary.push({
page: item.pageKey,
menu: item.menu,
specId: item.specId,
ok: false,
error: String(err),
});
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements ${row.url}`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(SUMMARY_OUT, JSON.stringify(summary, null, 2), 'utf8');
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
#!/usr/bin/env python3
"""将 docs/统计报表-* 截图同步至 knowledge-base/08-UI专项规范库。"""
"""将 docs/ 下页面截图同步至 knowledge-base/08-UI专项规范库(统计报表、准入、厂商、用户、库房、流程等)。"""
from __future__ import annotations
......@@ -13,7 +13,7 @@ DOCS = ROOT / "docs"
KB = ROOT / "knowledge-base" / "08-UI专项规范库"
TODAY = date.today().isoformat()
PAGES: list[dict] = [
STAT_PAGES: list[dict] = [
# 对账报表
{"id": "RECON-001", "group": "对账报表", "name": "科室对账查询", "folder": "统计报表-对账报表-科室对账查询"},
{"id": "RECON-002", "group": "对账报表", "name": "科室净入汇总表", "folder": "统计报表-对账报表-科室净入汇总表"},
......@@ -79,12 +79,716 @@ PAGES: list[dict] = [
{"id": "REAG-001", "group": "试剂批号统计", "name": "试剂批号统计明细", "folder": "统计报表-试剂批号统计-试剂批号统计明细"},
]
# 基础设置 → 准入管理(docs/基础管理-准入管理-*)
ADMIT_PAGES: list[dict] = [
{
"id": "ADMIT-001",
"name": "物料准入管理",
"folder": "基础管理-准入管理-物料准入管理",
"filters": "新增日期(区间)、进院供应商、耗材类型、产品名称、规格型号、注册证号、器械注册人",
"status_tabs": "全部、待申请、审批中、已通过、已驳回、已作废、生效失败、已生效",
"toolbar": "**关联产品**、**生成申请单**、**一键生成**、**作废**;列设置",
"list_note": "含准入类型(新品入院/产品增规/C码变更等)、产品编号/名称、规格型号、器械注册人、耗材类型、默认进院供应商;行操作 **详情**、**日志**、**编辑**(视状态)",
},
{
"id": "ADMIT-002",
"name": "产品准入申请",
"folder": "基础管理-准入管理-产品准入申请",
"filters": "以截图为准(常见:日期、供应商、产品/规格、单号等)",
"status_tabs": "以截图状态 Tab 为准",
"toolbar": "以截图工具栏为准",
"list_note": "产品准入申请列表与行内操作以截图为准",
},
{
"id": "ADMIT-003",
"name": "物料准入单列表",
"folder": "基础管理-准入管理-物料准入单列表",
"filters": "以截图为准",
"status_tabs": "以截图状态 Tab 为准",
"toolbar": "以截图工具栏为准",
"list_note": "物料准入单列表、详情/审批相关操作以截图为准",
},
{
"id": "ADMIT-004",
"name": "产品准入单列表",
"folder": "基础管理-准入管理-产品准入单列表",
"filters": "以截图为准",
"status_tabs": "以截图状态 Tab 为准",
"toolbar": "以截图工具栏为准",
"list_note": "产品准入单列表与行内操作以截图为准",
},
{
"id": "ADMIT-005",
"name": "供货目录申请",
"folder": "基础管理-准入管理-供货目录申请",
"filters": "制单日期(区间)、进院供应商、二级供应商、变更单号、产品名称、规格型号",
"status_tabs": "全部、待提交、审批中、已通过、已驳回",
"toolbar": "**批量提交**、**新增**;列设置",
"list_note": "申请单号、申请人、申请时间、进院/二级供应商、业务模式、是否默认、备注;行操作 **编辑**、**详情**",
},
{
"id": "ADMIT-006",
"name": "准入任务分配",
"folder": "基础管理-准入管理-准入任务分配",
"filters": "以截图为准",
"status_tabs": "以截图为准(若有)",
"toolbar": "以截图工具栏为准",
"list_note": "准入任务分配列表与分配操作以截图为准",
},
]
# 基础设置 → 厂商管理(docs/基础管理-厂商管理-*)
VENDOR_PAGES: list[dict] = [
{
"id": "VENDOR-001",
"name": "厂商查询",
"folder": "基础管理-厂商管理-厂商查询",
"filters": "厂商名称(搜索框)",
"status_tabs": "器械注册人、经销商",
"toolbar": "搜索、列设置、刷新/导出(以截图图标为准)",
"list_note": "列:序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号;底部分页",
"extra_imgs": 1,
},
{
"id": "VENDOR-002",
"name": "供应商资质管理",
"folder": "基础管理-厂商管理-供应商资质管理",
"filters": "随证件 Tab 变化(如进院供应商名称、证件类型;授权链路:授权书名称/授权单位/被授权单位)",
"status_tabs": (
"证件 Tab:证件效期提醒、授权链路效期预警、供应商三证、器械注册人、器械注册证号、"
"授权书、承诺书、法人委托书、二级供应商三证;效期快捷:近三个月到期、近一个月到期、已过期(带计数)"
),
"toolbar": "**导出** / **导出明细**(视 Tab);列设置",
"list_note": "证件效期提醒列含数据来源、进院供应商、有效期至、证件名称/类型、效期提醒、查看;授权链路预警列含授权单位、授权书名称、被授权单位、效期起止、效期提醒、查看",
"extra_imgs": 8,
},
{
"id": "VENDOR-003",
"name": "授权变更初审",
"folder": "基础管理-厂商管理-授权变更初审",
"filters": "制单日期(区间)、单号、进院供应商、预转供应商、状态、变更类型、原供应商、医院ID、医院名称",
"status_tabs": "无独立状态 Tab(状态下拉筛选)",
"toolbar": "查询、重置、收起/展开",
"list_note": "列:进院供应商名称、单号、状态、制单时间、制单人、变更类型、原供应商、预转供应商;行操作 **详情**",
},
{
"id": "VENDOR-004",
"name": "授权变更终审",
"folder": "基础管理-厂商管理-授权变更终审",
"filters": "制单日期(区间)、单号、进院供应商、预转供应商、状态、变更类型、原供应商",
"status_tabs": "无独立状态 Tab(状态下拉筛选)",
"toolbar": "查询、重置、收起/展开",
"list_note": "列与初审类似:进院供应商名称、单号、状态、制单时间、制单人、变更类型、原/预转供应商;行操作 **详情**",
},
{
"id": "VENDOR-005",
"name": "注册证变更初审",
"folder": "基础管理-厂商管理-注册证变更初审",
"filters": "制单日期(区间)、单号、变更类型、注册证号(新)、状态、变更情况具体说明",
"status_tabs": "无独立状态 Tab(状态下拉筛选)",
"toolbar": "查询、重置、收起/展开",
"list_note": "列:进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新);行操作 **详情**",
},
{
"id": "VENDOR-006",
"name": "注册证变更终审",
"folder": "基础管理-厂商管理-注册证变更终审",
"filters": "制单日期(区间)、单号、变更类型、注册证号(新)、状态",
"status_tabs": "无独立状态 Tab(状态下拉筛选)",
"toolbar": "查询、重置、收起/展开",
"list_note": "列与初审类似:进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新);行操作 **详情**",
},
{
"id": "VENDOR-007",
"name": "注册证差异对比",
"folder": "基础管理-厂商管理-注册证差异对比",
"filters": (
"物料名称、规格型号、状态、望海注册证号、是否医疗器械、生产厂家、FLI+注册证号、"
"27位医保编码;可选「包含FLI+长期有效数据」"
),
"status_tabs": "全部、一致、不一致",
"toolbar": "**导出**;数据更新时间 + **立即更新**;列设置",
"list_note": (
"对比望海 vs FLI+:物料编号/名称、规格型号、生产厂家、27位医保编码、"
"两侧注册证号/生效日期/有效期至;宽表横向滚动"
),
},
{
"id": "VENDOR-008",
"name": "评价方案维护",
"folder": "基础管理-厂商管理-评价方案维护",
"filters": "创建日期(区间)、启用日期(区间)、方案编码、方案名称",
"status_tabs": "无",
"toolbar": "**新增**;列设置",
"list_note": "列:方案编码、方案名称、创建人/时间、启用人/时间、停用人/时间、状态、备注、操作",
},
{
"id": "VENDOR-009",
"name": "评价指标维护",
"folder": "基础管理-厂商管理-评价指标维护",
"filters": "左侧指标分类树搜索;右侧:指标编码、指标名称",
"status_tabs": "无(左树 + 右列表)",
"toolbar": "左树 **新增**;右表 **新增**;列设置",
"list_note": "左:指标分类树(可编辑);右列:评价类型、指标编码、指标名称、状态;行操作 **编辑**、**详情**、**日志**",
},
{
"id": "VENDOR-010",
"name": "供应商节假日管理",
"folder": "基础管理-厂商管理-供应商节假日管理",
"filters": "左侧:名称/年度搜索",
"status_tabs": "无(左列表 + 右详情)",
"toolbar": "左:**新增**;右:供应商自定义列表 **新增**、**批量删除**",
"list_note": (
"左:供应商节假日设置(年度、名称、编辑);右上:默认假日详情(名称/年度/起止/创建人时间);"
"右下:供应商自定义节假日(进院供应商编码/名称、起止、备注、创建人时间、操作)"
),
},
]
# 基础设置 → 用户管理(docs/基础管理-用户管理-*)
USER_PAGES: list[dict] = [
{
"id": "USER-001",
"name": "用户管理",
"folder": "基础管理-用户管理-用户管理",
"path": "/spd/basic/user/platform/person",
"tab_title": "用户管理",
"layout": "整页单表:顶部筛选区 + 工具栏 + 用户列表 + 底部分页(无左侧组织树)",
"filters": [
("姓名", "文本", "占位「姓名、登录账号」"),
("科室", "下拉(可搜索)", "请选择"),
("角色", "下拉(可搜索)", "请选择"),
],
"actions": "**重置**、**高级筛选**(`下高级筛选` 收起/展开);本页截图未见独立「查询」按钮",
"toolbar": "页签右侧 **新增用户** 图标按钮(按实际文案定位)",
"columns": [
("序号", "行号(可勾选)"),
("姓名", "用户真实姓名"),
("登录账号", "登录用户名"),
("联系方式", "手机号/电话,可能为非法占位值(如 `1`、`0922`)"),
("所属科室", "科室名称,未分配显示 `--`"),
("医院员工编号", "工号,未填显示 `--`"),
("角色", "已分配角色,多角色截断显示(如 `内网医院初始化模板角色 (…`)"),
("科室任职数", "该用户在各科室的任职条数,无则 `--`"),
("操作", "**编辑**、**授权**、**重置密码**"),
],
"row_ops": "**编辑**、**授权**、**重置密码**",
"paging": "「共 **142** 条」、页码 `1 2 3 >`、**50 条/页**、**跳至 N 页**",
"scan_key": "basic-user",
"po_hint": "pages/spd/basic-user.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '用户管理', exact: true }).click()` 存在歧义(一级菜单名同为「用户管理」),建议直接 `openPath('/spd/basic/user/platform/person')` 并断言 URL。",
"本页**无「所属医院」筛选/列**;与医共体用户管理页列顺序不同,勿复用同一套列断言。",
"行操作 **授权**、**重置密码** 均为写操作,须先确认当前行。",
],
"points": [
"姓名/科室/角色组合筛选后列表条数与「共 N 条」一致;重置后恢复默认。",
"「角色」多角色截断显示(如 `内网医院初始化模板角色 (…`),编辑页应能查看全部角色。",
"「科室任职数」与用户在各科室任职记录条数一致,无任职显示 `--`。",
"新增/编辑:必填为空拦截;取消不保存;编辑回填与原值一致。",
"授权弹窗保存后重新打开应保留勾选;取消不生效。",
],
},
{
"id": "USER-002",
"name": "医共体用户管理",
"folder": "基础管理-用户管理-医共体用户管理",
"path": "/spd/basic/user/platform/personCom",
"tab_title": "医共体用户管理",
"layout": "整页单表,结构与「用户管理」一致,差异在引入**所属医院**维度(医共体多院区统一查看)",
"filters": [
("姓名", "文本", "占位「姓名、登录账号」"),
("所属医院", "下拉(可搜索)", "默认已带医院,如 `邹城市人民医院(test)`"),
("联系方式", "文本", "占位「联系方式」"),
],
"actions": "**重置**、**高级筛选**;本页截图未见独立「查询」按钮",
"toolbar": "页签右侧 **新增** 图标按钮(按实际文案定位)",
"columns": [
("序号", "行号(可勾选)"),
("登录账号", "列宽较窄,长账号截断显示(如 `zhon.…`、`jiang.…`)"),
("姓名", "用户真实姓名"),
("联系方式", "手机号/电话,可能为非法占位值"),
("所属医院", "医共体下所属医院,如 `邹城市人民医院(test)`"),
("所属科室", "科室名称,未分配显示 `--`"),
("医院员工编号", "工号,未填 `--`"),
("角色", "已分配角色,多角色截断显示"),
("科室任职数", "截断表头(截图显示 `科'`),与用户管理页同义"),
("操作", "**编辑**、**授权**、**重置密码**"),
],
"row_ops": "**编辑**、**授权**、**重置密码**",
"paging": "「共 **142** 条」、页码 `1 2 3 >`、**50 条/页**、**跳至 N 页**(与用户管理页数据量一致,两页为同一用户池)",
"scan_key": "basic-user-union",
"po_hint": "pages/spd/basic-user-union.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '医共体用户管理', exact: true }).click()`;或 `openPath('/spd/basic/user/platform/personCom')` 断言 URL。",
"**勿与用户管理页混用列断言**:本页为 `登录账号 → 姓名 → 联系方式 → 所属医院 → 所属科室 → 医院员工编号 → 角色 → 科室任职数 → 操作`,用户管理页为 `姓名 → 登录账号 → …`(无「所属医院」)。",
"「登录账号」列窄,`cellText` 取到的是截断文本;按账号搜索/断言应使用筛选框而非列文本。",
"写操作须先定位当前行,医共体下避免误改其他院区用户。",
],
"points": [
"切换「所属医院」后列表只显示该院区用户,「共 N 条」同步变化。",
"两页同一用户的关键字段(姓名、登录账号、角色、所属科室)一致,仅多出「所属医院」维度。",
"姓名 + 联系方式组合筛选精确命中;重置恢复默认。",
"写操作(编辑/授权/重置密码)须先定位当前行,医共体下避免误改其他院区用户。",
],
},
{
"id": "USER-003",
"name": "角色管理",
"folder": "基础管理-用户管理-角色管理",
"path": "/spd/basic/user/platform/role",
"tab_title": "",
"layout": (
"左右分栏:左侧角色列表,右侧「菜单权限」勾选树(应用 `SPD内网系统PC端` → 模块 → 菜单/功能,三级结构)"
),
"filters": [
("角色名称", "文本(左侧列表顶部)", "占位含「角色」,输入即过滤"),
],
"actions": "左侧搜索框实时过滤;右侧权限树按模块展开勾选;底部 **收起导航** 为全局导航按钮,非本页功能",
"toolbar": "左侧角色行内 **编辑** / 删除类图标按钮(右上角图标按钮按实际文案定位)",
"columns": [
("角色名称", "左列主键;示例:医院管理员、中心库库管、护士、护士长、库管、测试01、test、验收-herp、验收-ict、角色测试123"),
("操作", "行内 **编辑** / 删除类图标按钮"),
],
"row_ops": "**编辑** / 删除类图标按钮;右侧为菜单权限树(非行操作)",
"paging": "左列底部 **收起导航**(全局按钮);未见独立分页控件",
"scan_key": "basic-role",
"po_hint": "pages/spd/basic-role.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '角色管理', exact: true }).click()`;或 `openPath('/spd/basic/user/platform/role')` 断言 URL。",
"左右分栏:先断言角色列表与权限树均可见;选中角色后再断言右侧勾选状态已刷新(避免读到上一个角色的树)。",
"权限树按模块展开,断言时优先定位「模块名 + 菜单名」层级,避免全局文本匹配到多份同名菜单。",
"授权保存属写操作:测试账号权限变更会影响其他用例,执行前记录原勾选,结束后还原。",
],
"points": [
"角色搜索过滤结果与关键字匹配;清空后恢复全量。",
"切换角色后右侧菜单权限勾选与该角色已配置权限一致(无串选/残留)。",
"父节点勾选/取消后子节点全选/全不选;部分勾选时父节点为半选态。",
"保存授权后重新进入页面,勾选状态持久化;取消不保存。",
"角色增删改后,用户管理页「角色」下拉与用户行「角色」列同步更新。",
],
"modules": [
("基础设置", "医院资料、医院信息登记、院区管理、公告管理、财务月份设置"),
(
"运维管理",
"菜单控制规则设置、医院配置、菜单受控列表、院区配置、科室配置、术语配置、资源模板设置、"
"字典管理、打印机配置、编码规则管理、PDA授权、PDA版本管理、缓存监控、操作日志、"
"消息通知模板、消息通知设置、功能注册、任务执行记录查询",
),
(
"厂商管理",
"厂商查询、供应商资质管理、授权变更初审、注册证变更初审、注册证变更终审、授权变更终审、"
"注册证差异对比、评价方案维护、评价指标维护、供应商节假日管理",
),
(
"产品管理",
"产品核对重新发起、产品维护、物料信息管理、产品初审核对、产品供应关系维护、产品物价核对、"
"院区产品设置、院区单元包设置、中心库上下限建议(AI)、请领出库渠道设置、结算点设置、"
"调价管理、产品信息变更、变更单明细查询、外围系统产品新增、外围系统产品变更管理、产品查询 等",
),
],
},
]
# 基础设置 → 库房管理(docs/基础管理-库房管理-*)
# 菜单另有「华清通道」,本次无截图,不建规范。
WH_PAGES: list[dict] = [
{
"id": "WH-001",
"group": "库房管理",
"name": "库区设置",
"folder": "基础管理-库房管理-库区设置",
"path": "/spd/basic/storehouse/platform/storehouse/area",
"siblings": "**库区设置** / **探针绑定**",
"tab_title": "库区设置",
"layout": "左右分栏:左侧「库房信息」树,右侧为当前库房下的库区列表",
"filters": [
("库房名称", "文本(左侧树顶部)", "占位「库房名称」,过滤库房树"),
("库区编号/名称", "文本(右侧列表右上)", "占位「库区编号/名称」,搜索图标触发"),
],
"actions": "左侧 **同步**;右侧搜索图标检索,另有刷新与列设置。截图未见独立「查询」按钮",
"toolbar": "右侧 **新增**",
"columns": [
("库区编号", "如 `A001`、`B001`、`C001`、`E001`、`0807`"),
("库区名称", "如「中心库A区」;默认库区在名称旁带绿色「默认库区」标签(截图为中心库B区)"),
("库区地址", "自由文本,可与编号或名称相同"),
("是否专项库区", "是 / 否"),
("操作", "**编辑**、**人员管理**、**货位管理**、**停用**或**启用**"),
],
"row_ops": "**编辑**、**人员管理**、**货位管理**、**停用** / **启用**",
"paging": "右侧「共 **5** 条」、页码 `1`、**50 条/页**(条数随所选库房变化)",
"scan_key": "warehouse-zone",
"po_hint": "pages/spd/warehouse-zone.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '库区设置', exact: true }).click()`;或 `openPath('/spd/basic/storehouse/platform/storehouse/area')` 并断言 URL。",
"先点选左侧库房,再断言右侧列表;切换库房后列表须刷新,避免读到上一库房的库区。",
"**停用** / **启用**、**同步**、**新增** 均为写操作。默认库区是名称旁标签,不是独立列。",
"「人员管理」「货位管理」会离开当前列表(弹层或子页),本次无截图,断言前先确认面板标题。",
],
"points": [
"左侧库房关键字过滤只命中名称匹配的节点;清空后恢复全量树。",
"切换库房后,右侧库区编号/名称/地址属于该库房,「共 N 条」同步变化。",
"「库区编号/名称」搜索同时覆盖编号与名称;无命中时列表为空且分页为 0。",
"同一库房仅一个「默认库区」标签;专项库区「是/否」与编辑回填一致。",
"停用后该行操作变为「启用」,再次进入仍保持;启用行显示「停用」。",
],
"extra_md": """
### 左侧库房树
标题 **库房信息**,操作 **同步**。截图节点示例:光谷院区中心库房(当前选中)、洪山院区中心库房、1205院区中心库房、结算院区中心库房、回归测试院区中心库房、千泉山院区中心库房、马王堆院区中心库房、主院区中心库房、岳麓山院区中心库房、数据检查院区中心库房、东土城院区中心库房、智能院区中心库房、自动采购/请领建议专用院区中心库房。
选中库房后,右侧列表只展示该库房的库区。
""",
},
{
"id": "WH-002",
"group": "库房管理",
"name": "探针绑定",
"folder": "基础管理-库房管理-探针绑定",
"path": "/spd/basic/storehouse/storehouse/probe",
"siblings": "**库区设置** / **探针绑定**",
"tab_title": "探针绑定",
"shot_note": "`UI-SPEC-SPD-WH-002/page-main.png`、`page-main-2.png`(货位绑定弹窗)",
"layout": "整页单表:顶部筛选 + 探针列表;行内「绑定货位」打开「货位绑定探针」弹窗",
"filters": [
("探针ID", "文本", "占位「请输入」"),
("主机编码", "文本", "占位「请输入」"),
("是否绑定货位", "下拉", "占位「请选择」(已绑定 / 未绑定)"),
],
"actions": "**查询**、**重置**",
"toolbar": "列设置(截图未见新增按钮)",
"columns": [
("序号", "行号"),
("探针ID", "如 `360001`–`360011`"),
("主机编码", "如 `X233300001`,多条探针可共用同一主机"),
("是否绑定货位", "已绑定 / 未绑定"),
("绑定的货位", "已绑定货位数量(如 `28`、`3`、`2`);未绑定为 `0`"),
("操作", "**绑定货位**"),
],
"row_ops": "**绑定货位**(打开货位选择弹窗)",
"paging": "主列表「共 **11** 条」、**50 条/页**(以当前环境为准)",
"scan_key": "warehouse-probe",
"po_hint": "pages/spd/warehouse-probe.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '探针绑定', exact: true }).click()`;或 `openPath('/spd/basic/storehouse/storehouse/probe')` 并断言 URL。",
"「绑定的货位」是数量,不是货位号;货位号在弹窗「货位号」列。",
"弹窗有独立筛选与分页(截图「共 2626 条」),不要用弹窗总条数断言主列表。",
"弹窗 **确定** 会改绑定关系,按钮文案带已选数量(如 `确定(0)`);冒烟只打开后 **取消**。",
],
"points": [
"探针ID、主机编码、是否绑定货位组合查询后,列表与「共 N 条」一致;重置恢复默认。",
"「已绑定」行的绑定数量大于 0,「未绑定」行数量为 0。",
"打开「绑定货位」后弹窗标题为「货位绑定探针」,关闭后主列表勾选与筛选条件不丢失。",
"弹窗内按当前库房/库区/货位号筛选,结果列与条件一致;未选货位时确定按钮计数为 0。",
"同一货位行若已有探针ID,再次勾选前需确认是换绑还是追加(以保存后的主列表数量为准)。",
],
"extra_md": """
### 货位绑定探针弹窗
![探针绑定-货位绑定探针](UI-SPEC-SPD-WH-002/page-main-2.png)
行内 **绑定货位** 打开。筛选与主列表相互独立。
| 字段 | 控件 | 占位/默认值 |
|------|------|-------------|
| 当前库房 | 下拉 | 可带默认库房,如「千泉山院区中心…」 |
| 当前库区 | 下拉 | 空 |
| 货位查询 | 文本 | 占位「请输入」 |
| 货位状态 | 下拉 | 空 |
| 默认货位 | 下拉 | 空 |
- 操作:**查询**、**重置**
| 列 | 说明 |
|----|------|
| 勾选 | 多选;底部 **确定(N)** 的 N 为已选货位数 |
| 货位号 | 如 `001-01-01-01`、`001-ZNG001-01-01` |
| 库区默认货位 | 是 / 否 |
| 当前库房 | 如「千泉山院区中心库房」 |
| 当前库区 | 如「千泉山院区中心库库区1」 |
| 状态 | 启用等 |
| 探针ID | 已绑定显示探针号(如 `360003`),未绑定 `--` |
弹窗分页截图为「共 **2626** 条」、**50 条/页**、**跳至 N 页**。底部 **取消**、**确定(0)**。
""",
},
]
# 基础设置 → 流程设置(docs/基础管理-流程设置-*)
FLOW_PAGES: list[dict] = [
{
"id": "FLOW-001",
"group": "流程设置",
"name": "审批流程设置",
"folder": "基础管理-流程设置-审批流程设置",
"path": "/spd/basic/flowSet/workflow/flowSet",
"siblings": "**审批流程设置** / **审批流实例**",
"tab_title": "审批流程设置",
"layout": "左右分栏:左侧业务类型列表,右侧为该类型的默认审批流与自定义审批流",
"filters": [
("流程名称", "文本(左侧列表顶部)", "占位「流程名称」,过滤业务类型"),
],
"actions": "左侧搜索过滤类型;右侧 **新增流程**、底部 **保存** / **重置**",
"toolbar": "自定义审批流区域右上 **新增流程**",
"columns": [
("流程名称", "必填;自定义流名称,截图示例 `1`、`2`"),
("条件", "必填;行内 **编辑**。示例:「是否超限:是」「科室:中心库(洪山)」"),
("审批流程", "必填;节点卡片(节点类型、账号、人员)+ **+** 追加节点"),
("操作", "**删除**"),
],
"row_ops": "条件列 **编辑**;审批节点 **+**;行 **删除**",
"paging": "左侧为业务类型全量列表,未见分页;右侧自定义流按行展示,未见分页",
"scan_key": "flow-def",
"po_hint": "pages/spd/flow-def.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '审批流程设置', exact: true }).click()`;或 `openPath('/spd/basic/flowSet/workflow/flowSet')` 断言 URL。与「审批流实例」区分,使用 exact。",
"先选中左侧业务类型,再读右侧默认流与自定义流;切换类型后两侧都要刷新,避免串用上一类型的节点。",
"**保存** 会改变线上审批路由,冒烟只断言设计器结构,不提交;改完须 **重置** 或恢复原配置。",
"节点卡片含「节点 / 账号 / 人员」三行,断言时按卡片容器取文本,避免全局匹配到同名人员。",
],
"points": [
"流程名称关键字只保留匹配的业务类型;清空后恢复全量。",
"切换业务类型后,默认审批流节点与自定义审批流行与该类型已保存配置一致。",
"自定义流「流程名称 / 条件 / 审批流程」为空时保存被拦截;删除行后未点保存不落库。",
"重置放弃未保存改动,重新进入与上次保存结果一致。",
"默认流与自定义流互不覆盖:只改自定义行时,默认审批人节点保持不变。",
],
"extra_md": """
### 左侧业务类型
截图可见类型(采购计划为当前选中,列表可继续向下滚动):采购计划、结算、调价、产品信息变更、请领单、领用申请单、跟台手术申请单、试剂性能验证、退库出库单、临采任务、二级库调拨、备货计划、产品准入、供货目录申请、移库申请单、物料准入、退货出库拣货、产品DI变更、科室消耗退回、限量任务、科室领用退库出库单。
### 默认审批流
标题 **默认审批流**。审批人节点卡片示例:节点「运营主管」、账号 `test_1`、人员「蒋华」,卡片右侧 **+** 追加节点。未配置自定义流时,单据走该默认链。
### 自定义审批流
标题 **自定义审批流**。列头「流程名称」「条件」「审批流程」带必填标记。节点示例:
| 行 | 条件 | 节点 |
|----|------|------|
| 1 | 是否超限:是 | 指定用户 / `cgzxtest01` / 采购中心管理员01 |
| 2 | 科室:中心库(洪山) | 指定用户 / `qizhenjun` / 齐-超管 |
底部 **保存**、**重置**。
""",
},
{
"id": "FLOW-002",
"group": "流程设置",
"name": "审批流实例",
"folder": "基础管理-流程设置-审批流实例",
"path": "/spd/basic/flowSet/workflow/flowCase",
"siblings": "**审批流程设置** / **审批流实例**",
"tab_title": "审批流实例",
"layout": "整页单表:顶部筛选 + 审批单据列表 + 底部分页",
"filters": [
("单据编号", "文本", "占位「单据编号」"),
("审批状态", "下拉", "空;列表可见「审批通过」「审批中」"),
("发起时间", "日期区间", "截图示例 `2026-08-21` → `2026-10-20`(约两个月,结束日为当天)"),
("业务类型", "下拉", "空;与审批流程设置左侧类型同源"),
("摘要", "文本", "占位「摘要」"),
],
"actions": "**查询**、**重置**",
"toolbar": "列设置(截图未见新增、导出)",
"columns": [
("序号", "行号"),
("单据编号", "前缀随业务类型变化:`PLAN` 采购计划、`DB` 二级库调拨、`GC` 产品信息变更、`BUY` 请领单"),
("业务类型", "如采购计划、二级库调拨、产品信息变更、请领单"),
("发起人", "姓名,窄列截断"),
("发起部门", "部门名称,窄列截断"),
("发起时间", "日期时间,窄列截断为 `YYYY-MM-DD …`"),
("摘要", "业务摘要;无则 `--`,长文本截断"),
("审批状态", "审批通过 / 审批中(以页面枚举为准)"),
("操作", "**详情**"),
],
"row_ops": "**详情**(只读,打开该单据审批过程)",
"paging": "「共 **2348** 条」、页码、**50 条/页**、**跳至 N 页**(默认日期区间较大,条数随区间变化)",
"scan_key": "flow-inst",
"po_hint": "pages/spd/flow-inst.page.ts",
"notes": [
"菜单导航:`page.getByRole('menuitem', { name: '审批流实例', exact: true }).click()`;或 `openPath('/spd/basic/flowSet/workflow/flowCase')` 断言 URL。不要点到「审批流程设置」。",
"默认发起时间跨度大,全量查询条数多;收窄日期后再断言「共 N 条」,且列表「发起时间」须落在区间内。",
"单据编号前缀与业务类型对应,按编号搜索时用完整单号,不用截断后的发起人/部门列文本。",
"本页行操作只有 **详情**;改审批人、条件应回到审批流程设置,且该页保存会影响后续新实例。",
],
"points": [
"单据编号精确查询至多一条,且编号、业务类型、状态与条件一致。",
"审批状态、业务类型、发起时间、摘要组合筛选后,抽样行同时满足全部条件。",
"重置后日期回到默认区间,其余条件清空,「共 N 条」恢复。",
"摘要为 `--` 的行,按非空摘要关键字查询不应命中。",
"详情打开的单据编号与当前行一致;关闭详情后筛选条件与页码保持。",
],
"extra_md": "",
},
]
PAGES: list[dict] = STAT_PAGES + ADMIT_PAGES + VENDOR_PAGES + USER_PAGES + WH_PAGES + FLOW_PAGES
def png_files(folder: Path) -> list[Path]:
return sorted(p for p in folder.iterdir() if p.suffix.lower() == ".png")
def render_spec(page: dict, spec_id: str) -> str:
def render_admit_spec(page: dict, spec_id: str) -> str:
title = f"准入管理-{page['name']}页面说明"
menu = f"**基础设置** → **准入管理** → **{page['name']}**"
docs_path = f"docs/{page['folder']}/"
kb_dir = f"UI-SPEC-SPD-{spec_id}"
extra_img = ""
if spec_id == "ADMIT-005":
extra_img = "\n\n![供货目录申请-补充截图](UI-SPEC-SPD-ADMIT-005/page-main-2.png)\n"
return f"""---
title: {title}
module: 基础设置
status: 生效
related_br: ""
related_api: ""
related_jrn: ""
related_tc: ""
related_def: ""
create_time: "{TODAY}"
update_time: "{TODAY}"
author: ""
---
# 准入管理 - {page['name']}
> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与准入流程验收参考。
> 同模块索引见 [`INDEX.md`](./INDEX.md)。
## 文档与截图
| 项 | 路径 |
|----|------|
| 截图原件 | `{docs_path}` |
| 知识库副本 | `{kb_dir}/page-main.png` |
## 页面路径
{menu}
| 项 | 值 |
|----|-----|
| 主框架 URL | 待元素扫描补充(菜单名:`{page['name']}`) |
| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |
## 主界面
![{page['name']}主界面]({kb_dir}/page-main.png){extra_img}
### 筛选区
- 字段:{page.get('filters', '以截图为准')}
- 操作:**查询**、**重置**;部分页面含 **收起/展开**
### 状态 Tab(若有)
- {page.get('status_tabs', '以截图为准')}
### 列表与工具栏
- {page.get('toolbar', '以截图为准')}
- {page.get('list_note', '主表列与行内操作以截图为准')}
- 底部分页:「共 N 条」、页码与每页条数切换
## 自动化建议
1. 完成专项元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object。
2. 菜单导航:`page.getByRole('menuitem', {{ name: '{page['name']}', exact: true }}).click()`(注意与同组「产品/物料准入单列表」等近似菜单区分)。
3. 状态 Tab 切换后列表与 Tab 计数一致;批量操作需先勾选行。
## 测试关注点
1. 筛选 + 状态 Tab 组合后,列表条数与底部分页「共 N 条」一致。
2. 生成申请单/一键生成/批量提交等写操作与当前勾选行、筛选条件一致。
3. 审批态行操作(编辑/详情/作废)与状态机规则一致。
"""
def render_vendor_spec(page: dict, spec_id: str) -> str:
title = f"厂商管理-{page['name']}页面说明"
menu = f"**基础设置** → **厂商管理** → **{page['name']}**"
docs_path = f"docs/{page['folder']}/"
kb_dir = f"UI-SPEC-SPD-{spec_id}"
extra_n = int(page.get("extra_imgs") or 0)
extra_img = ""
for i in range(2, extra_n + 2):
extra_img += f"\n\n![{page['name']}-补充截图{i - 1}]({kb_dir}/page-main-{i}.png)\n"
return f"""---
title: {title}
module: 基础设置
status: 生效
related_br: ""
related_api: ""
related_jrn: ""
related_tc: ""
related_def: ""
create_time: "{TODAY}"
update_time: "{TODAY}"
author: ""
---
# 厂商管理 - {page['name']}
> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与厂商/资质流程验收参考。
> 同模块索引见 [`INDEX.md`](./INDEX.md)。
## 文档与截图
| 项 | 路径 |
|----|------|
| 截图原件 | `{docs_path}` |
| 知识库副本 | `{kb_dir}/page-main.png` |
## 页面路径
{menu}
| 项 | 值 |
|----|-----|
| 主框架 URL | 待元素扫描补充(菜单名:`{page['name']}`) |
| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |
## 主界面
![{page['name']}主界面]({kb_dir}/page-main.png){extra_img}
### 筛选区
- 字段:{page.get('filters', '以截图为准')}
- 操作:**查询**、**重置**;部分页面含 **收起/展开**
### 状态 / 分类 Tab(若有)
- {page.get('status_tabs', '以截图为准')}
### 列表与工具栏
- {page.get('toolbar', '以截图为准')}
- {page.get('list_note', '主表列与行内操作以截图为准')}
- 底部分页:「共 N 条」、页码与每页条数切换(左右分栏页面对应侧分页)
## 自动化建议
1. 完成专项元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object。
2. 菜单导航:`page.getByRole('menuitem', {{ name: '{page['name']}', exact: true }}).click()`(注意初审/终审、方案/指标等近似菜单区分)。
3. 资质多 Tab、变更审批详情、左右分栏(指标树/节假日)需先断言目标面板可见再操作。
## 测试关注点
1. 筛选 + Tab/状态下拉组合后,列表条数与底部分页「共 N 条」一致。
2. 授权/注册证初审与终审详情、状态流转与业务规则一致;写操作前确认勾选/当前行。
3. 注册证差异对比「一致/不一致」与两侧字段抽样一致;导出与当前查询条件一致。
"""
def render_stat_spec(page: dict, spec_id: str) -> str:
title = f"{page['group']}-{page['name']}页面说明"
menu = f"**统计报表** → **{page['group']}** → **{page['name']}**"
docs_path = f"docs/{page['folder']}/"
......@@ -161,6 +865,251 @@ author: ""
"""
def render_user_spec(page: dict, spec_id: str) -> str:
title = f"用户管理-{page['name']}页面说明"
menu = f"**基础设置** → **用户管理** → **{page['name']}**"
docs_path = f"docs/{page['folder']}/"
kb_dir = f"UI-SPEC-SPD-{spec_id}"
scan_key = page["scan_key"]
tab_row = f"| 浏览器页签标题 | `{page['tab_title']}` |\n" if page.get("tab_title") else ""
# 第 1 条固定为「先扫描再补 PO」,其余建议顺延编号
notes = "\n".join(f"{i}. {n}" for i, n in enumerate(page["notes"], start=2))
points = "\n".join(f"{i}. {p}" for i, p in enumerate(page["points"], start=1))
filter_rows = "\n".join(f"| {f} | {c} | {d} |" for f, c, d in page["filters"])
col_rows = "\n".join(f"| {name} | {desc} |" for name, desc in page["columns"])
modules = ""
if page.get("modules"):
rows = "\n".join(f"| {m} | {items} |" for m, items in page["modules"])
modules = f"""
### 右侧菜单权限树
二级模块(示例):
| 模块 | 菜单/功能(示例) |
|------|-------------------|
{rows}
- 勾选父节点联动子节点,保存后即为该角色授权范围。
"""
return f"""---
title: {title}
module: 基础设置
status: 生效
related_br: ""
related_api: ""
related_jrn: ""
related_tc: ""
related_def: ""
create_time: "{TODAY}"
update_time: "{TODAY}"
author: ""
---
# 用户管理 - {page['name']}
> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与用户/权限验收参考。
> 同模块索引见 [`INDEX.md`](./INDEX.md)。
## 文档与截图
| 项 | 路径 |
|----|------|
| 截图原件 | `{docs_path}` |
| 知识库副本 | `{kb_dir}/page-main.png` |
## 页面路径
{menu}(同组页面:**用户管理** / **医共体用户管理** / **角色管理**)
| 项 | 值 |
|----|-----|
| 主框架 URL | `http://spdtest.cmic.com.cn:8080{page['path']}` |
{tab_row}| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
| 元素扫描 JSON | 待扫描补充(建议 `data/scan/{scan_key}-elements.json`) |
| 定位器 YAML | 待扫描补充(建议 `locators/spd/{scan_key}.locator.yaml`) |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |
## 主界面
![{page['name']}主界面]({kb_dir}/page-main.png)
{page['layout']}
### 筛选/搜索区
| 字段 | 控件 | 占位/默认值 |
|------|------|-------------|
{filter_rows}
- 操作:{page['actions']}
### 列表与工具栏
工具栏:{page.get('toolbar', '**新增**(按实际文案定位)')}。
| 列 | 说明 |
|----|------|
{col_rows}
行操作:{page['row_ops']}。
### 分页
{page['paging']}
{modules}
## 自动化建议
1. 完成元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object(建议 `{page['po_hint']}`)。
{notes}
## 测试关注点
{points}
## 关联
- 同模块:用户管理([`UI-SPEC-SPD-USER-001`](./UI-SPEC-SPD-USER-001.md))、医共体用户管理([`UI-SPEC-SPD-USER-002`](./UI-SPEC-SPD-USER-002.md))、角色管理([`UI-SPEC-SPD-USER-003`](./UI-SPEC-SPD-USER-003.md))。
- 同层菜单:医院资料(`UI-SPEC-SPD-BASIC-*`)、厂商管理(`UI-SPEC-SPD-VENDOR-*`)、手术套包设置(`UI-SPEC-SPD-SURG-*`)。
{page.get('related_extra', '')}"""
def render_group_spec(page: dict, spec_id: str) -> str:
"""库房管理 / 流程设置等左右分栏或单表页面(结构对齐用户管理规范)。"""
group = page["group"]
title = f"{group}-{page['name']}页面说明"
menu = f"**基础设置** → **{group}** → **{page['name']}**"
docs_path = f"docs/{page['folder']}/"
kb_dir = f"UI-SPEC-SPD-{spec_id}"
scan_key = page["scan_key"]
siblings = page.get("siblings", f"**{page['name']}**")
shot = page.get("shot_note") or f"`{kb_dir}/page-main.png`"
tab_row = f"| 浏览器页签标题 | `{page['tab_title']}` |\n" if page.get("tab_title") else ""
notes = "\n".join(f"{i}. {n}" for i, n in enumerate(page["notes"], start=2))
points = "\n".join(f"{i}. {p}" for i, p in enumerate(page["points"], start=1))
filter_rows = "\n".join(f"| {f} | {c} | {d} |" for f, c, d in page["filters"])
col_rows = "\n".join(f"| {name} | {desc} |" for name, desc in page["columns"])
extra = (page.get("extra_md") or "").strip()
extra = f"\n{extra}\n" if extra else "\n"
page_url = (
f"`http://spdtest.cmic.com.cn:8080{page['path']}`"
if page.get("path")
else f"待元素扫描补充(菜单名:`{page['name']}`)"
)
if group == "库房管理":
peer = (
"- 同模块:库区设置([`UI-SPEC-SPD-WH-001`](./UI-SPEC-SPD-WH-001.md))、"
"探针绑定([`UI-SPEC-SPD-WH-002`](./UI-SPEC-SPD-WH-002.md))。"
"菜单另有「华清通道」,本次无截图,未建规范。\n"
"- 同层菜单:医院资料(`UI-SPEC-SPD-BASIC-*`)、用户管理(`UI-SPEC-SPD-USER-*`)、"
"流程设置(`UI-SPEC-SPD-FLOW-*`)。"
)
else:
peer = (
"- 同模块:审批流程设置([`UI-SPEC-SPD-FLOW-001`](./UI-SPEC-SPD-FLOW-001.md))、"
"审批流实例([`UI-SPEC-SPD-FLOW-002`](./UI-SPEC-SPD-FLOW-002.md))。\n"
"- 同层菜单:库房管理(`UI-SPEC-SPD-WH-*`)、准入管理(`UI-SPEC-SPD-ADMIT-*`)、"
"用户管理(`UI-SPEC-SPD-USER-*`)。自定义审批流保存后,新单据出现在审批流实例。"
)
return f"""---
title: {title}
module: 基础设置
status: 生效
related_br: ""
related_api: ""
related_jrn: ""
related_tc: ""
related_def: ""
create_time: "{TODAY}"
update_time: "{TODAY}"
author: ""
---
# {group} - {page['name']}
> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与{group}验收参考。
> 同模块索引见 [`INDEX.md`](./INDEX.md)。
## 文档与截图
| 项 | 路径 |
|----|------|
| 截图原件 | `{docs_path}` |
| 知识库副本 | {shot} |
## 页面路径
{menu}(同组页面:{siblings})
| 项 | 值 |
|----|-----|
| 主框架 URL | {page_url} |
{tab_row}| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
| 元素扫描 JSON | `data/scan/{scan_key}-elements.json` |
| 定位器 YAML | `locators/spd/{scan_key}.locator.yaml` |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |
## 主界面
![{page['name']}主界面]({kb_dir}/page-main.png)
{page['layout']}
### 筛选/搜索区
| 字段 | 控件 | 占位/默认值 |
|------|------|-------------|
{filter_rows}
- 操作:{page['actions']}
### 列表与工具栏
工具栏:{page.get('toolbar', '列设置')}。
| 列 | 说明 |
|----|------|
{col_rows}
行操作:{page['row_ops']}。
### 分页
{page['paging']}
{extra}
## 自动化建议
1. 元素扫描 JSON 与定位器 YAML 已生成;下一步补 Page Object(建议 `{page['po_hint']}`)。
{notes}
## 测试关注点
{points}
## 关联
{peer}
"""
def render_spec(page: dict, spec_id: str) -> str:
if spec_id.startswith("ADMIT-"):
return render_admit_spec(page, spec_id)
if spec_id.startswith("VENDOR-"):
return render_vendor_spec(page, spec_id)
if spec_id.startswith("USER-"):
return render_user_spec(page, spec_id)
if spec_id.startswith("WH-") or spec_id.startswith("FLOW-"):
return render_group_spec(page, spec_id)
return render_stat_spec(page, spec_id)
def main() -> None:
parser = argparse.ArgumentParser(description="同步 docs 截图至 UI 专项规范库")
parser.add_argument(
......@@ -184,8 +1133,12 @@ def main() -> None:
if not images:
raise SystemExit(f"no png in {src_dir}")
shutil.copy2(images[0], kb_dir / "page-main.png")
if len(images) > 1:
shutil.copy2(images[1], kb_dir / "page-main-2.png")
# 复制全部补充截图:page-main-2.png … page-main-N.png
for idx, img in enumerate(images[1:], start=2):
shutil.copy2(img, kb_dir / f"page-main-{idx}.png")
# 若配置了 extra_imgs,确保规范中引用的张数不超过实际文件
if "extra_imgs" in page:
page = {**page, "extra_imgs": max(0, min(int(page["extra_imgs"]), len(images) - 1))}
spec_file.write_text(render_spec(page, spec_id), encoding="utf-8")
print(f"OK UI-SPEC-SPD-{spec_id}\t{page['name']}\t{len(images)} img")
print("done", len(pages))
......
#!/usr/bin/env node
/**
* Flake 隔离闭环:把 reports/flake 的隔离候选合并写入 config/quarantine.json。
*
* 背景:flake-report.mjs 只产出「隔离候选」清单,若不落库,主门禁会一直跑这些高 flake 用例,
* 导致夜间全量被环境/瞬时问题反复刷红。本脚本负责把它变成真正的隔离项(闭环)。
*
* 用法:
* node scripts/sync-quarantine.mjs # 预览:只打印将要写入的项
* node scripts/sync-quarantine.mjs --write # 实际写入 config/quarantine.json
* node scripts/sync-quarantine.mjs --write --owner=liguangyu --mode=observe
*
* 安全约束:
* - 只隔离 environment / transient / script 类候选;**product(断言/业务缺陷)绝不自动隔离**。
* - 已存在的人工条目(含 ticket/owner 等)一律保留,只补充/更新自动条目。
* - 自动条目带 `auto: true` 标记,便于下次同步识别与回收。
*/
import {
existsSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/** 允许自动隔离的失败类别;product 属业务缺陷,禁止自动隔离。 */
const AUTO_ISOLATABLE = new Set(['environment', 'transient', 'script', 'data']);
const CLASS_LABEL = {
environment: '环境/基础设施抖动',
transient: '瞬时 Flake',
script: '脚本/定位不稳定',
data: '测试数据不足',
product: '产品缺陷',
};
function parseArgs(argv) {
const out = {
trend: path.join(ROOT, 'reports/flake/latest.json'),
quarantine: path.join(ROOT, 'config/quarantine.json'),
write: false,
mode: 'quarantine',
owner: process.env.QUARANTINE_OWNER || 'ui-auto',
ticket: process.env.QUARANTINE_TICKET || '',
};
for (const arg of argv) {
if (arg === '--write') out.write = true;
else if (arg.startsWith('--trend=')) out.trend = path.resolve(arg.slice(8));
else if (arg.startsWith('--quarantine=')) out.quarantine = path.resolve(arg.slice(13));
else if (arg.startsWith('--mode=')) out.mode = arg.slice(7);
else if (arg.startsWith('--owner=')) out.owner = arg.slice(8);
else if (arg.startsWith('--ticket=')) out.ticket = arg.slice(9);
}
if (!['quarantine', 'observe'].includes(out.mode)) out.mode = 'quarantine';
return out;
}
function readJson(file, fallback) {
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch {
return fallback;
}
}
function extractCaseId(title) {
return String(title || '').match(/\b(STAT-[A-Z]+-\d{3,})\b/)?.[1];
}
/** 取该用例出现最多的失败类别(决定是否可自动隔离)。 */
function dominantClass(classes) {
const entries = Object.entries(classes || {});
if (!entries.length) return { cls: '', n: 0 };
entries.sort((a, b) => b[1] - a[1]);
return { cls: entries[0][0], n: entries[0][1] };
}
function buildAutoItems(trend, opts) {
const items = [];
const skipped = [];
for (const cand of trend.quarantineCandidates || []) {
const { cls } = dominantClass(cand.classes);
const id = extractCaseId(cand.title);
if (!id) {
skipped.push({ title: cand.title, reason: '标题无 STAT- 编号,无法稳定匹配' });
continue;
}
if (!AUTO_ISOLATABLE.has(cls)) {
skipped.push({ title: cand.title, reason: `失败类别 ${cls || '未知'}(疑似产品缺陷)不自动隔离` });
continue;
}
items.push({
id,
mode: opts.mode,
reason: `flake failRate=${Number(cand.failRate).toFixed(2)} (${cand.failCount}/${cand.decided}) 主导类别=${CLASS_LABEL[cls] || cls}`,
owner: opts.owner,
since: new Date().toISOString().slice(0, 10),
category: cls,
auto: true,
...(opts.ticket ? { ticket: opts.ticket } : {}),
});
}
return { items, skipped };
}
function mergeItems(existingItems, autoItems) {
const manual = (existingItems || []).filter((item) => !item.auto);
const manualIds = new Set(manual.map((item) => item.id).filter(Boolean));
const autoById = new Map();
for (const item of autoItems) {
// 人工已声明的用例不再自动覆盖,尊重人工结论。
if (manualIds.has(item.id)) continue;
autoById.set(item.id, item);
}
return [...manual, ...[...autoById.values()]];
}
function main() {
const opts = parseArgs(process.argv.slice(2));
if (!existsSync(opts.trend)) {
console.error(`[quarantine] 未找到 flake 趋势文件:${path.relative(ROOT, opts.trend)}`);
console.error('[quarantine] 先执行 `npm run flake:report` 生成隔离候选。');
process.exit(2);
}
const trend = readJson(opts.trend, { quarantineCandidates: [] });
const { items: autoItems, skipped } = buildAutoItems(trend, opts);
const current = readJson(opts.quarantine, { version: 1, items: [] });
const merged = mergeItems(current.items, autoItems);
const added = merged.filter((item) => item.auto).length;
console.log(
`[quarantine] 候选=${(trend.quarantineCandidates || []).length} 自动可隔离=${autoItems.length} 跳过=${skipped.length} 合并后总计=${merged.length}`,
);
for (const item of autoItems) {
console.log(` + ${item.id} [${item.mode}] ${item.reason}`);
}
for (const s of skipped) {
console.log(` ~ 跳过 ${s.title}:${s.reason}`);
}
if (!opts.write) {
console.log('[quarantine] 预览模式,未写入。加 --write 落库。');
return;
}
const next = { version: current.version ?? 1, items: merged };
writeFileSync(opts.quarantine, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
console.log(
`[quarantine] 已写入 ${path.relative(ROOT, opts.quarantine)}(自动项 ${added},人工项保留 ${
merged.length - added
})。`,
);
}
main();
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"ADMIT-PLS-001","基础设置 · 准入管理","产品准入单列表 · 页面进入","菜单进入产品准入单列表","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 产品准入单列表」进入
2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productOrder/list`,且路径不是 `zrlist`
3. 断言制单日期、查询、状态 Tab、列表表头可见","进入产品准入单列表;不误入物料准入单列表。",""
"ADMIT-PLS-002","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)","1. 点击「查 询」并等待列表稳定
2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"ADMIT-PLS-003","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","按产品名称筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);默认查询至少 1 条且产品名称非空","1. 从首行读取 `${sampleProductName}`
2. 若筛选区无产品名称,则用注册证号或器械注册人中首行非空字段
3. 制单日期保持默认,点击「查 询」
4. 断言结果包含该关键字","结果与已填关键字匹配。",""
"ADMIT-PLS-004","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已填入筛选关键字","1. 点击「重 置」
2. 断言筛选恢复默认","筛选恢复默认;列表按默认规则刷新。",""
"ADMIT-PLS-005","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)","1. 注册证号填入 `__NOT_EXIST_CERT__99999`
2. 点击「查 询」
3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。",""
"ADMIT-PLS-006","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","列表核心列与附件计数","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);默认查询至少 1 条数据","1. 断言表头含准入单号、进院供应商、二级供应商、产品名称、注册证号、耗材类型、产品条数、状态、备注、附件、操作
2. 断言首行附件列展示个数(可为 0 个)","核心列可见;附件列有个数;操作为「详情」。本页列表不要求「准入类型」列。",""
"ADMIT-PLS-007","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","切换已通过 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)","1. 点击「已通过」
2. 断言有数据时状态为已通过
3. 断言 Tab 括号数量等于分页「共 N 条」","已通过 Tab 的状态与计数一致。",""
"ADMIT-PLS-008","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","与物料准入单列表不串 URL","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);当前在产品准入单列表","1. 记录当前 URL
2. 断言 URL 含 `productOrder/list` 且不含 `zrlist`
3. 不点击物料准入单列表菜单","停留在产品准入单列表路径,不串到物料单路径。",""
"ADMIT-PLS-009","基础设置 · 准入管理","产品准入单列表 · 单据详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);默认查询至少 1 条数据","1. 记录首行 `${sampleOrderNo}`
2. 点击「详情」
3. 断言可见「审批进程」「单据日志」「返 回」
4. 返回后 URL 仍含 `productOrder/list`","详情可打开;返回后仍是产品准入单列表。",""
"ADMIT-PLS-010","基础设置 · 准入管理","产品准入单列表 · 单据详情","详情产品条数与列表一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已打开首行详情","1. 读取详情明细分页总数或当前行数
2. 断言等于列表「产品条数」","明细条数与产品条数一致。",""
"ADMIT-PLS-011","基础设置 · 准入管理","产品准入单列表 · 单据详情","单据日志可打开","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已打开单据详情","1. 点击「单据日志」
2. 断言日志层可见
3. 关闭后仍在该单据","单据日志可查看;详情不被销毁。",""
"ADMIT-PLS-012","基础设置 · 准入管理","产品准入单列表 · 单据详情","详情搜索无匹配(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已打开单据详情","1. 搜索框填入 `__NOT_EXIST_GOODS__99999`
2. 断言明细空态,返回按钮仍在","无匹配只影响明细;单据头仍在。",""
"ADMIT-PLS-013","基础设置 · 准入管理","产品准入单列表 · 单据详情","已通过单据不确认通过","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已通过 Tab 至少 1 条并打开详情","1. 「通过」禁用或不可见时,断言状态保持已通过
2. 若可点且出现确认框,点「取 消」
3. 返回列表,断言状态仍为已通过","不把已通过单据再次审批成功。无已通过数据则 skip。",""
"ADMIT-PLS-014","基础设置 · 准入管理","产品准入单列表 · 筛选与状态","多条件组合查询(制单日期等6项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载
2. 「制单日期」日期区间选择当天
3. 「销售商」填写关键字
4. 「耗材类型」下拉选择任一可选项
5. 「器械注册人」填写关键字
6. 「注册证号」填写关键字
7. 「供应商」填写关键字
8. 点击「查询」
9. 检查列表返回结果","多条件同时生效:制单日期、销售商、耗材类型、器械注册人、注册证号、供应商 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行"
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"ADMIT-PAP-001","基础设置 · 准入管理","产品准入申请 · 页面进入","菜单进入产品准入申请页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 产品准入申请」进入
2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productApply/list`,且不是 `ZRlist`
3. 断言可见「查 询」、「Excel导入」「生成申请单」「一键生成」「新增产品」「作 废」及列表表头","进入产品准入申请页;不误入物料准入管理(ZRlist)或产品准入单列表。",""
"ADMIT-PAP-002","基础设置 · 准入管理","产品准入申请 · 筛选查询","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 点击「查 询」并等待列表稳定
2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"ADMIT-PAP-003","基础设置 · 准入管理","产品准入申请 · 筛选查询","产品名称筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据","1. 从首行读取 `${sampleProductName}`
2. 填入产品名称,新增日期保持默认,点击「查 询」
3. 断言结果行「产品名称」均包含该关键字","产品名称与已填关键字匹配。",""
"ADMIT-PAP-004","基础设置 · 准入管理","产品准入申请 · 筛选查询","名称与耗材类型组合查询","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据","1. 读取首行 `${sampleProductName}`、`${sampleConsumableType}`
2. 同时填入后点击「查 询」(日期保持默认)
3. 断言结果同时满足两个条件","组合查询结果同时满足产品名称与耗材类型。类型为空则只断言名称。",""
"ADMIT-PAP-005","基础设置 · 准入管理","产品准入申请 · 筛选查询","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 填入 `${sampleProductName}`
2. 点击「重 置」
3. 断言筛选恢复默认","筛选字段恢复默认;列表按默认规则刷新。",""
"ADMIT-PAP-006","基础设置 · 准入管理","产品准入申请 · 筛选查询","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 产品名称填入 `__NOT_EXIST_GOODS__99999`
2. 点击「查 询」
3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。",""
"ADMIT-PAP-007","基础设置 · 准入管理","产品准入申请 · 状态与列表","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据","1. 断言表头含准入单号、产品编号、产品名称、规格型号、注册规格、注册型号、器械注册人、单位、操作","核心列可见;操作含「详情」「日志」。",""
"ADMIT-PAP-008","基础设置 · 准入管理","产品准入申请 · 状态与列表","切换待申请 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 点击「待申请」
2. 断言有数据时行不属于已生效/已作废;计数为 0 时为空态","待申请 Tab 与列表状态一致。",""
"ADMIT-PAP-009","基础设置 · 准入管理","产品准入申请 · 状态与列表","翻至第 2 页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);分页总数大于每页条数","1. 记录第 1 页首行准入单号
2. 点击页码「2」或「下一页」
3. 断言页码高亮为 2,首行单号与第 1 页不同","翻页成功且数据更换。不足两页则 skip。",""
"ADMIT-PAP-010","基础设置 · 准入管理","产品准入申请 · 状态与列表","跳至指定页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);总页数不少于 2,分页存在「跳至」","1. 在跳至输入框填入 `2` 并回车
2. 断言当前页为 2","跳转到第 2 页。无跳至或仅 1 页则 skip。",""
"ADMIT-PAP-011","基础设置 · 准入管理","产品准入申请 · 新增与导入","打开新增产品后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 点击「新增产品」
2. 断言可见产品名称、器械注册人、规格型号、注册证号及返回
3. 点击返回,不保存","新增页字段可见;返回后列表不新增记录。",""
"ADMIT-PAP-012","基础设置 · 准入管理","产品准入申请 · 新增与导入","新增取消不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 记录分页总数 `${sampleTotal}`
2. 打开新增,产品名称填 `${uniqProductName}`(建议 `AUTO-PAP-${timestamp}`)后返回不保存
3. 按该名称查询,断言无此行,总数不变","取消后不产生新产品。",""
"ADMIT-PAP-013","基础设置 · 准入管理","产品准入申请 · 新增与导入","Excel 导入打开后取消","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)","1. 记录总数 `${sampleTotal}`
2. 点击「Excel导入」
3. 断言导入层打开
4. 关闭或取消,不选择文件
5. 断言总数不变","取消导入不增加产品。",""
"ADMIT-PAP-014","基础设置 · 准入管理","产品准入申请 · 新增与导入","产品名称为空保存拦截(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);已打开新增产品页","1. 产品名称保持 mock 空值,其它能填的非名称字段可留空
2. 点击保存
3. 断言必填提示,页面不关闭
4. 返回列表,断言总数不变","保存被拦截;不落库。",""
"ADMIT-PAP-015","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据","1. 记录首行 `${sampleProductName}`
2. 点击「详情」,断言名称一致
3. 返回列表","详情与该行一致;返回后仍在产品准入申请列表。",""
"ADMIT-PAP-016","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","待申请编辑回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);待申请至少 1 行含「编辑」","1. 读取该行 `${sampleProductName}`、`${sampleSpec}`
2. 点击「编辑」
3. 断言名称与规格型号回填一致
4. 返回不保存","编辑回填与列表行一致。无编辑按钮则 skip。",""
"ADMIT-PAP-017","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","编辑取消不保存","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);存在可编辑行","1. 打开编辑,把产品名称改为 `${uniqProductName}` 后返回不保存
2. 再次打开,断言名称仍为原值","取消后数据不变。",""
"ADMIT-PAP-018","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","作废确认后取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);待申请至少 1 条可勾选","1. 勾选 1 行并点击「作 废」
2. 断言确认框出现
3. 点击「取 消」
4. 断言该行仍在,状态未变为已作废","取消作废不改变状态。本条不点击确定。",""
"ADMIT-PAP-019","基础设置 · 准入管理","产品准入申请 · 详情、编辑与作废","已通过行不提供编辑","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);已通过 Tab 计数大于 0","1. 切换「已通过」
2. 断言首行无「编辑」,或打开后保存被拒绝","已通过行不能改写产品名称。计数为 0 则 skip。",""
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"ADMIT-SUP-001","基础设置 · 准入管理","供货目录申请 · 页面进入","菜单进入供货目录申请页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 供货目录申请」进入
2. 断言 URL 含 `/spd/basic/dataSync/dataSync/supplyApply/list`
3. 断言可见「查 询」、「批量提交」、「新 增」及列表表头","进入供货目录申请页;不误入准入任务分配。",""
"ADMIT-SUP-002","基础设置 · 准入管理","供货目录申请 · 筛选与状态","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 点击「查 询」并等待列表稳定
2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"ADMIT-SUP-003","基础设置 · 准入管理","供货目录申请 · 筛选与状态","按进院供应商筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询至少 1 条且进院供应商非空","1. 从首行读取 `${sampleHospitalSupplier}`
2. 在进院供应商选择该值,制单日期保持默认,点击「查 询」
3. 断言结果行「进院供应商」与所选值一致","进院供应商列与条件一致。",""
"ADMIT-SUP-004","基础设置 · 准入管理","供货目录申请 · 筛选与状态","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已选择进院供应商","1. 点击「重 置」
2. 断言进院供应商等筛选恢复默认","筛选恢复默认;列表按默认规则刷新。",""
"ADMIT-SUP-005","基础设置 · 准入管理","供货目录申请 · 筛选与状态","变更单号无匹配空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 变更单号填入 `__NOT_EXIST_ORDER__99999`;若筛选区无此框,则在产品名称填同一 mock
2. 点击「查 询」
3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。",""
"ADMIT-SUP-006","基础设置 · 准入管理","供货目录申请 · 筛选与状态","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询至少 1 条数据","1. 断言表头含申请单号、申请人、申请时间、进院供应商、二级供应商、业务模式、是否默认、备注、操作","核心列可见;操作含「编辑」或「详情」(随状态变化)。",""
"ADMIT-SUP-007","基础设置 · 准入管理","供货目录申请 · 筛选与状态","切换待提交 Tab 且计数一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 点击「待提交」
2. 读取括号数量
3. 断言分页「共 N 条」等于该数量;有数据时状态为待提交","待提交 Tab 与分页总数一致。计数为 0 时为空态。",""
"ADMIT-SUP-008","基础设置 · 准入管理","供货目录申请 · 新增与编辑","打开新增供货目录","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 点击「新 增」
2. 断言可见进院供应商、二级供应商、业务模式、是否默认、计划生效时间、添加产品、提交
3. 返回不提交","新增页字段齐全;返回后列表不新增申请单。",""
"ADMIT-SUP-009","基础设置 · 准入管理","供货目录申请 · 新增与编辑","新增放弃不落库","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)","1. 记录总数 `${sampleTotal}`
2. 打开新增,备注填 `${uniqRemark}`(建议 `AUTO-SUP-${timestamp}`)后返回不提交
3. 断言总数不变,列表无该备注","未提交不产生申请单。",""
"ADMIT-SUP-010","基础设置 · 准入管理","供货目录申请 · 新增与编辑","业务模式与是否默认只切换不提交","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已打开新增页","1. 展开业务模式、是否默认,断言存在可选项
2. 各选一项后返回,不提交
3. 断言列表总数不变","选项可见;不提交则无新单。",""
"ADMIT-SUP-011","基础设置 · 准入管理","供货目录申请 · 新增与编辑","待提交编辑回填","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);待提交至少 1 行含「编辑」","1. 读取该行 `${sampleHospitalSupplier}`、`${sampleBizMode}`、`${sampleDefaultFlag}`
2. 点击「编辑」
3. 断言进院供应商、业务模式、是否默认与该行一致
4. 返回不提交","编辑回填与列表行一致。无待提交或无编辑则 skip。",""
"ADMIT-SUP-012","基础设置 · 准入管理","供货目录申请 · 新增与编辑","编辑取消不保存","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);存在可编辑的待提交行","1. 打开编辑,把备注改为 `${uniqRemark}` 后返回不提交
2. 再次打开,断言备注仍为原值","取消后备注不变。",""
"ADMIT-SUP-013","基础设置 · 准入管理","供货目录申请 · 新增与编辑","进院供应商为空不可提交(mock反例)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已打开新增页,进院供应商未选","1. 不选进院供应商,点击「提 交」
2. 断言必填提示,页面不关闭
3. 返回列表,断言总数不变","提交被拦截;不产生申请单。",""
"ADMIT-SUP-014","基础设置 · 准入管理","供货目录申请 · 新增与编辑","未添加产品不可提交","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已打开新增页,产品明细为 0","1. 若进院供应商、二级供应商、业务模式可从样本选择则先选上,但不添加产品
2. 点击「提 交」
3. 断言拦截提示,明细仍为 0,列表总数不变","没有产品明细时不能提交。",""
"ADMIT-SUP-015","基础设置 · 准入管理","供货目录申请 · 批量提交与详情","未勾选时批量提交不可用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),无勾选行","1. 断言「批量提交」计数为 0,或按钮不可用
2. 若仍可点击,断言出现请选择提示,且无单据离开待提交","未勾选不提交任何申请单。",""
"ADMIT-SUP-016","基础设置 · 准入管理","供货目录申请 · 批量提交与详情","勾选后批量提交取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);待提交至少 1 条","1. 勾选 1 行,记录申请单号与状态
2. 点击「批量提交」
3. 若出现确认框,点「取 消」
4. 断言该单仍为待提交","取消批量提交不改变状态。本条不点击确定。无待提交则 skip。",""
"ADMIT-SUP-017","基础设置 · 准入管理","供货目录申请 · 批量提交与详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询至少 1 条数据","1. 点击首行「详情」
2. 断言可见「审批进程」「单据日志」「返 回」
3. 返回后 URL 仍含 `supplyApply/list`","详情可打开;返回后仍在供货目录申请列表。",""
"ADMIT-SUP-018","基础设置 · 准入管理","供货目录申请 · 筛选与状态","多条件组合查询(制单日期等6项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载
2. 「制单日期」日期区间选择当天
3. 「销售商」下拉选择任一可选项
4. 「供应商」下拉选择任一可选项
5. 「变更单号」填写关键字
6. 「物料名称」填写关键字
7. 「规格型号」填写关键字
8. 点击「查询」
9. 检查列表返回结果","多条件同时生效:制单日期、销售商、供应商、变更单号、物料名称、规格型号 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行"
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"ADMIT-TSK-001","基础设置 · 准入管理","准入任务分配 · 页面进入","菜单进入准入任务分配页","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 准入任务分配」进入
2. 断言 URL 含 `/spd/basic/dataSync/dataSync/accessKind/list`
3. 断言可见「查 询」、「待分类」「已分类」、「批量分配」及列表表头","进入准入任务分配页;不误入供货目录申请。",""
"ADMIT-TSK-002","基础设置 · 准入管理","准入任务分配 · 筛选与分类","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe),当前为待分类","1. 点击「查 询」并等待列表稳定
2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"ADMIT-TSK-003","基础设置 · 准入管理","准入任务分配 · 筛选与分类","产品名称筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 条数据","1. 从首行读取 `${sampleProductName}`
2. 填入产品名称,新增日期与操作日期保持默认,点击「查 询」
3. 断言结果行「产品名称」均包含该关键字","产品名称与已填关键字匹配。",""
"ADMIT-TSK-004","基础设置 · 准入管理","准入任务分配 · 筛选与分类","待分类与已分类切换","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe)","1. 记录「待分类」括号数量 `${pendingCount}`
2. 点击「已分类」,断言分页总数等于已分类括号数量;为 0 时为空态
3. 再点「待分类」,断言分页总数等于 `${pendingCount}`","两个 Tab 的列表范围与各自计数一致,不串数据。",""
"ADMIT-TSK-005","基础设置 · 准入管理","准入任务分配 · 筛选与分类","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);已填入 `${sampleProductName}`","1. 点击「重 置」
2. 断言产品名称等筛选恢复默认","筛选恢复默认;列表按默认规则刷新。",""
"ADMIT-TSK-006","基础设置 · 准入管理","准入任务分配 · 筛选与分类","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);当前在待分类","1. 产品名称填入 `__NOT_EXIST_GOODS__99999`
2. 点击「查 询」
3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。",""
"ADMIT-TSK-007","基础设置 · 准入管理","准入任务分配 · 列表分页","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 条数据","1. 断言表头含准入类型、产品编号、产品名称、规格型号、规格、型号、器械注册人、操作
2. 断言待分类行操作含「分配类型」","核心列可见;待分类行可发起分配类型。",""
"ADMIT-TSK-008","基础设置 · 准入管理","准入任务分配 · 列表分页","翻至第 2 页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类总数大于每页条数","1. 记录第 1 页首行产品名称或产品编号
2. 点击页码「2」或「下一页」
3. 断言页码为 2,首行与第 1 页不同","翻页成功且数据更换。不足两页则 skip。",""
"ADMIT-TSK-009","基础设置 · 准入管理","准入任务分配 · 列表分页","跳至指定页","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);总页数不少于 2,且存在「跳至」","1. 跳至输入 `2` 并回车
2. 断言当前页为 2","跳转到第 2 页。无跳至或仅 1 页则 skip。",""
"ADMIT-TSK-010","基础设置 · 准入管理","准入任务分配 · 列表分页","第 1 页上一页不可用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);当前在第 1 页","1. 断言「上一页」禁用,或点击后仍停留在第 1 页","第 1 页不能再向前翻。",""
"ADMIT-TSK-011","基础设置 · 准入管理","准入任务分配 · 分配操作","单行分配类型取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 行含「分配类型」","1. 记录该行产品名称,以及待分类计数 `${pendingCount}`
2. 点击「分配类型」
3. 断言分配层打开
4. 取消或关闭,不确认
5. 断言该行仍在待分类,计数仍为 `${pendingCount}`","取消分配不改变分类。本条不点击确定。",""
"ADMIT-TSK-012","基础设置 · 准入管理","准入任务分配 · 分配操作","未勾选时批量分配不可用","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类页无勾选行","1. 点击「批量分配」
2. 断言按钮不可用,或提示先选择数据
3. 断言待分类计数不变","未勾选不分配任何行。",""
"ADMIT-TSK-013","基础设置 · 准入管理","准入任务分配 · 分配操作","勾选后批量分配取消","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 条","1. 勾选 1 行,记录待分类计数
2. 点击「批量分配」
3. 若打开分配层或确认框,点取消或关闭
4. 断言该行仍在待分类,计数不变","取消批量分配不改变分类。本条不确认分配。",""
"ADMIT-TSK-014","基础设置 · 准入管理","准入任务分配 · 分配操作","已分类行不再按待分类分配","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);已分类计数大于 0","1. 切换到「已分类」
2. 断言首行无「分配类型」,或点击后被拒绝且该行仍留在已分类
3. 断言待分类计数不因此增加","已分类数据不会被这次操作退回待分类。已分类为 0 则 skip。",""
"ADMIT-TSK-015","基础设置 · 准入管理","准入任务分配 · 筛选与分类","多条件组合查询(新增日期等11项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载
2. 「新增日期」日期区间选择当天
3. 「操作日期」日期区间选择当天
4. 「销售商」填写关键字
5. 「物料名称」填写关键字
6. 「注册证号」填写关键字
7. 「操作人」填写关键字
8. 「器械注册人」填写关键字
9. 「耗材类型」下拉选择任一可选项
10. 「规格型号」填写关键字
11. 「准入类型」下拉选择任一可选项
12. 「准入状态」下拉选择任一可选项
13. 点击「查询」
14. 检查列表返回结果","多条件同时生效:新增日期、操作日期、销售商、物料名称、注册证号、操作人、器械注册人、耗材类型、规格型号、准入类型、准入状态 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行"
"用例编号","功能模块","功能测试点","用例标题","优先级","前置条件","测试步骤","预期结果","实际结果"
"ADMIT-MLS-001","基础设置 · 准入管理","物料准入单列表 · 页面进入","菜单进入物料准入单列表","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录(已登录);具备「基础设置 → 准入管理」权限;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 按菜单「基础设置 → 准入管理 → 物料准入单列表」进入
2. 断言 URL 含 `/spd/basic/dataSync/dataSync/productOrder/zrlist`,且不是 `productOrder/list`
3. 断言制单日期、查询、状态 Tab、列表表头可见","进入物料准入单列表;不误入产品准入单列表。",""
"ADMIT-MLS-002","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","默认查询列表有数据或空态明确","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 点击「查 询」并等待列表稳定
2. 断言出现数据行,或「暂无数据」且分页总数为 0","查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",""
"ADMIT-MLS-003","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","按准入单号筛选","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);默认查询至少 1 条数据","1. 从首行读取 `${sampleOrderNo}`
2. 若页面有准入单号条件则填入;否则用注册证号或产品名称中首行非空字段
3. 制单日期保持默认,点击「查 询」
4. 断言结果行包含该单号或该关键字","结果与已填单号或关键字匹配。列表筛选区没有单号框时,改用首行注册证号,并在用例备注中记录实际字段。",""
"ADMIT-MLS-004","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","重置恢复默认","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 填入上一条使用的关键字
2. 点击「重 置」
3. 断言筛选恢复默认","筛选恢复默认;列表按默认规则刷新。",""
"ADMIT-MLS-005","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","无匹配结果空态(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 注册证号填入 `__NOT_EXIST_CERT__99999`
2. 点击「查 询」
3. 断言空态且分页总数为 0","无匹配时不报错;总数为 0。",""
"ADMIT-MLS-006","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","列表核心列完整","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);默认查询至少 1 条数据","1. 断言表头含准入类型、准入单号、进院供应商、二级供应商、产品名称、注册证号、耗材类型、产品条数、状态、操作","核心列可见;操作列为「详情」。",""
"ADMIT-MLS-007","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","切换已通过 Tab","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)","1. 点击「已通过」
2. 断言有数据时「状态」列为已通过;计数为 0 时为空态
3. 读取 Tab 括号数量,断言与分页「共 N 条」一致","已通过 Tab 的状态列、计数与分页总数一致。",""
"ADMIT-MLS-008","基础设置 · 准入管理","物料准入单列表 · 单据详情","打开详情后返回","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);默认查询至少 1 条数据","1. 记录首行 `${sampleOrderNo}`、`${sampleProductCount}`
2. 点击「详情」
3. 断言可见「审批进程」「单据日志」「返 回」
4. 点击「返 回」
5. 断言 URL 仍含 `productOrder/zrlist`","进入单据详情并可返回列表。",""
"ADMIT-MLS-009","基础设置 · 准入管理","物料准入单列表 · 单据详情","详情产品条数与列表一致","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开首行详情,且 `${sampleProductCount}` 为正整数","1. 统计详情明细数据行数(跨页则读详情分页总数)
2. 断言等于列表「产品条数」`${sampleProductCount}`","详情明细条数与列表产品条数一致。",""
"ADMIT-MLS-010","基础设置 · 准入管理","物料准入单列表 · 单据详情","审批进程可打开","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开单据详情","1. 点击「审批进程」
2. 断言进程层或区域可见
3. 关闭后仍停留在该单据","审批进程可查看;单据不被关掉。",""
"ADMIT-MLS-011","基础设置 · 准入管理","物料准入单列表 · 单据详情","详情导出不白屏","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开单据详情","1. 点击「导 出」
2. 断言出现下载,或出现导出任务/成功提示
3. 断言页面仍为该单据详情,不白屏","导出有反馈;详情页仍在。无导出按钮则 skip。",""
"ADMIT-MLS-012","基础设置 · 准入管理","物料准入单列表 · 单据详情","详情搜索无匹配(mock反例)","中","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开单据详情","1. 在「请输入产品编码、名称」填入 `__NOT_EXIST_GOODS__99999`
2. 触发搜索
3. 断言明细空态,单据头(审批进程、返回)仍在","无匹配时只清空明细,不销毁单据头。",""
"ADMIT-MLS-013","基础设置 · 准入管理","物料准入单列表 · 单据详情","已通过单据不确认通过","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已通过 Tab 至少 1 条,并打开其详情","1. 若「通过」不可见或禁用,断言该状态保持已通过后返回
2. 若「通过」可点,点击后出现确认框则点「取 消」
3. 返回列表,断言该单状态仍为已通过","本条不把已通过单据再次审批成功。已通过 Tab 无数据则 skip。",""
"ADMIT-MLS-014","基础设置 · 准入管理","物料准入单列表 · 筛选与状态","多条件组合查询(制单日期等6项)","高","测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开","1. 进入页面并等待列表加载
2. 「制单日期」日期区间选择当天
3. 「销售商」填写关键字
4. 「耗材类型」下拉选择任一可选项
5. 「器械注册人」填写关键字
6. 「注册证号」填写关键字
7. 「供应商」填写关键字
8. 点击「查询」
9. 检查列表返回结果","多条件同时生效:制单日期、销售商、耗材类型、器械注册人、注册证号、供应商 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系","待执行"
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment