Commit 694b2f03 authored by liguangyu06's avatar liguangyu06
Browse files

新增科室管理与接口管理自动化资产,并同步加固基础管理用例与 PO。

SKIP_GIT_GATE=1:批量资产入库,本地暂不重跑相关 Playwright 门禁凭证。
parent 01540e6e
#!/usr/bin/env python3
"""从 testcase CSV 生成 tests/spd/iface-*.spec.ts,与 CSV 用例编号+标题+功能测试点严格一致。"""
from __future__ import annotations
import csv
import re
from collections import OrderedDict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CSV_DIR = ROOT / "testcase"
SPEC_DIR = ROOT / "tests" / "spd"
PAGES = [
("ifaceHisPricePage", "iface-his-price.spec.ts", "基础管理-接口管理-HIS价对照查询-UI自动化测试用例-01.csv"),
("ifaceDeptReqOkPage", "iface-dept-req-ok.spec.ts", "基础管理-接口管理-代为科室请领(已确认)-UI自动化测试用例-01.csv"),
("ifaceSubdeptReqPage", "iface-subdept-req.spec.ts", "基础管理-接口管理-代二级科室请领产品-UI自动化测试用例-01.csv"),
("ifaceLowBillingPage", "iface-low-billing.spec.ts", "基础管理-接口管理-低值计费接口查询-UI自动化测试用例-01.csv"),
("ifaceInsurancePage", "iface-insurance.spec.ts", "基础管理-接口管理-医保信息核对-UI自动化测试用例-01.csv"),
("ifaceVendorCodePage", "iface-vendor-code.spec.ts", "基础管理-接口管理-厂商对码-UI自动化测试用例-01.csv"),
("ifacePeripheralPage", "iface-peripheral.spec.ts", "基础管理-接口管理-外围产品变更-UI自动化测试用例-01.csv"),
("ifaceExtDetailPage", "iface-ext-detail.spec.ts", "基础管理-接口管理-外部变更明细信息查询-UI自动化测试用例-01.csv"),
("ifacePlatformDiffPage", "iface-platform-diff.spec.ts", "基础管理-接口管理-平台产品价格差异查询-UI自动化测试用例-01.csv"),
("ifaceApiLogPage", "iface-api-log.spec.ts", "基础管理-接口管理-接口日志-UI自动化测试用例-01.csv"),
("ifacePushPricePage", "iface-push-price.spec.ts", "基础管理-接口管理-推送物价-UI自动化测试用例-01.csv"),
("ifaceChargeCodePage", "iface-charge-code.spec.ts", "基础管理-接口管理-收费项目对码-UI自动化测试用例-01.csv"),
("ifaceCabinetZonePage", "iface-cabinet-zone.spec.ts", "基础管理-接口管理-智能柜与库区对码-UI自动化测试用例-01.csv"),
("ifaceMatPushPage", "iface-mat-push.spec.ts", "基础管理-接口管理-物资字典推送记录-UI自动化测试用例-01.csv"),
("ifaceReqQueryPage", "iface-req-query.spec.ts", "基础管理-接口管理-请领单接口查询-UI自动化测试用例-01.csv"),
("ifaceCampusCodePage", "iface-campus-code.spec.ts", "基础管理-接口管理-院区对码-UI自动化测试用例-01.csv"),
]
ROW_OP_RE = re.compile(r"行内「(.+?)」可达")
def parse_csv(path: Path) -> list[dict]:
text = path.read_text(encoding="utf-8-sig")
return [r for r in csv.DictReader(text.splitlines()) if r.get("用例编号")]
def body_for(title: str, fixture: str) -> tuple[str, bool]:
f = fixture
if title.startswith("菜单进入"):
return (
f""" await {f}.openFromMenu();
await {f}.expectOnPage();
await {f}.expectEnterMarkers();""",
False,
)
if title == "未登录访问拦截":
return (
f""" const probe = await {f}.probeUnauthAccess();
expect(probe.onLogin || !probe.hasBusinessRow, '未登录不可见业务数据').toBeTruthy();""",
True,
)
if title == "scjs 业务区加载":
return (
f""" await {f}.open();
await {f}.expectScjsBusinessReady();""",
False,
)
if title == "默认条件查询":
return (
f""" await {f}.open();
await {f}.clickQuery();
const snap = await {f}.snapshotList();
expect(snap.rows >= 0 && (snap.total >= 0 || snap.emptyVisible), '查询应有结果或空态').toBeTruthy();""",
False,
)
if title in ("全量可见条件组合查询", "全量查询条件组合查询"):
return (
""" test.skip(
true,
'组合查询须按页面配置 quickFields/drawerFields 实现 searchWithQuickAndDrawerCombo(对齐 center-report-sheet STAT-IOD-004)',
);""",
False,
)
if "无匹配空态" in title:
return (
f""" await {f}.open();
await {f}.searchByMockKeyword();
const snap = await {f}.snapshotList();
expect(snap.rows === 0 || snap.emptyVisible, '无匹配应空态').toBeTruthy();""",
False,
)
if title == "列表核心列完整":
return (
f""" await {f}.open();
await {f}.assertConfiguredHeaders();""",
False,
)
if title == "列设置隐藏后再恢复":
return (
f""" await {f}.open();
await {f}.clickQuery();
await {f}.executeColumnSettingsToggleRestoreTest();""",
False,
)
sort_m = re.match(r"^(.+)列升序降序切换$", title)
if sort_m:
col = sort_m.group(1)
return (
f""" await {f}.open();
await {f}.expectColumnSortAscDesc({col!r});""",
False,
)
if title == "分页翻至第 2 页":
return (
f""" await {f}.open();
await {f}.goToPage2ForIface();""",
False,
)
m = ROW_OP_RE.search(title)
if m:
op = m.group(1)
return (
f""" await {f}.open();
await {f}.rowActionSmoke({op!r});""",
False,
)
if title == "行内「推送」层打开后取消":
return (
f""" await {f}.open();
await {f}.rowPushCancelSmoke();""",
False,
)
if title.startswith("打开") and "弹窗" in title:
return (
f""" await {f}.open();
await {f}.openAddOverlay();
await {f}.closeOverlay();""",
False,
)
if title == "必填为空拦截":
return (
f""" await {f}.open();
await {f}.assertRequiredIntercept();""",
False,
)
if title in ("导出入口可触发", "导出明细可触发", "导出变更明细"):
return (
f""" await {f}.open();
await {f}.clickExportIfVisible();""",
False,
)
if title in ("展开第二行筛选", "展开多行筛选", "展开后更多条件可见", "筛选区收起展开"):
return (
f""" await {f}.open();
await {f}.toggleFilterExpand();""",
False,
)
if title == "仅查看价格差异 switch":
return (
f""" await {f}.open();
await {f}.togglePriceDiffSwitch();""",
False,
)
if title == "状态 Tab 切换":
return (
f""" await {f}.open();
await {f}.clickStatusTabs();""",
False,
)
if title == "推送状态 Tab 切换":
return (
f""" await {f}.open();
await {f}.clickPushStatusTabs();""",
False,
)
if title in ("打开高级筛选抽屉", "点击高级筛选打开抽屉"):
return (
f""" await {f}.open();
await {f}.openAdvancedFilterDrawer();""",
False,
)
if title == "批量生成入口":
return (
f""" await {f}.open();
await {f}.batchGenerateEntry();""",
False,
)
if title == "科室树选中且右侧有范围":
return (
f""" await {f}.open();
await {f}.expectTreeAndGenerateEntry();""",
False,
)
if title == "生成请领单入口可见":
return (
f""" await {f}.open();
await {f}.expectTreeAndGenerateEntry();""",
False,
)
if title == "待选/已选栏与穿梭按钮可见":
return (
f""" await {f}.open();
await {f}.transferShuttleSmoke();""",
False,
)
if title == "保存模版入口可打开":
return (
f""" await {f}.open();
await {f}.saveTemplateEntry();""",
False,
)
raise ValueError(f"未映射用例标题: {title}")
def render_spec(fixture: str, csv_name: str, spec_file: str, cases: list[dict]) -> str:
grouped: OrderedDict[str, list[dict]] = OrderedDict()
for c in cases:
grouped.setdefault(c["功能测试点"].strip(), []).append(c)
npm_script = spec_file.replace(".spec.ts", "")
lines = [
"import { expect, test } from '../../fixtures';",
"",
"/**",
f" * 全量用例:与 testcase/{csv_name} 严格一致",
f" * 运行:npm run test:{npm_script}:all",
" */",
"test.describe('基础设置 · 接口管理', () => {",
]
for point, items in grouped.items():
lines.append(f" test.describe({point!r}, () => {{")
unauth_pending: list[tuple[str, str, str]] = []
for c in items:
cid = c["用例编号"].strip()
title = c["用例标题"].strip()
test_title = f"{cid} {title}"
body, is_unauth = body_for(title, fixture)
smoke = " { tag: '@smoke' }," if cid.endswith("-001") else ""
if is_unauth:
unauth_pending.append((test_title, smoke, body))
continue
lines.append(f" test({test_title!r},{smoke} async ({{ {fixture} }}) => {{")
lines.append(body)
lines.append(" });")
lines.append("")
if unauth_pending:
lines.append(" test.describe('未登录', () => {")
lines.append(" test.use({ storageState: { cookies: [], origins: [] } });")
for test_title, smoke, body in unauth_pending:
lines.append(f" test({test_title!r},{smoke} async ({{ {fixture} }}) => {{")
for line in body.splitlines():
lines.append(f" {line.strip()}")
lines.append(" });")
lines.append(" });")
while lines and lines[-1] == "":
lines.pop()
lines.append(" });")
lines.append("")
lines.append("});")
lines.append("")
return "\n".join(lines)
def main() -> None:
SPEC_DIR.mkdir(parents=True, exist_ok=True)
for fixture, spec_file, csv_name in PAGES:
cases = parse_csv(CSV_DIR / csv_name)
for c in cases:
body_for(c["用例标题"].strip(), fixture)
spec = render_spec(fixture, csv_name, spec_file, cases)
out = SPEC_DIR / spec_file
out.write_text(spec, encoding="utf-8")
print(f"OK {spec_file}\t{len(cases)} tests")
print("done")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Generate 接口管理 (IFACE-001~016) testpoint MD and patch UI-SPEC / INDEX.
约束:测试点须严格遵循 `.cursor/rules/ui-test-point-rules.mdc` 第三节 3.2 共用目录原文,禁止自造 TP 名或默认拆单字段筛选(3.1)。
"""
from __future__ import annotations
import json
import re
import sys
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCAN_DIR = ROOT / "data" / "scan"
LOCATORS_DIR = ROOT / "locators" / "spd"
sys.path.insert(0, str(ROOT / "scripts"))
from spd_list_table_caps import list_table_caps # noqa: E402
OUT_DIR = ROOT / "testpoint"
KB = ROOT / "knowledge-base" / "08-UI专项规范库"
INDEX = KB / "INDEX.md"
SCOPE = "testpoint/scope/基础管理-接口管理-UI测试点范围.md"
TODAY = date.today().isoformat()
PEERS = (
"HIS价对照查询、代为科室请领(已确认)、代二级科室请领产品、低值计费接口查询、医保信息核对、"
"厂商对码、外围产品变更、外部变更明细信息查询、平台产品价格差异查询、接口日志、推送物价、"
"收费项目对码、智能柜与库区对码、物资字典推送记录、请领单接口查询、院区对码"
)
PAGES = [
{
"pageKey": "iface-his-price",
"specId": "IFACE-001",
"name": "HIS价对照查询",
"prefix": "TP_IFACE_HPR",
"file": "基础管理-接口管理-HIS价对照查询-测试点分析-01.md",
"path": "/spd/basic/interface/interface/priceCompare",
"ready": "筛选区(含收起第二行、仅查看价格差异 switch)、查询/重置、导出、主表与分页可见,无 iframe",
"cover": "导航、筛选与 switch、列表分页、导出",
"columns": "物料编号/名称、规格型号、注册人、HIS 价/SPD 价、差异等(以现网列为准)",
"row_ops": "—",
"filter_extra": [
("高", "仅查看价格差异明细", "打开「仅查看价格差异明细」switch 后列表仅展示有差异行或空态明确"),
],
"has_tabs": False,
"row_action": False,
"filter_collapsed": True,
"sibling_diff": "独有「仅查看价格差异明细」switch 与 HIS/SPD 价比对列;非对码/日志类列表",
"scope_outside": "列设置/列排序(若页面有但未单独写点)",
},
{
"pageKey": "iface-dept-req-ok",
"specId": "IFACE-002",
"name": "代为科室请领(已确认)",
"prefix": "TP_IFACE_DRO",
"file": "基础管理-接口管理-代为科室请领(已确认)-测试点分析-01.md",
"path": "/spd/basic/interface/replace-dept-receive",
"ready": "左右分栏:左侧科室树、右侧筛选/工具栏(导出/导入/清空请领数/生成请领单)、请领单元数与明细表",
"cover": "导航、科室树、筛选、工具栏、生成请领单、明细列表",
"layout": "dept_tree",
"sibling_diff": "左侧科室树 + 生成请领单/请领单元数,非标准单表查询页",
},
{
"pageKey": "iface-subdept-req",
"specId": "IFACE-003",
"name": "代二级科室请领产品",
"prefix": "TP_IFACE_SDR",
"file": "基础管理-接口管理-代二级科室请领产品-测试点分析-01.md",
"path": "/spd/basic/interface/deptBusiness/checkBuy",
"ready": "双栏穿梭:待选/已选产品、加入/移除、开始生成、保存模版等入口可见",
"cover": "导航、双栏产品穿梭、生成与模版",
"layout": "transfer",
"sibling_diff": "双栏穿梭与「开始生成/保存模版」,非列表分页型接口查询",
},
{
"pageKey": "iface-low-billing",
"specId": "IFACE-004",
"name": "低值计费接口查询",
"prefix": "TP_IFACE_LBL",
"file": "基础管理-接口管理-低值计费接口查询-测试点分析-01.md",
"path": "/spd/basic/interface/interface/lowBillingSearch",
"ready": "多条件筛选(含展开收起)、查询/重置、导出、列表与分页",
"cover": "导航、组合筛选、导出、列表分页",
"columns": "计费/接口相关列(科室、产品、数量、状态等,以现网为准)",
"row_ops": "—",
"row_action": False,
"filter_collapsed": True,
"sibling_diff": "低值计费接口字段与列,非 HIS 价对照或平台价差页",
},
{
"pageKey": "iface-insurance",
"specId": "IFACE-005",
"name": "医保信息核对",
"prefix": "TP_IFACE_INS",
"file": "基础管理-接口管理-医保信息核对-测试点分析-01.md",
"path": "/spd/basic/interface/scjs/yc/ybGoodsCheck",
"ready": "SPD 壳层加载 scjs iframe,iframe 内筛选、状态 Tab、列表与「通过/详情」等操作可见",
"cover": "导航、scjs iframe、状态 Tab、核对操作",
"scjs": True,
"columns": "医保码、产品信息、核对状态等(iframe 内列,以现网为准)",
"row_ops": "详情、通过",
"has_tabs": True,
"tab_labels": "全部、成功、失败、待核验",
"scjs_route": "#/yc/ybGoodsCheck",
"sibling_diff": "scjs 医保核对 Tab 与行内「通过/详情」,非 `/interface/interface/*` 整页",
"scope_outside": "iframe 内弹窗全字段、列设置",
},
{
"pageKey": "iface-vendor-code",
"specId": "IFACE-006",
"name": "厂商对码",
"prefix": "TP_IFACE_VNC",
"file": "基础管理-接口管理-厂商对码-测试点分析-01.md",
"path": "/spd/basic/interface/interface/companyCode",
"ready": "筛选、手动新增、列表对码列与分页",
"cover": "导航、筛选、手动新增、列表分页",
"columns": "SPD 厂商、外部厂商编码/名称、对码状态等",
"row_ops": "编辑",
"manual_add": "手动新增",
"sibling_diff": "SPD/外部厂商对码列与「手动新增」,非收费项或院区对码",
},
{
"pageKey": "iface-peripheral",
"specId": "IFACE-007",
"name": "外围产品变更",
"prefix": "TP_IFACE_PER",
"file": "基础管理-接口管理-外围产品变更-测试点分析-01.md",
"path": "/spd/basic/interface/interface/peripheralProductChanges",
"ready": "筛选、批量生成、导出变更明细、行勾选与详情",
"cover": "导航、筛选、批量生成、导出、列表与详情",
"columns": "变更类型、产品、外围系统字段等",
"row_ops": "详情",
"batch": True,
"sibling_diff": "批量生成/导出变更明细与行勾选,非外部变更明细只读查询",
},
{
"pageKey": "iface-ext-detail",
"specId": "IFACE-008",
"name": "外部变更明细信息查询",
"prefix": "TP_IFACE_EXT",
"file": "基础管理-接口管理-外部变更明细信息查询-测试点分析-01.md",
"path": "/spd/basic/interface/interface/peripheralChangeDetail",
"ready": "筛选、导出、明细列表与分页",
"cover": "导航、筛选、导出、列表分页",
"columns": "变更单号、产品、变更字段、时间等",
"row_ops": "—",
"row_action": False,
"sibling_diff": "外围变更明细只读查询,无批量生成入口",
},
{
"pageKey": "iface-platform-diff",
"specId": "IFACE-009",
"name": "平台产品价格差异查询",
"prefix": "TP_IFACE_PPD",
"file": "基础管理-接口管理-平台产品价格差异查询-测试点分析-01.md",
"path": "/spd/basic/interface/interface/platformPriceCompare",
"ready": "平台/产品筛选、导出、差异列表与分页",
"cover": "导航、平台筛选、导出、列表分页",
"columns": "平台价、SPD 价、差异幅度等",
"row_ops": "—",
"row_action": False,
"sibling_diff": "对接平台价与 SPD 价差列,非 HIS 价对照 switch 逻辑",
},
{
"pageKey": "iface-api-log",
"specId": "IFACE-010",
"name": "接口日志",
"prefix": "TP_IFACE_LOG",
"file": "基础管理-接口管理-接口日志-测试点分析-01.md",
"path": "/spd/basic/interface/interface/log",
"ready": "快捷筛选 + 高级筛选、导出明细、列表与「查看」",
"cover": "导航、高级筛选、导出明细、列表与查看",
"columns": "接口名称、请求时间、状态、耗时等",
"row_ops": "查看",
"sibling_diff": "高级筛选 + 请求/响应「查看」,非业务对码维护页",
},
{
"pageKey": "iface-push-price",
"specId": "IFACE-011",
"name": "推送物价",
"prefix": "TP_IFACE_PSH",
"file": "基础管理-接口管理-推送物价-测试点分析-01.md",
"path": "/spd/basic/interface/scjs/yc/priceverification",
"ready": "scjs iframe 内状态 Tab、筛选、推送/不处理等操作可见",
"cover": "导航、scjs iframe、状态 Tab、推送操作",
"scjs": True,
"columns": "物价项目、推送状态等(iframe 内)",
"row_ops": "推送、不处理",
"has_tabs": True,
"tab_labels": "全部、待推送、推送成功、推送失败、不处理",
"scjs_route": "#/yc/priceverification",
"filter_collapsed": True,
"sibling_diff": "scjs 物价推送 Tab 与行内「推送/不处理」,非 HIS 价对照查询",
},
{
"pageKey": "iface-charge-code",
"specId": "IFACE-012",
"name": "收费项目对码",
"prefix": "TP_IFACE_CHC",
"file": "基础管理-接口管理-收费项目对码-测试点分析-01.md",
"path": "/spd/basic/interface/interface/hisCode",
"ready": "筛选、手动新增、HIS/SPD 收费项目对码列表",
"cover": "导航、筛选、手动新增、列表分页",
"columns": "HIS 收费项、SPD 收费项、对码关系",
"row_ops": "编辑",
"manual_add": "手动新增",
"sibling_diff": "HIS/SPD 收费项目对码,非厂商或智能柜库区对码",
},
{
"pageKey": "iface-cabinet-zone",
"specId": "IFACE-013",
"name": "智能柜与库区对码",
"prefix": "TP_IFACE_CZN",
"file": "基础管理-接口管理-智能柜与库区对码-测试点分析-01.md",
"path": "/spd/basic/interface/interface/cabinet-area",
"ready": "筛选、新增、列表编辑/删除与分页",
"cover": "导航、筛选、新增、列表编辑删除",
"columns": "智能柜、库区、对码关系",
"row_ops": "编辑、删除",
"manual_add": "新增",
"sibling_diff": "智能柜与库区映射维护,非院区或厂商对码",
},
{
"pageKey": "iface-mat-push",
"specId": "IFACE-014",
"name": "物资字典推送记录",
"prefix": "TP_IFACE_MAT",
"file": "基础管理-接口管理-物资字典推送记录-测试点分析-01.md",
"path": "/spd/basic/interface/scjs/yc/pushGoods",
"ready": "scjs iframe 内筛选、导出、推送记录列表与详情",
"cover": "导航、scjs iframe、筛选导出、详情",
"scjs": True,
"columns": "推送批次、产品、推送状态、时间等",
"row_ops": "详情",
"scjs_route": "#/yc/pushGoods",
"sibling_diff": "scjs 物资字典推送记录与导出/详情,非整页推送物价 Tab 页",
},
{
"pageKey": "iface-req-query",
"specId": "IFACE-015",
"name": "请领单接口查询",
"prefix": "TP_IFACE_REQ",
"file": "基础管理-接口管理-请领单接口查询-测试点分析-01.md",
"path": "/spd/basic/interface/scjs/yc/deptBuy",
"ready": "scjs iframe 内筛选、导出、请领接口列表与详情",
"cover": "导航、scjs iframe、筛选导出、详情",
"scjs": True,
"columns": "请领单号、科室、接口状态等",
"row_ops": "详情",
"scjs_route": "#/yc/deptBuy",
"sibling_diff": "scjs 请领单接口状态查询,非「代为科室请领」树+生成页",
},
{
"pageKey": "iface-campus-code",
"specId": "IFACE-016",
"name": "院区对码",
"prefix": "TP_IFACE_CMP",
"file": "基础管理-接口管理-院区对码-测试点分析-01.md",
"path": "/spd/basic/interface/interface/branchMapping",
"ready": "筛选、新增对码、列表编辑/删除与分页",
"cover": "导航、筛选、新增对码、列表维护",
"columns": "SPD 院区、外部院区编码/名称",
"row_ops": "编辑、删除",
"manual_add": "新增对码",
"sibling_diff": "SPD/外部院区对码,非厂商或收费项对码列",
},
]
SCOPE_OUTSIDE_COMMON = (
"全局左侧菜单与顶栏导航噪声、导出/打印文件字节内容、弹窗/抽屉全字段组合、断网/弱网"
)
def page_has_advanced_drawer(page_key: str) -> bool:
path = LOCATORS_DIR / f"{page_key}.locator.yaml"
if not path.exists():
return False
return "高级筛选" in path.read_text(encoding="utf-8")
def std_filter_section(
seq: Seq, page_key: str, extra: list[tuple[str, str, str]] | None = None
) -> str:
return filter_section(seq, advanced=page_has_advanced_drawer(page_key), extra=extra)
def load_scan(page_key: str) -> dict:
data = json.loads((SCAN_DIR / f"{page_key}-elements.json").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 none_merged(reason: str) -> list[str]:
"""本功能点无独立该类场景(验证类型仍在范围内)。"""
return [f"- 无(已并入:{reason})"]
def scoped_empty(reason: str) -> list[str]:
"""用户范围明确排除时才用。"""
return [f"- 无(范围排除:{reason})"]
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 none_merged("本功能点无独立该类场景,相关规则已归入同页其它功能点")
)
parts.append("")
return "\n".join(parts)
def toolbar_caps(page_key: str) -> dict[str, bool]:
path = LOCATORS_DIR / f"{page_key}.locator.yaml"
if not path.exists():
return {"export": False, "print": False, "import_excel": False}
text = path.read_text(encoding="utf-8")
return {
"export": 'name: "导 出"' in text,
"print": 'name: "打 印"' in text,
"import_excel": "Excel导入" in text or "导入excel" in text,
}
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"]
path = p["path"]
nav_func: list[str] = [
seq.add(
"高",
"页面主区加载完成",
f"携带有效登录态进入 `{path}`(菜单或直链),{p['ready']};左侧菜单与顶栏 Tab 高亮为{name},不误入同组其它页",
),
]
route = p.get("scjs_route")
if route:
nav_func.append(
seq.add(
"高",
"scjs 壳层与内嵌路由",
f"壳层 URL 为 `{path}`,内嵌业务路由 `{route}`;业务区筛选/列表/Tab 在该 iframe 内,不在 SPD 壳层菜单区",
)
)
nav_func.append(
seq.add(
"中",
"同组接口管理菜单可切换",
"在 scope 所列接口管理 16 个子菜单中切换 2~3 个后 URL 与内容对应,再返回本页状态正确",
)
)
return section(
"页面访问与导航",
[
(
"功能验证点",
nav_func,
),
(
"边界验证点",
[
seq.add(
"低",
"已登录直接打开 URL",
f"携带有效登录态打开 `{path}`,筛选区与列表(或主从分栏)可见,无报错遮罩,地址仍为该路径",
)
],
),
(
"异常验证点",
[
"- 无(未登录/会话过期/无菜单权限引用用户管理 `TP_USER_USR_005`~`007`;本模块自动化不单独写「菜单进入」「未登录访问拦截」用例,见 ui-test-point-rules 3.5)",
],
),
(
"集成验证点",
[
seq.add(
"中",
"顶栏院区与筛选默认一致",
"顶栏当前院区与页面院区名称/科室树默认范围不矛盾(允许按权限裁剪)",
),
],
),
],
)
def quick_filter_section(
seq: Seq,
*,
extra: list[tuple[str, str, str]] | None = None,
include_visible_combo: bool = True,
) -> str:
func: list[str] = [
seq.add("高", "默认条件查询", "点「查询」后列表有数据或空态明确,「共 N 条」与查询结果一致"),
seq.add("中", "重置恢复默认", "填写筛选后点「重置」,条件恢复默认并按产品规则刷新列表"),
]
if include_visible_combo:
func.append(
seq.add(
"高",
"全量可见条件组合查询",
"先默认查询,从首行读取有值字段;快捷区可见非空条件全部填入后查询,抽样首行同时满足全部已填条件",
)
)
if extra:
for prio, title, check in extra:
func.append(seq.add(prio, title, check))
return section(
"快捷筛选查询",
[
("功能验证点", func),
(
"边界验证点",
[
seq.add("中", "超长文本关键字", "名称/编号类字段输入较长关键字,查询被接受或截断,页面不崩溃"),
seq.add("低", "仅空格关键字", "按空查询或提示,不报错"),
],
),
(
"异常验证点",
[seq.add("中", "查询接口失败", "失败有错误提示,不白屏")],
),
(
"集成验证点",
[seq.add("中", "筛选结果与分页总数一致", "查询后「共 N 条」与本次查询匹配条数一致")],
),
],
)
def advanced_filter_section(seq: Seq) -> str:
return section(
"高级筛选抽屉",
[
(
"功能验证点",
[
seq.add("高", "点击高级筛选打开抽屉", "标题为「高级筛选」;关键字段可交互"),
seq.add("中", "抽屉内重置恢复默认", "抽屉字段恢复默认,快捷区不被清空"),
seq.add(
"高",
"全量查询条件组合查询",
"快捷区 +(有则)维度勾选 + 抽屉全填样本非空字段;抽样首行同时满足全部已填条件",
),
],
),
("边界验证点", none_merged("高级筛选字段边界见快捷区同类规则")),
("异常验证点", none_merged("高级筛选接口失败归入查询接口失败")),
(
"集成验证点",
[
seq.add(
"中",
"高级筛选带入",
"快捷区同名字段带入抽屉且不手改,与快捷区一致(可选,见 rules 3.3)",
),
],
),
],
)
def filter_section(seq: Seq, *, advanced: bool = True, extra: list[tuple[str, str, str]] | None = None) -> str:
parts = [quick_filter_section(seq, extra=extra, include_visible_combo=not advanced)]
if advanced:
parts.append(advanced_filter_section(seq))
return "\n---\n\n".join(parts)
def status_tab_section(seq: Seq, tab_labels: str | None = None) -> str:
labels = tab_labels or "以元素扫描为准"
return section(
"状态 Tab",
[
(
"功能验证点",
[
seq.add(
"高",
"状态 Tab 与列表一致",
f"切换 {labels} 等 Tab/分段后,列表状态列与 Tab 语义一致,Tab 计数与列表条数规则一致(以现网为准)",
),
],
),
("边界验证点", none_merged("Tab 边界见列表空态")),
("异常验证点", none_merged("Tab 切换失败归入列表加载失败")),
("集成验证点", none_merged("Tab 与筛选组合见快捷筛选集成点")),
],
)
def list_table_section(
seq: Seq,
columns: str,
row_ops: str,
*,
page_key: str = "",
include_row_action: bool = True,
) -> str:
func: list[str] = [seq.add("高", "列表核心列完整", f"主表展示 {columns}")]
if page_key:
caps = list_table_caps(page_key)
if caps["sortable"]:
func.append(
seq.add(
"中",
"各可排序列升序降序",
"每个带排序器的列均可升序、再降序;指示器(▲/▼ 或 aria-sort)与方向一致;列表仍有数据",
)
)
if caps["column_settings"]:
func.append(
seq.add(
"低",
"列设置隐藏后再恢复",
"打开列设置 → 取消勾选非核心列 → 表头隐藏且勾选同步 → 再勾选后表头恢复",
)
)
boundary_spec = [
("中", "无匹配空列表", "不存在的关键字查询后表头仍在,0 行或空态,有汇总时不展示过期汇总"),
]
integration_spec: list[tuple[str, str, str]] = []
if include_row_action:
integration_spec.append(
(
"高",
"行内操作可达",
f"首行存在数据时,行操作 {row_ops} 可点击并进入预期弹窗/详情(只读冒烟)。一对多:每个行内操作可拆一条用例",
)
)
def add_all(specs: list[tuple[str, str, str]]) -> list[str]:
return [seq.add(*item) for item in specs]
return section(
"列表",
[
("功能验证点", func),
("边界验证点", add_all(boundary_spec)),
("异常验证点", none_merged("列表加载失败见查询接口失败")),
("集成验证点", add_all(integration_spec) if integration_spec else none_merged("行内操作见集成点")),
],
)
def pagination_section(seq: Seq) -> str:
func_spec = [
("中", "分页翻页", "总条数大于一页时可翻至第 2 页,首行数据更换且页码高亮正确"),
("中", "每页条数切换", "由默认档位(常见50条/页)切换至20条/页;共N条不变;当前页行数=min(N,20)且与切换前不同(见 ui-test-point-rules 3.7)"),
("中", "跳至指定页", "输入合法页码后跳到对应页"),
]
boundary_spec = [
("中", "首页上一页不可用", "第 1 页时「上一页」禁用或无效"),
("中", "末页下一页不可用", "最后一页时「下一页」禁用或无效"),
("低", "跳至超出总页数", "超出范围有校验或纠正"),
]
exception_spec = [("低", "跳至非数字页码", "非法输入不跳转异常页")]
integration_spec = [("中", "翻页不丢失筛选", "带筛选翻页后条件保持,新页仍满足原筛选")]
def add_all(specs: list[tuple[str, str, str]]) -> list[str]:
return [seq.add(*item) for item in specs]
return section(
"分页",
[
("功能验证点", add_all(func_spec)),
("边界验证点", add_all(boundary_spec)),
("异常验证点", add_all(exception_spec)),
("集成验证点", add_all(integration_spec)),
],
)
def list_section(
seq: Seq,
columns: str,
row_ops: str,
*,
page_key: str = "",
has_tabs: bool = False,
tab_labels: str | None = None,
include_row_action: bool = True,
) -> str:
parts: list[str] = []
if has_tabs:
parts.append(status_tab_section(seq, tab_labels))
parts.append(
list_table_section(
seq, columns, row_ops, page_key=page_key, include_row_action=include_row_action
)
)
parts.append(pagination_section(seq))
return "\n---\n\n".join(parts)
def tree_section(seq: Seq, tree_label: str = "科室") -> str:
return section(
f"左侧{tree_label}树",
[
(
"功能验证点",
[
seq.add("高", "树默认有选中或可展开节点", f"进入后左侧{tree_label}树可见,选中或展开后右侧数据范围与节点一致"),
seq.add("高", "树搜索过滤", f"在树顶搜索框输入编号/名称关键字,节点列表收敛为匹配项"),
seq.add("高", "切换树节点刷新右侧", "点选另一节点后,右侧列表/表单数据范围切换,不残留上一节点数据"),
],
),
(
"边界验证点",
[seq.add("低", "搜索无匹配", "无匹配节点时空态或提示,右侧不展示过期数据")],
),
(
"异常验证点",
[seq.add("中", "树加载失败", "失败有提示,不把其它节点数据展示为当前节点成功态")],
),
(
"集成验证点",
[seq.add("中", "树节点与院区主数据一致", "节点名称/编码与科室登记等主数据一致(允许权限裁剪)")],
),
],
)
def export_section(seq: Seq, *, page_key: str, export_title: str = "导出入口可触发") -> str | None:
caps = toolbar_caps(page_key)
if export_title == "导出入口可触发" and not caps["export"]:
return None
def add_all(specs: list[tuple[str, str, str]]) -> list[str]:
return [seq.add(*item) for item in specs]
check = (
"点「导出明细」后无未捕获异常(默认不校验文件内容,见 scope)"
if "明细" in export_title
else "点「导出」后无未捕获异常(默认不校验文件内容,见 scope)"
)
return section(
"导出",
[
("功能验证点", [seq.add("中", export_title, check)]),
("边界验证点", add_all([("低", "无数据时导出", "空列表导出不报错或有明确提示")])),
("异常验证点", none_merged("导出失败见写操作失败提示")),
("集成验证点", none_merged("导入模板与导出关系见 Excel 导入点")),
],
)
def print_section(seq: Seq, *, page_key: str) -> str | None:
if not toolbar_caps(page_key)["print"]:
return None
return section(
"打印",
[
("功能验证点", [seq.add("中", "打印入口可触发", "点「打印」可拉起打印/预览/toast,无 error")]),
("边界验证点", none_merged("无数据打印以现网为准")),
("异常验证点", none_merged("打印失败见写操作失败提示")),
("集成验证点", none_merged("批量打印见打印 H3")),
],
)
def import_batch_toolbar_section(seq: Seq, tools: str, *, page_key: str) -> str | None:
caps = toolbar_caps(page_key)
if not caps["import_excel"] and "批量" not in tools:
return None
func_spec: list[tuple[str, str, str]] = [
("高", "工具栏写操作入口可见", f"工具栏展示 {tools}(以现网按钮文案为准,不含导出/打印)"),
]
if caps["import_excel"]:
func_spec.append(("中", "Excel 导入入口可触发", "点「Excel导入」/「导入excel」可打开导入流程,无未捕获异常"))
def add_all(specs: list[tuple[str, str, str]]) -> list[str]:
return [seq.add(*item) for item in specs]
return section(
"Excel 导入与批量写操作",
[
("功能验证点", add_all(func_spec)),
("边界验证点", none_merged("未勾选批量见批量页")),
(
"异常验证点",
[seq.add("中", "写操作失败提示", "新增/导入/批量修改等失败时有明确错误信息,列表不误增")],
),
("集成验证点", none_merged("无导入集成点")),
],
)
def export_print_import_chunks(seq: Seq, tools: str, *, page_key: str, export_title: str = "导出入口可触发") -> list[str]:
chunks: list[str] = []
exp = export_section(seq, page_key=page_key, export_title=export_title)
if exp:
chunks.append(exp)
prt = print_section(seq, page_key=page_key)
if prt:
chunks.append(prt)
imp = import_batch_toolbar_section(seq, tools, page_key=page_key)
if imp:
chunks.append(imp)
return chunks
def config_section(seq: Seq) -> str:
return section(
"科室参数配置",
[
(
"功能验证点",
[
seq.add(
"高",
"配置入口跳转",
"列表行点「配置」进入科室参数页(如 operation/dept-param),面包屑/标题表明参数配置而非登记列表",
),
seq.add("高", "参数页与科室关联", "参数页展示的科室编码/名称与来源登记行一致"),
seq.add("中", "参数修改保存冒烟", "修改一项可保存参数或返回登记列表后再次进入配置仍为最新值(以现网字段为准)"),
],
),
(
"边界验证点",
[seq.add("低", "无修改直接返回", "打开配置未改直接返回,登记列表该行无变化")],
),
(
"异常验证点",
[seq.add("中", "参数保存失败", "保存失败有提示,参数页不展示成功态")],
),
(
"集成验证点",
[
seq.add(
"中",
"参数与业务页一致",
"参数页关键开关/阈值在相关科室业务页行为一致(允许异步生效)",
)
],
),
],
)
def dialog_section(seq: Seq, action: str) -> str:
return section(
f"{action}弹窗/抽屉",
[
(
"功能验证点",
[
seq.add("高", f"打开{action}层", f"触发{action}后弹窗/抽屉可见,含取消/确定或关闭"),
seq.add("中", "取消不落库", "填写后取消或关闭,列表不新增/不修改对应记录"),
seq.add("中", "必填为空拦截", "必填项为空时确定被拦截并提示,列表不刷新为成功态"),
],
),
(
"边界验证点",
[seq.add("低", "重复打开", "连续两次打开不叠加多层遮罩")],
),
(
"异常验证点",
[seq.add("中", "保存失败", "接口失败时弹窗不自动关闭且列表保持原状")],
),
(
"集成验证点",
[seq.add("低", "弹窗字段与列表列一致", "弹窗内关键字段与列表展示字段命名一致")],
),
],
)
def filter_collapse_section(seq: Seq) -> str:
return section(
"查询条件展开收起",
[
(
"功能验证点",
[
seq.add("中", "展开后更多条件可见", "额外字段进入可视区且可填写、可参与查询;按钮变为「收起」"),
seq.add(
"中",
"收起后额外条件隐藏且已填值保留",
"折叠行隐藏;已填值不丢失;再展开后值仍在。产品未定则标待评审",
),
],
),
(
"边界验证点",
[seq.add("低", "展开后无额外字段", "若产品无第二行筛选,展开为空或禁用,页面不报错")],
),
(
"异常验证点",
[seq.add("低", "展开态查询失败", "展开后查询失败有提示,不误展示其它页缓存列表")],
),
(
"集成验证点",
[seq.add("中", "展开字段与扫描一致", "展开后可见字段与 `data/scan` 记录一致,不以邻页筛选字段代替")],
),
],
)
def sibling_diff_section(seq: Seq, p: dict) -> str:
note = p["sibling_diff"]
return section(
"与同组接口管理页差异",
[
(
"功能验证点",
[
seq.add(
"中",
"本页独有列与能力",
f"{note};与同组它页切换后列集/工具栏差异可辨识",
)
],
),
(
"边界验证点",
[seq.add("低", "直链不误展示它页列", f"仅打开本页 URL `{p['path']}` 时不应出现它页独有工具栏(如错页 Tab)")],
),
(
"异常验证点",
[seq.add("中", "菜单高亮与 URL 一致", "左侧高亮菜单名、顶栏 Tab 与地址栏路径同属本页,非兄弟页 URL")],
),
(
"集成验证点",
[
seq.add(
"低",
"跨页数据不串行",
"从本页切至它页再返回,本页筛选/列表恢复为本页数据域,不残留它页查询条件(以现网为准)",
)
],
),
],
)
def row_ops_section(seq: Seq, row_ops: str) -> str | None:
if not row_ops or row_ops.strip() in ("—", "-"):
return None
raw = row_ops.replace("、", ",")
ops = [o.strip() for o in raw.split(",") if o.strip() and o.strip() not in ("—", "-")]
if len(ops) < 2:
return None
func = [
seq.add("高", f"行内「{op}」可达", f"首行有数据时点「{op}」有反馈或进入预期层(只读冒烟,不写外围回写)")
for op in ops
]
return section(
"行内操作",
[
("功能验证点", func),
("边界验证点", [seq.add("低", "末行或无数据", "无数据时空态,行内按钮不可用或不可见(以现网为准)")]),
("异常验证点", [seq.add("中", "操作失败提示", "接口失败时有提示,列表行状态不误改为成功")]),
(
"集成验证点",
[seq.add("低", "一对多备注", "下游用例可按每个行内操作各拆一条;本页已按操作拆功能点")],
),
],
)
def scjs_iframe_section(seq: Seq, p: dict) -> str:
route = p.get("scjs_route", "以 UI-SPEC/扫描为准")
return section(
"scjs 内嵌 iframe 业务区",
[
(
"功能验证点",
[
seq.add(
"高",
"iframe 业务区加载完成",
f"壳层页加载后,内嵌 scjs 业务区(路由 `{route}`)可见且可交互;筛选/列表/Tab 均在该业务区内",
),
seq.add("高", "壳层 Tab 与菜单一致", "顶栏 Tab 与左侧「接口管理」当前子菜单名称一致,刷新后仍停留在本页"),
],
),
(
"边界验证点",
[seq.add("低", "iframe 二次进入", "离开本页再返回,iframe 重新加载后筛选与列表可再次查询")],
),
(
"异常验证点",
[
seq.add(
"高",
"iframe 未就绪",
"iframe 空白或长时间 loading 时不应把壳层菜单元素当作本页业务断言对象",
)
],
),
(
"集成验证点",
[
seq.add(
"中",
"与 SPD 整页接口页区分",
"本页 URL 含 scjs 路径,业务操作在 iframe 内完成,勿与 `/interface/interface/*` 整页列表混测",
)
],
),
],
)
def batch_toolbar_section(seq: Seq) -> str:
return section(
"批量生成",
[
(
"功能验证点",
[
seq.add("高", "行勾选与全选", "表头全选/取消全选与行勾选状态一致,批量按钮可用性随勾选变化(以现网为准)"),
seq.add("高", "批量生成入口", "点「批量生成」后进入预期流程或 toast,无未捕获异常(默认不断言外围回写结果)"),
],
),
(
"边界验证点",
[seq.add("低", "未勾选批量", "未选行点批量时有提示或按钮不可用")],
),
(
"异常验证点",
[seq.add("中", "批量接口失败", "失败有提示,列表不误改状态")],
),
(
"集成验证点",
[seq.add("中", "变更与外部变更明细", "本页生成/导出的变更可在「外部变更明细信息查询」按单号追溯(允许异步)")],
),
],
)
def dept_tree_req_section(seq: Seq) -> str:
return "\n---\n\n".join(
[
tree_section(seq, tree_label="科室"),
filter_section(
seq,
advanced=False,
extra=[
("高", "产品/规格筛选", "右侧物料编号、规格等筛选后明细表匹配"),
("高", "请领单元数填写", "请领单元数输入框可编辑,与生成请领单逻辑一致(以现网校验为准)"),
],
),
section(
"请领生成与导入导出",
[
(
"功能验证点",
[
seq.add("高", "生成请领单", "选择科室与明细后点「生成请领单」,有成功/失败反馈,不误清未确认数据"),
seq.add("中", "导出/导入", "导出、导入入口可打开流程;导入失败有提示(不校验 Excel 字段级)"),
seq.add("中", "清空请领数", "点「清空请领数」后单元数或明细请领数恢复默认规则(以现网为准)"),
],
),
(
"边界验证点",
[seq.add("低", "无明细生成", "未选产品或无数量时生成被拦截或提示")],
),
(
"异常验证点",
[seq.add("中", "生成接口失败", "失败有提示,明细表不误增请领单号")],
),
(
"集成验证点",
[
seq.add(
"高",
"与请领单接口查询",
"生成成功的请领可在「请领单接口查询」按单号/科室检索(允许异步)",
)
],
),
],
),
list_section(
seq,
"产品编号、名称、规格、请领数量、库存等(以现网列为准)",
"—",
page_key="iface-dept-req-ok",
include_row_action=False,
),
]
)
def transfer_subdept_section(seq: Seq) -> str:
return "\n---\n\n".join(
[
filter_section(
seq,
advanced=False,
extra=[
("高", "科室/库区上下文", "页面顶部或左侧选择的科室、库区与待选产品范围一致"),
("高", "待选列表关键字", "待选栏物料编号/名称搜索后仅展示匹配行"),
],
),
section(
"双栏产品穿梭",
[
(
"功能验证点",
[
seq.add("高", "待选/已选列表可见", "左右两栏列表与「加入」「移除」按钮可见,标题表明二级科室请领场景"),
seq.add("高", "加入与移除", "待选行点「加入」进入已选栏;已选行点「移除」回到待选,计数不串栏"),
seq.add("高", "开始生成", "已选产品非空时「开始生成」可触发请领/接口流程并有反馈"),
seq.add("中", "保存模版", "「保存模版」可打开保存流程,取消不落库"),
],
),
(
"边界验证点",
[seq.add("低", "已选为空生成", "已选为空时开始生成被拦截或提示")],
),
(
"异常验证点",
[seq.add("中", "生成失败", "接口失败有提示,已选列表不误清空")],
),
(
"集成验证点",
[seq.add("中", "与代为科室请领区分", "本页为二级科室产品穿梭,URL 与「代为科室请领(已确认)」不同")],
),
],
),
list_section(
seq,
"待选栏:产品编号、名称、规格、单位等;已选栏:同结构(以现网列为准)",
"加入、移除",
page_key="iface-subdept-req",
include_row_action=True,
),
]
)
def standard_iface_chunks(p: dict, seq: Seq) -> list[str]:
chunks: list[str] = []
if p.get("scjs"):
chunks.append(scjs_iframe_section(seq, p))
extra = p.get("filter_extra") or None
chunks.append(std_filter_section(seq, p["pageKey"], extra))
if p.get("filter_collapsed"):
chunks.append(filter_collapse_section(seq))
caps = toolbar_caps(p["pageKey"])
export_title = "导出明细可触发" if p["pageKey"] == "iface-api-log" else "导出入口可触发"
chunks.extend(
export_print_import_chunks(
seq, "导入等(以 YAML 为准)", page_key=p["pageKey"], export_title=export_title
)
)
if p.get("batch"):
chunks.append(batch_toolbar_section(seq))
chunks.extend(
export_print_import_chunks(
seq, "导出变更明细", page_key=p["pageKey"], export_title="导出变更明细"
)
)
if p.get("manual_add"):
chunks.append(dialog_section(seq, p["manual_add"]))
row_ops = p.get("row_ops", "详情")
split_row = row_ops_section(seq, row_ops)
include_row = p.get("row_action", True) and split_row is None
if split_row:
chunks.append(split_row)
chunks.append(
list_section(
seq,
p.get("columns", "主表核心列(以现网为准)"),
row_ops,
page_key=p["pageKey"],
has_tabs=p.get("has_tabs", False),
tab_labels=p.get("tab_labels"),
include_row_action=include_row,
)
)
return chunks
def body_for(p: dict, seq: Seq) -> str:
layout = p.get("layout")
if layout == "dept_tree":
parts = [dept_tree_req_section(seq)]
elif layout == "transfer":
parts = [transfer_subdept_section(seq)]
else:
parts = ["\n---\n\n".join(standard_iface_chunks(p, seq))]
if p.get("sibling_diff"):
parts.append(sibling_diff_section(seq, p))
return "\n---\n\n".join(parts)
def quality(p: dict, tp_count: int, el_count: int) -> str:
tab_note = (
f"状态 Tab 文案以扫描为准({p.get('tab_labels', '无 Tab')});Tab 计数与列表条数规则以现网为准"
if p.get("has_tabs")
else "本页无状态 Tab"
)
extra_out = p.get("scope_outside", "")
outside = SCOPE_OUTSIDE_COMMON + (f";{extra_out}" if extra_out else "")
return f"""## 质量自检摘要
| 项 | 结论 |
|----|------|
| 范围 | 在范围内:{p['cover']}。排除:断网/弱网、导出文件内容、Excel 字段级导入、弹窗全字段、登录/改密通用流程(见 `{SCOPE}`)。未声称 100% 全覆盖。 |
| 范围外未覆盖 | {outside} |
| 覆盖 | 已写功能域见正文 H3;共 **{tp_count}** 条原子测试点 |
| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 |
| 去重 | 登录态过期、无菜单权限、改密通用流程引用用户管理 `TP_USER_USR_006`/`TP_USER_USR_007`,本页不展开;顶栏院区为本页业务点。编号前缀 `{p['prefix']}` |
| 待评审项 | 弹窗内全字段以补扫为准;写操作冒烟不提交脏数据;筛选收起后字段是否保留;{tab_note} |
| 报表能力 | {"scjs 内嵌业务页,非统计报表 iframe 第四节清单" if p.get("scjs") else "整页渲染,非统计报表 iframe 第四节清单"} |
| 下游 | 可转 testcases/MD、CSV;扫描 `npm run discover:scan-iface`;测试点 `npm run generate:testpoints-iface` |
"""
def render_testpoint(p: dict, scan: dict) -> tuple[str, int]:
spec = f"UI-SPEC-SPD-{p['specId']}"
seq = Seq(p["prefix"], spec)
md = f"""# 基础管理-接口管理-{p['name']} — 测试点分析
**版本**:01
**定稿日期**:{TODAY}
**关联需求**:`knowledge-base/08-UI专项规范库/{spec}.md`
**页面 URL**:`{scan['url']}`
**元素扫描**:`data/scan/{p['pageKey']}-elements.json`({scan['elementCount']} 项)
**定位器**:`locators/spd/{p['pageKey']}.locator.yaml`
**本次生成范围**:`{SCOPE}`
---
## 基础设置 · 接口管理 · {p['name']}
{nav_section(p, seq)}
---
{body_for(p, seq)}
---
{quality(p, seq.n, scan['elementCount'])}
"""
return md, seq.n
def patch_ui_spec(p: 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)
if 'related_tc: ""' in text:
text = text.replace('related_tc: ""', f'related_tc: "testpoint/{tp_file}"', 1)
else:
text = re.sub(r'related_tc: "[^"]*"', f'related_tc: "testpoint/{tp_file}"', 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)
for p in PAGES:
sid = p["specId"]
el = scans[sid]["elementCount"]
tp_n = counts[sid]
prefix = p["prefix"].replace("TP_", "`TP_") + "`"
row = (
f"| {p['name']} | {sid} | `{p['path']}` | "
f"已扫描 · `{p['pageKey']}` · **{el}** 项 · 测试点 **{tp_n}** 条 · {prefix} |"
)
text = re.sub(
rf"\| {re.escape(p['name'])} \| {sid} \| `{re.escape(p['path'])}` \| .+ \|",
row,
text,
count=1,
)
if "generate:testpoints-iface" not in text:
text = text.replace(
"npm run discover:yaml-from-json-iface\n",
"npm run discover:yaml-from-json-iface\nnpm run generate:testpoints-iface\n",
)
INDEX.write_text(text, encoding="utf-8")
def main() -> None:
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, 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}")
for p in PAGES:
if p["specId"] not in scans:
scans[p["specId"]] = load_scan(p["pageKey"])
if p["specId"] not in counts:
tp_file = OUT_DIR / p["file"]
counts[p["specId"]] = len(
re.findall(r"\*\*TP_[A-Z0-9_]+_\d{3}\*\*", tp_file.read_text(encoding="utf-8"))
) if tp_file.exists() else 0
patch_index(counts, scans)
print("done", sum(counts.values()), "TP total")
if __name__ == "__main__":
main()
......@@ -369,7 +369,10 @@ def case_block(
def pre_common(name: str, iframe: str, extra: str = "") -> str:
base = f"测试环境可访问;测试账号已登录并进入{name}页(iframe {iframe})"
base = (
f"测试环境可访问;测试账号已登录并进入{name}页(iframe {iframe});"
"已进入页面并点击「查 询」"
)
return f"{base};{extra}" if extra else base
......
......@@ -201,122 +201,117 @@ tp(
"进入术种维护页;不误入基础包维护/标准套包维护;左右分栏渲染完整。",
)
tp(
"SURG-TYPE-002", "术种维护 · 页面进入", "同组页签切换后返回", "中", T_PAGE,
f"{LOGIN_NOTE}\n2. 点击页内 Tab「标准套包维护」,等待 URL 切换\n3. 返回点击 Tab「术种维护」\n4. 断言 URL 回到 typeMaintenance,左树与状态 Tab 重新渲染",
"三页签互切正常;返回后本页数据恢复。",
)
tp(
"SURG-TYPE-003", "术种维护 · 页面进入", "未登录访问拦截", "高",
"SURG-TYPE-002", "术种维护 · 页面进入", "未登录访问拦截", "高",
"测试环境可访问;使用 test.use({ storageState: { cookies: [], origins: [] } }) 无登录态",
f"1. 不加载登录态(空 storageState)\n2. 直接访问 `{TYPE_URL}`\n3. 断言跳转登录页或不展示术种列表数据",
"未登录被拦截,不泄露术种数据。",
)
tp(
"SURG-TYPE-004", "术种维护 · 左侧术种分类树", "术种分类名称搜索过滤", "高", T_PAGE,
"SURG-TYPE-003", "术种维护 · 左侧术种分类树", "术种分类名称搜索过滤", "高", T_PAGE,
f"{LOGIN_NOTE}\n2. 从左树读取 `§sampleOperCategoryName§`\n3. 在分类名称搜索框填入该关键字(即时过滤或回车,以现网为准)\n4. 断言树节点文本均包含该关键字",
"过滤后树节点名称与关键字匹配;无未捕获异常。",
)
tp(
"SURG-TYPE-005", "术种维护 · 左侧术种分类树", "选择分类联动右侧列表", "高", T_SAMPLE,
"SURG-TYPE-004", "术种维护 · 左侧术种分类树", "选择分类联动右侧列表", "高", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 点击 `§sampleOperCategoryName§` 分类节点\n3. 等待右侧列表刷新稳定\n4. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致",
"右侧仅展示该分类下术种;总数与筛选一致。",
)
tp(
"SURG-TYPE-006", "术种维护 · 左侧术种分类树", "分类树计数与下钻", "中",
"SURG-TYPE-005", "术种维护 · 左侧术种分类树", "分类树计数与下钻", "中",
f"{T_PAGE};至少 1 个带计数的分类节点",
f"{LOGIN_NOTE}\n2. 读取某分类节点括号内计数 `§nodeCount§`\n3. 点击该节点\n4. 断言右侧「共 N 条」与 `§nodeCount§` 一致",
"分类计数与该分类下术种条数一致。",
)
tp(
"SURG-TYPE-007", "术种维护 · 左侧术种分类树", "打开分类维护弹窗", "中", T_PAGE,
"SURG-TYPE-006", "术种维护 · 左侧术种分类树", "打开分类维护弹窗", "中", T_PAGE,
f"{LOGIN_NOTE}\n2. 点击左侧「新增」\n3. 断言弹窗可见,含分类名称输入(#basic_kindName)与「取 消」「保 存」\n4. 点击「取 消」关闭",
"分类维护弹窗字段完整;取消可关闭。",
)
tp(
"SURG-TYPE-008", "术种维护 · 左侧术种分类树", "分类搜索清空恢复全量", "低", T_PAGE,
"SURG-TYPE-007", "术种维护 · 左侧术种分类树", "分类搜索清空恢复全量", "低", T_PAGE,
f"{LOGIN_NOTE}\n2. 用 `§sampleOperCategoryName§` 过滤并记录过滤后节点数\n3. 清空分类搜索框\n4. 断言节点数回到未过滤的默认集合",
"清空后分类树恢复默认集合。",
)
tp(
"SURG-TYPE-009", "术种维护 · 筛选与状态 Tab", "术种名称筛选过滤", "高", T_SAMPLE,
"SURG-TYPE-008", "术种维护 · 筛选与状态 Tab", "术种名称筛选过滤", "高", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleOperName§`\n3. 在 `#table_search_form_operationName` 填入该关键字并点击「查 询」\n4. 断言结果行术种名称均包含该关键字",
"过滤结果与关键字匹配;「共 N 条」随之更新。",
)
tp(
"SURG-TYPE-010", "术种维护 · 筛选与状态 Tab", "病种名称筛选过滤", "高", T_SAMPLE,
"SURG-TYPE-009", "术种维护 · 筛选与状态 Tab", "病种名称筛选过滤", "高", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleDiseaseName§`\n3. 在 `#table_search_form_disease` 填入该关键字并点击「查 询」\n4. 断言结果行病种名称均包含该关键字",
"按病种名称过滤生效。",
)
tp(
"SURG-TYPE-011", "术种维护 · 筛选与状态 Tab", "状态 Tab 切换与计数一致", "高", T_PAGE,
"SURG-TYPE-010", "术种维护 · 筛选与状态 Tab", "状态 Tab 切换与计数一致", "高", T_PAGE,
f"{LOGIN_NOTE}\n2. 读取「全部」Tab 括号计数 `§totalCount§`\n3. 依次点击「停用」「未建分类」「全部」\n4. 每次断言列表条数与「共 N 条」和该 Tab 括号计数一致",
"Tab 切换后列表仅展示对应状态;括号计数与结果规模一致。",
)
tp(
"SURG-TYPE-011", "术种维护 · 筛选与状态 Tab", "左树 + 筛选 + Tab 组合过滤", "中", T_SAMPLE,
"SURG-TYPE-010", "术种维护 · 筛选与状态 Tab", "左树 + 筛选 + Tab 组合过滤", "中", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 选择 `§sampleOperCategoryName§` 分类节点\n3. 填入术种名称关键字并点「查 询」\n4. 切到「全部」Tab\n5. 断言结果为三者交集且无未捕获异常",
"三条件叠加后结果正确,总数一致。",
)
tp(
"SURG-TYPE-012", "术种维护 · 筛选与状态 Tab", "重置清空筛选", "中", T_SAMPLE,
"SURG-TYPE-011", "术种维护 · 筛选与状态 Tab", "重置清空筛选", "中", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 用关键字过滤后点击「重 置」\n3. 断言筛选输入框被清空、列表恢复默认集合",
"重置清空筛选并恢复默认列表。",
)
tp(
"SURG-TYPE-013", "术种维护 · 筛选与状态 Tab", "无匹配组合空态(mock反例)", "低", T_PAGE,
"SURG-TYPE-012", "术种维护 · 筛选与状态 Tab", "无匹配组合空态(mock反例)", "低", T_PAGE,
f"{MOCK_NOTE}\n2. 在 `#table_search_form_operationName` 输入 mock 不存在关键字 `__NOT_EXIST_OPER__99999`\n3. 点击「查 询」\n4. 断言「暂无数据」且分页总数为 0",
"无匹配时空态明确,不白屏;总数为 0。",
)
tp(
"SURG-TYPE-014", "术种维护 · 列表与编码展示", "列表核心列完整", "高", T_SAMPLE,
"SURG-TYPE-013", "术种维护 · 列表与编码展示", "列表核心列完整", "高", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 断言表头包含序号、术种名称、病种名称、诊断编码(ICD-10)、诊断名称(ICD-10)、操作编码(ICD-9-CM-3)、操作名称(ICD-9-CM-3)、操作",
"核心列均可见,操作列含「编 辑」「停用/启用」。",
)
tp(
"SURG-TYPE-014", "术种维护 · 列表与编码展示", "ICD-10 诊断编码展示完整", "中",
"SURG-TYPE-013", "术种维护 · 列表与编码展示", "ICD-10 诊断编码展示完整", "中",
f"{T_SAMPLE};至少 1 条含诊断编码的数据",
f"{LOGIN_NOTE}\n2. 读取含 ICD-10 编码的行 `§sampleIcd10Code§`\n3. 断言编码文本完整(如 A02.000x010)不截断、无乱码",
"ICD-10 编码完整展示。",
)
tp(
"SURG-TYPE-014", "术种维护 · 列表与编码展示", "ICD-9-CM-3 操作编码展示完整", "低",
"SURG-TYPE-013", "术种维护 · 列表与编码展示", "ICD-9-CM-3 操作编码展示完整", "低",
f"{T_SAMPLE};至少 1 条含操作编码的数据",
f"{LOGIN_NOTE}\n2. 读取含操作编码的行 `§sampleIcd9Code§`\n3. 断言操作编码(如 00.0200)与操作名称(如 心脏治疗性超声)对应展示无乱码",
"ICD-9-CM-3 编码与操作名称对应正确。",
)
tp(
"SURG-TYPE-015", "术种维护 · 列表与编码展示", "分页与页大小切换", "中",
"SURG-TYPE-014", "术种维护 · 列表与编码展示", "分页与页大小切换", "中",
f"{T_SAMPLE};列表总数大于单页行数上限",
f"{LOGIN_NOTE}\n2. 切换每页条数(如 50 → 100)\n3. 断言单页行数不超过所选值,分页总数不变",
"页大小切换生效;总数保持稳定。",
)
tp(
"SURG-TYPE-016", "术种维护 · 列表与编码展示", "首页上一页不可用", "低", T_PAGE,
"SURG-TYPE-015", "术种维护 · 列表与编码展示", "首页上一页不可用", "低", T_PAGE,
f"{LOGIN_NOTE}\n2. 处于第 1 页\n3. 断言「上一页」置灰或点击无效果",
"第 1 页无法回到上一页。",
)
tp(
"SURG-TYPE-017", "术种维护 · 新增术种", "打开新增术种入口", "高", T_PAGE,
"SURG-TYPE-016", "术种维护 · 新增术种", "打开新增术种入口", "高", T_PAGE,
f"{LOGIN_NOTE}\n2. 点击「新增术种」\n3. 断言新增弹窗/页面可见,含术种名称、病种名称、分类与编码相关字段\n4. 断言含「取 消」「保 存」",
"新增术种入口字段完整。",
)
tp(
"SURG-TYPE-018", "术种维护 · 新增术种", "新增取消不落库", "高", T_PAGE,
"SURG-TYPE-017", "术种维护 · 新增术种", "新增取消不落库", "高", T_PAGE,
f"{LOGIN_NOTE}\n2. 记录当前分页总数 `§sampleTotal§`\n3. 打开新增,填写名称 `§uniqOperName§` 后点「取 消」\n4. 断言列表总数不变,且无该名称行",
"取消后不新增术种。",
)
tp(
"SURG-TYPE-019", "术种维护 · 新增术种", "术种名称为空拦截(mock反例)", "高", T_PAGE,
"SURG-TYPE-018", "术种维护 · 新增术种", "术种名称为空拦截(mock反例)", "高", T_PAGE,
f"{MOCK_NOTE}\n2. 打开新增术种弹窗,名称留空\n3. 点击「保 存」\n4. 断言必填提示,弹窗仍在,列表无新行",
"保存被拦截;不落库。",
)
tp(
"SURG-TYPE-020", "术种维护 · 行内操作(编辑 / 停用)", "行内编辑回填", "高", T_SAMPLE,
"SURG-TYPE-019", "术种维护 · 行内操作(编辑 / 停用)", "行内编辑回填", "高", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 点击首行「编 辑」\n3. 断言编辑弹窗可见且回填当前术种数据\n4. 点击「取 消」关闭",
"编辑弹窗回填正确;取消不影响数据。",
)
tp(
"SURG-TYPE-021", "术种维护 · 行内操作(编辑 / 停用)", "停用状态流转", "中",
"SURG-TYPE-020", "术种维护 · 行内操作(编辑 / 停用)", "停用状态流转", "中",
f"{T_PAGE};存在启用状态术种(优先选 `§uniqOperName§` 自建数据);允许写术种",
f"{LOGIN_NOTE}\n2. 记录「停用」Tab 当前计数 `§disabledCount§`\n3. 点击目标行「停用」,若有确认框则点「确 定」\n4. 断言「停用」Tab 计数 +1\n5. 后置:对该术种执行「启用」恢复",
"术种状态变为停用;「停用」计数 +1;结束恢复原状态。",
......@@ -348,97 +343,92 @@ sp(
"进入标准套包维护页;不误入基础包维护/术种维护;左右分栏渲染完整。",
)
sp(
"SURG-STD-002", "标准套包维护 · 页面进入", "同组页签切换后返回", "中", S_PAGE,
f"{LOGIN_NOTE}\n2. 点击页内 Tab「术种维护」,等待 URL 切换\n3. 返回点击 Tab「标准套包维护」\n4. 断言 URL 回到 standardMaintenance,左树与构成列表重新渲染",
"三页签互切正常;返回后本页数据恢复。",
)
sp(
"SURG-STD-003", "标准套包维护 · 页面进入", "未登录访问拦截", "高",
"SURG-STD-002", "标准套包维护 · 页面进入", "未登录访问拦截", "高",
"测试环境可访问;使用 test.use({ storageState: { cookies: [], origins: [] } }) 无登录态",
f"1. 不加载登录态(空 storageState)\n2. 直接访问 `{STD_URL}`\n3. 断言跳转登录页或不展示套包列表数据",
"未登录被拦截,不泄露套包数据。",
)
sp(
"SURG-STD-004", "标准套包维护 · 左侧术种分类树", "术种分类名称搜索过滤", "高", S_PAGE,
"SURG-STD-003", "标准套包维护 · 左侧术种分类树", "术种分类名称搜索过滤", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 从左树读取 `§sampleOperCategoryName§`\n3. 在分类搜索框填入该关键字(即时过滤或回车,以现网为准)\n4. 断言树节点文本均包含该关键字",
"过滤后树节点名称与关键字匹配;无未捕获异常。",
)
sp(
"SURG-STD-005", "标准套包维护 · 左侧术种分类树", "选择分类联动构成列表", "高", S_SAMPLE,
"SURG-STD-004", "标准套包维护 · 左侧术种分类树", "选择分类联动构成列表", "高", S_SAMPLE,
f"{LOGIN_NOTE}\n2. 点击 `§sampleOperCategoryName§` 分类节点\n3. 等待右侧构成列表刷新稳定\n4. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致",
"右侧仅展示该分类下标准套包;总数与筛选一致。",
)
sp(
"SURG-STD-004", "标准套包维护 · 左侧术种分类树", "分类搜索清空恢复全量", "低", S_PAGE,
"SURG-STD-003", "标准套包维护 · 左侧术种分类树", "分类搜索清空恢复全量", "低", S_PAGE,
f"{LOGIN_NOTE}\n2. 用 `§sampleOperCategoryName§` 过滤并记录过滤后节点数\n3. 清空分类搜索框\n4. 断言节点数回到未过滤的默认集合",
"清空后分类树恢复默认集合。",
)
sp(
"SURG-STD-005", "标准套包维护 · 筛选与构成列表", "默认展示标准套包构成", "高", S_PAGE,
"SURG-STD-004", "标准套包维护 · 筛选与构成列表", "默认展示标准套包构成", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 等待列表稳定\n3. 断言按「包名称」分组出现数据行,或「暂无数据」且分页总数为 0",
"加载无报错;有数据时「共 N 条」N≥1;无数据时空态明确。",
)
sp(
"SURG-STD-005", "标准套包维护 · 筛选与构成列表", "产品名称筛选过滤", "高", S_SAMPLE,
"SURG-STD-004", "标准套包维护 · 筛选与构成列表", "产品名称筛选过滤", "高", S_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleProductName§`\n3. 在 `#table_search_form_keyword` 填入该关键字并点击「查 询」\n4. 断言结果行产品名称均包含该关键字",
"过滤结果与关键字匹配;「共 N 条」随之更新。",
)
sp(
"SURG-STD-005", "标准套包维护 · 筛选与构成列表", "规格型号筛选过滤", "中", S_SAMPLE,
"SURG-STD-004", "标准套包维护 · 筛选与构成列表", "规格型号筛选过滤", "中", S_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleSpecModel§`\n3. 在 `#table_search_form_goodsSpec` 填入该关键字并点击「查 询」\n4. 断言结果行规格型号均包含该关键字",
"按规格型号过滤生效。",
)
sp(
"SURG-STD-005", "标准套包维护 · 筛选与构成列表", "器械注册人筛选过滤", "中", S_SAMPLE,
"SURG-STD-004", "标准套包维护 · 筛选与构成列表", "器械注册人筛选过滤", "中", S_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleMfrsName§`\n3. 在 `#table_search_form_goodsMfrsName` 填入该关键字并点击「查 询」\n4. 断言结果行器械注册人均包含该关键字",
"按器械注册人过滤生效。",
)
sp(
"SURG-STD-009", "标准套包维护 · 筛选与构成列表", "构成列表核心列完整", "高",
"SURG-STD-008", "标准套包维护 · 筛选与构成列表", "构成列表核心列完整", "高",
f"{S_SAMPLE};存在分组",
f"{LOGIN_NOTE}\n2. 断言表头/首行包含包名称、包数量/每包含量、产品编号、产品名称、规格型号、器械注册人、单位、产地、耗材类型、操作",
"核心列均可见,操作列含「编 辑」「移 除」。",
)
sp(
"SURG-STD-006", "标准套包维护 · 筛选与构成列表", "包名称展开/收起子项", "高",
"SURG-STD-005", "标准套包维护 · 筛选与构成列表", "包名称展开/收起子项", "高",
f"{S_SAMPLE};存在可展开的包分组",
f"{LOGIN_NOTE}\n2. 读取首个包分组名称 `§samplePkgGroupName§`\n3. 点击展开该分组,断言出现子项行\n4. 再次点击收起,断言子项行隐藏",
"展开显示子项、收起隐藏子项;分组计数不受影响。",
)
sp(
"SURG-STD-009", "标准套包维护 · 筛选与构成列表", "查询与重置", "中", S_SAMPLE,
"SURG-STD-008", "标准套包维护 · 筛选与构成列表", "查询与重置", "中", S_SAMPLE,
f"{LOGIN_NOTE}\n2. 用关键字过滤后点击「重 置」\n3. 断言筛选输入框被清空、列表恢复默认集合",
"重置清空筛选并恢复默认列表。",
)
sp(
"SURG-STD-010", "标准套包维护 · 筛选与构成列表", "分页与页大小切换", "中",
"SURG-STD-009", "标准套包维护 · 筛选与构成列表", "分页与页大小切换", "中",
f"{S_SAMPLE};列表总数大于单页行数上限",
f"{LOGIN_NOTE}\n2. 切换每页条数(如 50 → 100)\n3. 断言单页行数不超过所选值,分页总数不变",
"页大小切换生效;总数保持稳定。",
)
sp(
"SURG-STD-007", "标准套包维护 · 筛选与构成列表", "无匹配组合空态(mock反例)", "低", S_PAGE,
"SURG-STD-006", "标准套包维护 · 筛选与构成列表", "无匹配组合空态(mock反例)", "低", S_PAGE,
f"{MOCK_NOTE}\n2. 在 `#table_search_form_keyword` 输入 mock 不存在关键字 `__NOT_EXIST_STD__99999`\n3. 点击「查 询」\n4. 断言「暂无数据」且分页总数为 0",
"无匹配时空态明确,不白屏;总数为 0。",
)
sp(
"SURG-STD-009", "标准套包维护 · 添加基础包弹窗", "打开添加基础包弹窗", "高", S_PAGE,
"SURG-STD-008", "标准套包维护 · 添加基础包弹窗", "打开添加基础包弹窗", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 点击「添加基础包」\n3. 断言弹窗可见,含左侧基础包分类树与右侧基础包明细\n4. 断言含「取 消」「确定(N)」",
"添加基础包弹窗字段完整;确定按钮带选择计数。",
)
sp(
"SURG-STD-008", "标准套包维护 · 添加基础包弹窗", "弹窗内筛选与选择计数联动", "高", S_PAGE,
"SURG-STD-007", "标准套包维护 · 添加基础包弹窗", "弹窗内筛选与选择计数联动", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 打开添加基础包弹窗,在搜索框输入基础包关键字\n3. 勾选 1 个基础包\n4. 断言「确定(N)」计数由 0 变为 1",
"筛选生效;选择数与确定按钮计数联动。",
)
sp(
"SURG-STD-009", "标准套包维护 · 添加基础包弹窗", "弹窗内构成数量可编辑", "中",
"SURG-STD-008", "标准套包维护 · 添加基础包弹窗", "弹窗内构成数量可编辑", "中",
f"{S_PAGE};已勾选至少 1 个基础包",
f"{LOGIN_NOTE}\n2. 定位明细行「构成数量」步进器,点击「Increase Value」\n3. 断言数量 +1 且合计随之更新",
"构成数量可编辑;加减生效。",
)
sp(
"SURG-STD-010", "标准套包维护 · 添加基础包弹窗", "添加基础包取消不落库", "高", S_PAGE,
"SURG-STD-009", "标准套包维护 · 添加基础包弹窗", "添加基础包取消不落库", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 记录当前分页总数 `§sampleTotal§`\n3. 打开添加基础包,勾选 1 个基础包后点「取 消」\n4. 断言构成列表总数不变",
"取消后不新增构成行。",
)
......@@ -449,18 +439,18 @@ sp(
"未选择时确定不可用。",
)
sp(
"SURG-STD-011", "标准套包维护 · 添加基础包弹窗", "重复添加同一基础包", "中",
"SURG-STD-010", "标准套包维护 · 添加基础包弹窗", "重复添加同一基础包", "中",
f"{S_PAGE};标准套包已含某基础包(或先添加一个);允许写套包",
f"{LOGIN_NOTE}\n2. 再次打开添加基础包,选择同一基础包并确定\n3. 断言按产品规则拒绝/合并/累加,不产生重复脏行",
"重复添加不产生重复行(策略以现网为准)。",
)
sp(
"SURG-STD-012", "标准套包维护 · 添加散件弹窗", "打开添加散件弹窗", "高", S_PAGE,
"SURG-STD-011", "标准套包维护 · 添加散件弹窗", "打开添加散件弹窗", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 点击「添加散件」\n3. 断言弹窗可见并展示「产品列表」视图,含产品名称/规格型号/器械注册人/耗材类型筛选与分页",
"添加散件弹窗产品列表视图完整。",
)
sp(
"SURG-STD-013", "标准套包维护 · 添加散件弹窗", "产品列表筛选与选择散件", "高",
"SURG-STD-012", "标准套包维护 · 添加散件弹窗", "产品列表筛选与选择散件", "高",
f"{S_PAGE};已打开添加散件弹窗",
f"{LOGIN_NOTE}\n2. 在产品名称筛选框输入 `§sampleProductName§`\n3. 勾选首个匹配散件\n4. 断言散件进入应拣明细,计数同步变化",
"筛选生效;选中散件进入应拣明细。",
......@@ -483,13 +473,13 @@ sp(
"取消后不新增散件行。",
)
sp(
"SURG-STD-014", "标准套包维护 · 添加散件弹窗", "万级散件列表首屏可交互", "低",
"SURG-STD-013", "标准套包维护 · 添加散件弹窗", "万级散件列表首屏可交互", "低",
f"{S_PAGE};添加散件产品列表总量达万级(示例「共 16299 条」)",
f"{LOGIN_NOTE}\n2. 打开添加散件弹窗\n3. 断言首屏可交互(筛选/分页可用),不卡死\n4. 避免全量断言,仅校验首行与总数格式",
"万级列表首屏可用;无超时白屏。",
)
sp(
"SURG-STD-015", "标准套包维护 · 行内操作(编辑 / 移除)", "行内编辑构成项", "高",
"SURG-STD-014", "标准套包维护 · 行内操作(编辑 / 移除)", "行内编辑构成项", "高",
f"{S_SAMPLE};先展开目标包",
f"{LOGIN_NOTE}\n2. 点击子项行「编 辑」\n3. 断言编辑弹窗/表单可见并回填当前值\n4. 点击「取 消」关闭",
"编辑回填正确;取消不影响数据。",
......
......@@ -500,8 +500,8 @@ PAGES = [
tp_total=45,
write_note=(
"权限树 001/002 只测展开收起,不保存授权;003 切换同步。"
"编辑 004 取消;005 不改名保存(受 SKIP_BASIC_WRITE)。"
"新增 006–010 不提交落库。"
"编辑 004 取消不保存。"
"新增 005–009 不提交落库。"
),
nav_note="菜单「基础设置 → 用户管理 → 角色管理」;本页左右分栏、无独立分页。",
samples=(
......@@ -557,19 +557,7 @@ PAGES = [
pre="左栏至少 1 个角色",
),
case(
5, "角色增删改", "功能验证点", "P0", "编辑角色保存成功", 33,
[
"点选左栏任一角色",
"点击左栏右下角(或行内)编辑图标,断言弹出编辑角色弹框",
"不修改角色名称,点击弹框内「保 存」",
"断言页面无报错提示,且出现「编辑成功」类成功提示",
"断言编辑弹框关闭",
],
"保存成功;提示编辑成功;角色数据无异常报错。",
pre="左栏至少 1 个可编辑角色;环境允许写操作(非 SKIP_BASIC_WRITE=1)",
),
case(
6, "角色增删改", "功能验证点", "P0", "点击角色按钮打开新增弹框", 32,
5, "角色增删改", "功能验证点", "P0", "点击角色按钮打开新增弹框", 32,
[
"点击左栏工具区「角 色」按钮",
"断言弹出新增角色弹框,标题或内容含「新增」或「角色」",
......@@ -579,7 +567,7 @@ PAGES = [
"新增角色弹框正常打开;页面无报错;本条不提交保存。",
),
case(
7, "角色增删改", "异常验证点", "P0", "新增角色名称为空保存拦截", 39,
6, "角色增删改", "异常验证点", "P0", "新增角色名称为空保存拦截", 39,
[
"点击左栏工具区「角 色」按钮,断言弹出新增角色弹框",
"不填写角色名称,直接点击弹框内「保 存」",
......@@ -590,7 +578,7 @@ PAGES = [
"空名称保存被拦截;角色名称框变红并提示请输入角色名称!;弹框不提交。",
),
case(
8, "角色增删改", "功能验证点", "P0", "新增角色弹框取消关闭", 32,
7, "角色增删改", "功能验证点", "P0", "新增角色弹框取消关闭", 32,
[
"点击左栏工具区「角 色」按钮,断言弹出新增角色弹框",
"不填写任何内容,直接点击弹框内「取 消」",
......@@ -600,7 +588,7 @@ PAGES = [
"取消后弹框关闭;页面无报错;未新增角色。",
),
case(
9, "角色增删改", "功能验证点", "P0", "新增弹框查询配置化复选框可勾选", 32,
8, "角色增删改", "功能验证点", "P0", "新增弹框查询配置化复选框可勾选", 32,
[
"点击左栏工具区「角 色」按钮,断言弹出新增角色弹框",
"定位「查询配置化」复选框,点击勾选,断言为选中态",
......@@ -612,7 +600,7 @@ PAGES = [
pre="新增弹框含「查询配置化」复选框",
),
case(
10, "角色增删改", "功能验证点", "P0", "新增角色弹框X关闭", 32,
9, "角色增删改", "功能验证点", "P0", "新增角色弹框X关闭", 32,
[
"点击左栏工具区「角 色」按钮,断言弹出新增角色弹框",
"点击弹框右上角「X」关闭图标",
......@@ -622,18 +610,7 @@ PAGES = [
"X 关闭后弹框正常关闭;页面无报错;未新增角色。",
),
case(
11, "左侧角色列表", "功能验证点", "P1", "左栏角色列表可上下滑动", 9,
[
"定位左栏角色列表滚动区域",
"向下滚动列表,断言 scrollTop 增加且列表可继续交互",
"向上滚回(滚轮或滑至顶部),断言 scrollTop 减小",
"断言页面无报错遮罩",
],
"左栏可正常上下滑动;滚动后页面无异常。",
pre="左栏列表高度溢出可滚动(不足则 skip)",
),
case(
12, "右侧菜单权限树", "功能验证点", "P1", "右侧权限树子菜单可上下滑动", 25,
10, "右侧菜单权限树", "功能验证点", "P1", "右侧权限树子菜单可上下滑动", 25,
[
"点选左栏任一角色,断言右侧菜单权限树可见",
"定位右侧权限树滚动区域(含子菜单列表)",
......@@ -770,8 +747,6 @@ def render_md(p: dict) -> str:
lines.append(f" {i}. {step}")
auto_verb = "与" if p.get("spec_ready") else "规划与"
auto_line = f"- 自动化断言:{auto_verb} CSV `{case_id}` 及 `tests/spd/{p['spec_file']}` 一致"
if p.get("spec_ready") and c["title"] == "编辑角色保存成功":
auto_line += "(`SKIP_BASIC_WRITE=1` 时 skip)"
lines.append(auto_line)
lines += ["", "###### 预期结果", "", c["expect"], "", "---", ""]
while lines and lines[-1] in ("", "---"):
......
......@@ -46,7 +46,7 @@ def case(
def vendor_qual_sync_tab_cases() -> list[dict]:
"""VENDOR-QUL-010~057:与 vendor-qual-tab-cases.ts / vendor-qual-sync-tabs.spec.ts 一致。"""
"""VENDOR-QUL-010~055:与 vendor-qual-tab-cases.ts / vendor-qual-sync-tabs.spec.ts 一致。"""
# (主 Tab, 组合第二字段, 起始 auto 序号)
tabs: list[tuple[str, str, int]] = [
("授权链路效期预警", "(授权三字段)", 10),
......@@ -55,9 +55,11 @@ def vendor_qual_sync_tab_cases() -> list[dict]:
("器械注册证号", "(八项组合含展开区)", 24),
("授权书", "(六项组合含展开区)", 28),
("承诺书", "(承诺类型+状态)", 32),
("法人委托书", "(业务员+状态)", 36),
("二级供应商三证", "(三项组合,无证件类型)", 40),
("法人委托书", "(业务员+状态)", 35),
("二级供应商三证", "(三项组合,无证件类型)", 38),
]
tabs_without_view = {"承诺书", "法人委托书"}
tabs_registrant_view = {"器械注册人", "器械注册证号", "授权书"}
# 各主 Tab 列表区子页签(与 data/vendor-qual-subtabs.data.ts 一致)
list_subtab_summary: dict[str, str] = {
"授权链路效期预警": "三个月内过期/一个月内过期/已过期",
......@@ -84,13 +86,13 @@ def vendor_qual_sync_tab_cases() -> list[dict]:
# 列表子 Tab 用例编号(授权链路与 010 块连续;其余主 Tab 为 044~057)
expiry_case_ids: dict[str, tuple[int, int]] = {
"授权链路效期预警": (14, 15),
"供应商三证": (44, 45),
"器械注册人": (46, 47),
"器械注册证号": (48, 49),
"授权书": (50, 51),
"承诺书": (52, 53),
"法人委托书": (54, 55),
"二级供应商三证": (56, 57),
"供应商三证": (42, 43),
"器械注册人": (44, 45),
"器械注册证号": (46, 47),
"授权书": (48, 49),
"承诺书": (50, 51),
"法人委托书": (52, 53),
"二级供应商三证": (54, 55),
}
out: list[dict] = []
for tab, second, start in tabs:
......@@ -101,7 +103,8 @@ def vendor_qual_sync_tab_cases() -> list[dict]:
combo_steps = [
"切换至「授权链路效期预警」Tab 并点击「查 询」",
"点选列表首行",
"取首行「授权书名称」「授权单位」「被授权单位」写入筛选并「查 询」",
"点击首行「查看」,在详情取「授权书名称」「授权单位」「被授权单位」(授权链路首级)后关闭详情",
"将三项写入筛选并「查 询」",
"断言结果行与三项条件一致",
]
combo_pre = f"{pre_tab};首行三项非空"
......@@ -140,7 +143,7 @@ def vendor_qual_sync_tab_cases() -> list[dict]:
combo_steps = [
"切换 Tab 并查询",
"点选首行",
"填写器械注册人、销售商(自定义)名称、状态",
"取列表「器械注册人」「销售商(自定义)」填筛选「器械注册人」「销售商(自定义)名称」及「状态」",
"点击展开并填写注册证号、是否有附件、注册产品名称、是否新证、产品名称",
"断言结果与八项条件一致",
]
......@@ -221,21 +224,41 @@ def vendor_qual_sync_tab_cases() -> list[dict]:
auto=f"VENDOR-QUL-{start + 2:03d}",
)
)
view_title = f"{tab}·首行查看详情销售商(自定义)与列表一致" if tab != "授权链路效期预警" else f"{tab}·首行查看详情销售商(自定义)与列表一致"
out.append(
case(
start + 3,
"查看",
"功能验证点",
"P0",
view_title,
14,
["点选首行并点击查看", "断言详情销售商(自定义)与列表一致", "关闭详情"],
"详情可打开;销售商(自定义)与列表一致。",
pre=f"{pre_tab};列表至少 1 行含查看",
auto=f"VENDOR-QUL-{start + 3:03d}",
if tab not in tabs_without_view:
if tab in tabs_registrant_view:
view_title = f"{tab}·首行查看详情注册人名称与列表器械注册人一致"
view_steps = [
"点选首行并点击查看",
"读取详情「注册人名称」,与列表首行「器械注册人」核对",
"关闭详情",
]
view_expected = "详情可打开;注册人名称与列表器械注册人一致。"
elif tab == "供应商三证":
view_title = f"{tab}·首行查看详情销售商(自定义)与列表一致"
view_steps = [
"点选首行并点击查看",
"读取详情左上角标题作为销售商(自定义),与列表首行核对",
"关闭详情",
]
view_expected = "详情可打开;标题销售商(自定义)与列表一致。"
else:
view_title = f"{tab}·首行查看详情销售商(自定义)与列表一致"
view_steps = ["点选首行并点击查看", "断言详情销售商(自定义)与列表一致", "关闭详情"]
view_expected = "详情可打开;销售商(自定义)与列表一致。"
out.append(
case(
start + 3,
"查看",
"功能验证点",
"P0",
view_title,
14,
view_steps,
view_expected,
pre=f"{pre_tab};列表至少 1 行含查看",
auto=f"VENDOR-QUL-{start + 3:03d}",
)
)
)
if tab in expiry_case_ids:
exp_sw, exp_cnt = expiry_case_ids[tab]
switch_steps = (
......@@ -278,16 +301,16 @@ def vendor_qual_sync_tab_cases() -> list[dict]:
def vendor_qual_column_settings_other_tab_cases() -> list[dict]:
"""VENDOR-QUL-058~073:各主 Tab 列设置(证件效期提醒 005/006 见 vendor-qual.spec.ts)。"""
"""VENDOR-QUL-056~071:各主 Tab 列设置(证件效期提醒 005/006 见 vendor-qual.spec.ts)。"""
tabs: list[tuple[str, int]] = [
("授权链路效期预警", 58),
("供应商三证", 60),
("器械注册人", 62),
("器械注册证号", 64),
("授权书", 66),
("承诺书", 68),
("法人委托书", 70),
("二级供应商三证", 72),
("授权链路效期预警", 56),
("供应商三证", 58),
("器械注册人", 60),
("器械注册证号", 62),
("授权书", 64),
("承诺书", 66),
("法人委托书", 68),
("二级供应商三证", 70),
]
out: list[dict] = []
for tab, start in tabs:
......@@ -398,7 +421,7 @@ PAGES = [
spec_file="vendor-qual.spec.ts; vendor-qual-sync-tabs.spec.ts; vendor-qual-column-settings.spec.ts",
iframe=False,
write_note="本批只读:导出仅校验无报错;不改证件。",
samples="各主 Tab 组合查询见 data/vendor-qual-tab-cases.ts;子 Tab 见 vendor-qual-subtabs.data.ts;列设置 9 Tab 全覆盖(005/006 + 058~073)。",
samples="各主 Tab 组合查询见 data/vendor-qual-tab-cases.ts;子 Tab 见 vendor-qual-subtabs.data.ts;列设置 9 Tab 全覆盖(005/006 + 056~071)。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "进入页面列表 Tab 默认证件效期提醒", 1,
["按菜单「基础设置 → 厂商管理 → 供应商资质管理」进入",
......@@ -661,11 +684,11 @@ PAGES.append(
11,
[
"点击「展开」,断言启用日期/方案编码/方案名称等扩展条件可见",
"点击「收起」,断言扩展条件隐藏且「展开」按钮可见",
"点击「收起」,断言扩展条件隐藏(含方案名称不可见)且「展开」按钮可见",
"再次点击「展开」,断言扩展条件恢复可见",
"填写方案编码后收起再展开,断言条件值保留",
],
"展开与收起切换正常;收起后再展开已填条件不丢失。",
"展开与收起切换正常;收起后方案名称等扩展项不可见;收起后再展开已填条件不丢失。",
pre="筛选区含展开/收起控件",
auto="VENDOR-EVS-014",
),
......@@ -807,31 +830,31 @@ PAGES.append(
VENDOR_QUL_MD_APPENDIX = """
---
### 用例索引(VENDOR-QUL-001~073)
### 用例索引(VENDOR-QUL-001~071)
| 范围 | 内容 | 自动化 |
|------|------|--------|
| 001 | 进入页默认证件效期提醒 | `vendor-qual.spec.ts` |
| 002–009 | 证件效期提醒:组合/重置/导出/查看/效期子 Tab | `vendor-qual.spec.ts` |
| 005–006 | 证件效期提醒 · 列设置 | `vendor-qual.spec.ts` |
| 010–057 | 其余 8 主 Tab:组合/重置/导出/查看/列表子 Tab | `vendor-qual-sync-tabs.spec.ts` + `data/vendor-qual-tab-cases.ts` |
| 058–073 | 8 主 Tab · 列设置(打开 + 列隐藏恢复) | `vendor-qual-column-settings.spec.ts` |
| 010–055 | 其余 8 主 Tab:组合/重置/导出/查看(承诺书、法人委托书无查看)/列表子 Tab | `vendor-qual-sync-tabs.spec.ts` + `data/vendor-qual-tab-cases.ts` |
| 056–071 | 8 主 Tab · 列设置(打开 + 列隐藏恢复) | `vendor-qual-column-settings.spec.ts` |
### 其它证件主 Tab(与证件效期提醒 002–009 同步)
以下主 Tab 在 CSV **VENDOR-QUL-010~057** 与 `vendor-qual-sync-tabs.spec.ts` 中覆盖:**组合查询**、**重置**、**导出**、**查看**、**列表子 Tab**(文案见 `data/vendor-qual-subtabs.data.ts` / `data/scan/vendor-qual-subtabs.json`)。
以下主 Tab 在 CSV **VENDOR-QUL-010~055** 与 `vendor-qual-sync-tabs.spec.ts` 中覆盖:**组合查询**、**重置**、**导出**、**查看**(部分 Tab)、**列表子 Tab**(文案见 `data/vendor-qual-subtabs.data.ts` / `data/scan/vendor-qual-subtabs.json`)。
| 主 Tab | 组合/重置/导出/查看 | 列表子 Tab | 列设置 |
|--------|---------------------|------------|--------|
| 证件效期提醒 | 002–004、009 | **007–008** | **005–006** |
| 授权链路效期预警 | 010–013(授权书名称/授权单位/被授权单位) | **014–015**(三个月内/一个月内/已过期) | **058–059** |
| 供应商三证 | 016–019(销售商(自定义)名称) | **044–045**(全部/未过期/已过期/已缺失) | **060–061** |
| 器械注册人 | 020–023(收起+展开五项) | **046–047**(全部/已维护/未维护) | **062–063** |
| 器械注册证号 | 024–027(收起+展开八项) | **048–049**(全部/未过期/已过期) | **064–065** |
| 授权书 | 028–031(收起+展开六项) | **050–051**(全部/未过期/已过期) | **066–067** |
| 承诺书 | 032–035(销售商/承诺类型/状态) | **052–053** | **068–069** |
| 法人委托书 | 036–039(销售商/业务员/状态) | **054–055** | **070–071** |
| 二级供应商三证 | 040–043(三项组合) | **056–057** | **072–073** |
| 授权链路效期预警 | 010–013(授权书名称/授权单位/被授权单位) | **014–015**(三个月内/一个月内/已过期) | **056–057** |
| 供应商三证 | 016–019(销售商(自定义)名称) | **042–043**(全部/未过期/已过期/已缺失) | **058–059** |
| 器械注册人 | 020–023(收起+展开五项;查看比注册人名称) | **044–045**(全部/已维护/未维护) | **060–061** |
| 器械注册证号 | 024–027(列表销售商(自定义)→筛选销售商(自定义)名称) | **046–047**(全部/未过期/已过期) | **062–063** |
| 授权书 | 028–031(收起+展开六项;查看比注册人名称) | **048–049**(全部/未过期/已过期) | **064–065** |
| 承诺书 | 032–034(无查看) | **050–051** | **066–067** |
| 法人委托书 | 035–037(无查看) | **052–053** | **068–069** |
| 二级供应商三证 | 038–041(三项组合) | **054–055** | **070–071** |
**运行**:`npm run test:vendor-qual:all`(含上述三个 spec 文件)。
"""
......
......@@ -7,6 +7,8 @@
* 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
* node scripts/json-to-locator-yaml.mjs --all-deptm
* node scripts/json-to-locator-yaml.mjs --all-iface
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
......@@ -70,6 +72,34 @@ const NEW_PAGES = {
'warehouse-probe': { pageTitle: '基础设置 > 库房管理 > 探针绑定', module: 'spd-basic' },
'flow-def': { pageTitle: '基础设置 > 流程设置 > 审批流程设置', module: 'spd-basic' },
'flow-inst': { pageTitle: '基础设置 > 流程设置 > 审批流实例', module: 'spd-basic' },
'deptm-register': { pageTitle: '基础设置 > 科室管理 > 科室登记', module: 'spd-basic' },
'deptm-category': { pageTitle: '基础设置 > 科室管理 > 科室分类维护', module: 'spd-basic' },
'deptm-product-alloc': { pageTitle: '基础设置 > 科室管理 > 科室产品分配', module: 'spd-basic' },
'deptm-zone-product': { pageTitle: '基础设置 > 科室管理 > 科室库区产品设置', module: 'spd-basic' },
'deptm-follow-limit': { pageTitle: '基础设置 > 科室管理 > 科室跟台限额任务', module: 'spd-basic' },
'deptm-req-template': { pageTitle: '基础设置 > 科室管理 > 请领模板维护', module: 'spd-basic' },
'deptm-auto-req': { pageTitle: '基础设置 > 科室管理 > 自动请领排程', module: 'spd-basic' },
'deptm-staff': { pageTitle: '基础设置 > 科室管理 > 科室人员查询', module: 'spd-basic' },
'deptm-minmax': { pageTitle: '基础设置 > 科室管理 > 科室上下限建议', module: 'spd-basic' },
'deptm-budget': { pageTitle: '基础设置 > 科室管理 > 科室预算管理', module: 'spd-basic' },
'deptm-lab-machine': { pageTitle: '基础设置 > 科室管理 > 检验机器管理', module: 'spd-basic' },
'deptm-delivery': { pageTitle: '基础设置 > 科室管理 > 科室送货计划', module: 'spd-basic' },
'iface-his-price': { pageTitle: '基础设置 > 接口管理 > HIS价对照查询', module: 'spd-basic' },
'iface-dept-req-ok': { pageTitle: '基础设置 > 接口管理 > 代为科室请领(已确认)', module: 'spd-basic' },
'iface-subdept-req': { pageTitle: '基础设置 > 接口管理 > 代二级科室请领产品', module: 'spd-basic' },
'iface-low-billing': { pageTitle: '基础设置 > 接口管理 > 低值计费接口查询', module: 'spd-basic' },
'iface-insurance': { pageTitle: '基础设置 > 接口管理 > 医保信息核对', module: 'spd-basic' },
'iface-vendor-code': { pageTitle: '基础设置 > 接口管理 > 厂商对码', module: 'spd-basic' },
'iface-peripheral': { pageTitle: '基础设置 > 接口管理 > 外围产品变更', module: 'spd-basic' },
'iface-ext-detail': { pageTitle: '基础设置 > 接口管理 > 外部变更明细信息查询', module: 'spd-basic' },
'iface-platform-diff': { pageTitle: '基础设置 > 接口管理 > 平台产品价格差异查询', module: 'spd-basic' },
'iface-api-log': { pageTitle: '基础设置 > 接口管理 > 接口日志', module: 'spd-basic' },
'iface-push-price': { pageTitle: '基础设置 > 接口管理 > 推送物价', module: 'spd-basic' },
'iface-charge-code': { pageTitle: '基础设置 > 接口管理 > 收费项目对码', module: 'spd-basic' },
'iface-cabinet-zone': { pageTitle: '基础设置 > 接口管理 > 智能柜与库区对码', module: 'spd-basic' },
'iface-mat-push': { pageTitle: '基础设置 > 接口管理 > 物资字典推送记录', module: 'spd-basic' },
'iface-req-query': { pageTitle: '基础设置 > 接口管理 > 请领单接口查询', module: 'spd-basic' },
'iface-campus-code': { pageTitle: '基础设置 > 接口管理 > 院区对码', module: 'spd-basic' },
};
const GROUPS = {
......@@ -99,6 +129,38 @@ const GROUPS = {
'--all-surgery': ['surgery-base-pkg', 'surgery-oper-type', 'surgery-std-pkg'],
'--all-warehouse': ['warehouse-zone', 'warehouse-probe'],
'--all-flow': ['flow-def', 'flow-inst'],
'--all-deptm': [
'deptm-register',
'deptm-category',
'deptm-product-alloc',
'deptm-zone-product',
'deptm-follow-limit',
'deptm-req-template',
'deptm-auto-req',
'deptm-staff',
'deptm-minmax',
'deptm-budget',
'deptm-lab-machine',
'deptm-delivery',
],
'--all-iface': [
'iface-his-price',
'iface-dept-req-ok',
'iface-subdept-req',
'iface-low-billing',
'iface-insurance',
'iface-vendor-code',
'iface-peripheral',
'iface-ext-detail',
'iface-platform-diff',
'iface-api-log',
'iface-push-price',
'iface-charge-code',
'iface-cabinet-zone',
'iface-mat-push',
'iface-req-query',
'iface-campus-code',
],
'--all-new': Object.keys(NEW_PAGES),
};
......@@ -124,7 +186,7 @@ async function main() {
const args = process.argv.slice(2);
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>',
'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-deptm|--all-iface|--all-new>',
);
process.exit(1);
}
......
#!/usr/bin/env python3
"""按 CSV 重编号与 §3.5 删除项,批量替换 tests/spd 中用例编号。"""
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
REMAP = json.loads((ROOT / "data/basic-modules-id-remap.json").read_text(encoding="utf-8"))
CSV_TO_SPEC = {
"基础管理-库房管理-库区设置-UI自动化测试用例-01.csv": "tests/spd/warehouse-zone.spec.ts",
"基础管理-库房管理-探针绑定-UI自动化测试用例-01.csv": "tests/spd/warehouse-probe.spec.ts",
"基础管理-流程设置-审批流程设置-UI自动化测试用例-01.csv": "tests/spd/flow-def.spec.ts",
"基础管理-流程设置-审批流实例-UI自动化测试用例-01.csv": "tests/spd/flow-inst.spec.ts",
"基础管理-手术套包设置-基础包维护-UI自动化测试用例-01.csv": "tests/spd/surgery-base-pkg.spec.ts",
"基础管理-手术套包设置-术种维护-UI自动化测试用例-01.csv": "tests/spd/surgery-oper-type.spec.ts",
"基础管理-手术套包设置-标准套包维护-UI自动化测试用例-01.csv": "tests/spd/surgery-std-pkg.spec.ts",
"基础管理-医院资料-公告管理-UI自动化测试用例-01.csv": "tests/spd/basic-notice.spec.ts",
"基础管理-医院资料-院区管理-UI自动化测试用例-01.csv": "tests/spd/basic-branch.spec.ts",
"基础管理-医院资料-财务月份设置-UI自动化测试用例-01.csv": "tests/spd/basic-finance-month.spec.ts",
"基础管理-医院资料-医院信息登记-UI自动化测试用例-01.csv": "tests/spd/basic-hospital-info.spec.ts",
"基础管理-用户管理-用户管理-UI自动化测试用例-01.csv": "tests/spd/basic-user.spec.ts",
"基础管理-用户管理-医共体用户管理-UI自动化测试用例-01.csv": "tests/spd/basic-user-union.spec.ts",
"基础管理-用户管理-角色管理-UI自动化测试用例-01.csv": "tests/spd/basic-role.spec.ts",
"基础管理-准入管理-物料准入管理-UI自动化测试用例-01.csv": "tests/spd/admit-material.spec.ts",
"基础管理-准入管理-产品准入申请-UI自动化测试用例-01.csv": "tests/spd/admit-product-apply.spec.ts",
"基础管理-准入管理-准入任务分配-UI自动化测试用例-01.csv": "tests/spd/admit-task.spec.ts",
"基础管理-准入管理-供货目录申请-UI自动化测试用例-01.csv": "tests/spd/admit-supply.spec.ts",
"基础管理-准入管理-物料准入单列表-UI自动化测试用例-01.csv": "tests/spd/admit-material-list.spec.ts",
"基础管理-准入管理-产品准入单列表-UI自动化测试用例-01.csv": "tests/spd/admit-product-list.spec.ts",
}
DROP_TEST_RE = re.compile(
r"菜单进入|未登录访问拦截|未登录'|按探针ID查询|按主机编码查询|SURG-(STD|TYPE)-001|SURG-(STD|TYPE)-003"
)
H3_REPLACE = [
("筛选与查询", "快捷筛选查询"),
("筛选查询", "快捷筛选查询"),
("列表与分页", "列表"),
("页面进入", "快捷筛选查询"), # leftover describes
]
def remove_test_block(text: str, case_id: str) -> str:
"""Remove test('CASE-ID ...') { ... }); block."""
pattern = re.compile(
rf"\n\s*test\('{re.escape(case_id)}[^']*'[^)]*\)[^{{]*\{{",
re.MULTILINE,
)
m = pattern.search(text)
if not m:
return text
start = m.start()
i = m.end()
depth = 1
while i < len(text) and depth > 0:
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
i += 1
# include trailing );
while i < len(text) and text[i] in " \t;\n":
if text[i] == ";":
i += 1
break
i += 1
return text[:start] + text[i:]
def strip_page_enter_describe(text: str) -> str:
"""Remove test.describe('...页面进入'...) block (brace-balanced)."""
pattern = re.compile(r"\n\s*test\.describe\('[^']*页面进入[^']*',\s*\(\)\s*=>\s*\{")
m = pattern.search(text)
if not m:
return text
start = m.start()
i = m.end()
depth = 1
while i < len(text) and depth > 0:
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
i += 1
while i < len(text) and text[i] in " \t\n":
i += 1
if text[i : i + 2] == ");":
i += 2
return text[:start] + text[i:]
def apply_remap(text: str, pairs: list[list[str]]) -> str:
for old, new in sorted(pairs, key=lambda x: len(x[0]), reverse=True):
text = text.replace(f"test('{old}", f"test('{new}")
text = text.replace(f"test(\"{old}", f"test(\"{new}")
return text
def main() -> None:
for csv_name, spec_rel in CSV_TO_SPEC.items():
spec_path = ROOT / spec_rel
if not spec_path.exists():
print(f"skip missing {spec_rel}")
continue
text = spec_path.read_text(encoding="utf-8")
pairs = REMAP.get(csv_name, [])
old_ids = {p[0] for p in pairs}
# dropped ids: in spec but not in any new id and not in remap old->new chain
new_ids = {p[1] for p in pairs}
for old, new in pairs:
if old not in text and new in text:
continue
# Remove known forbidden tests by id prefix scan
for m in re.finditer(r"test\('([A-Z]+-[A-Z0-9]+-\d+)", text):
tid = m.group(1)
if DROP_TEST_RE.search(m.group(0)):
text = remove_test_block(text, tid)
text = strip_page_enter_describe(text)
text = apply_remap(text, pairs)
for a, b in H3_REPLACE:
text = text.replace(a, b)
text = text.replace("changePageSize(100)", "switchToPageSizeTwenty()")
spec_path.write_text(text, encoding="utf-8")
print(f"patched {spec_rel}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""按 ui-test-point-rules §3.5/3.6/3.7/3.1 批量改造六模块 CSV,并输出 spec 删改清单。"""
from __future__ import annotations
import csv
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TESTCASE = ROOT / "testcase"
SORT_JSON = ROOT / "data" / "spd-admin-sort-columns.json"
FORBIDDEN_TITLE_RE = re.compile(r"菜单进入|未登录访问拦截|未登录")
SINGLE_FIELD_QUERY_RE = re.compile(r"^按.+查询$|^探针ID查询$")
PAGE20_TITLE = "切换20条每页页面正常且列表同步"
PAGE20_STEPS = """1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 点击「查 询」,记录分页「共 N 条」与当前页可见行数
3. 分页区切换每页条数为「20 条/页」
4. 断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;筛选区、表头可见无报错"""
PAGE20_PRE = (
"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,"
"勿写入本表);{perm};{url_note};默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip"
)
PAGE20_RESULT = "切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。"
COL_SET_TITLE = "列设置隐藏后再恢复"
COL_SET_STEPS = """1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 点击「查 询」并等待列表稳定
3. 打开列设置,取消勾选一列非核心字段并关闭,断言表头隐藏
4. 再次打开列设置勾选恢复并关闭,断言表头恢复"""
COL_SET_PRE = (
"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;{perm};{url_note};"
"列表表头已加载;存在可隐藏的非核心列"
)
COL_SET_RESULT = "勾选状态与表头一致。"
SORT_STEP_TEMPLATE = """1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 默认查询后列表有数据
3. 横向滚动使「{col}」列表头可见(宽表时)
4. 第一次点击该列排序控件并校验升序指示器
5. 第二次点击并校验降序指示器"""
@dataclass
class ModuleCfg:
csv_name: str
id_prefix: str # WH-PRB
perm: str
url_fragment: str
page20: bool = False
column_settings: bool = False
sort_page_key: str = ""
drop_single_field_queries: bool = False
fix_page_size_legacy: bool = False
MODULES: list[ModuleCfg] = [
ModuleCfg(
"基础管理-库房管理-库区设置-UI自动化测试用例-01.csv",
"WH-ZON",
"具备「基础设置 → 库房管理 → 库区设置」权限",
"已进入库区设置页(/spd/basic/storehouse/platform/storehouse/area,无 iframe)",
page20=False,
column_settings=True,
),
ModuleCfg(
"基础管理-库房管理-探针绑定-UI自动化测试用例-01.csv",
"WH-PRB",
"具备「基础设置 → 库房管理 → 探针绑定」权限",
"已进入探针绑定页(/spd/basic/storehouse/storehouse/probe,无 iframe)",
page20=True,
column_settings=True,
drop_single_field_queries=True,
),
ModuleCfg(
"基础管理-流程设置-审批流程设置-UI自动化测试用例-01.csv",
"FLOW-DEF",
"具备「基础设置 → 流程设置 → 审批流程设置」权限",
"已进入审批流程设置页(/spd/basic/flowSet/workflow/flowSet,无 iframe)",
),
ModuleCfg(
"基础管理-流程设置-审批流实例-UI自动化测试用例-01.csv",
"FLOW-INS",
"具备「基础设置 → 流程设置 → 审批流实例」权限",
"已进入审批流实例页(/spd/basic/flowSet/workflow/flowCase,无 iframe)",
page20=True,
),
ModuleCfg(
"基础管理-手术套包设置-基础包维护-UI自动化测试用例-01.csv",
"SURG-BASE",
"具备「基础设置 → 手术套包设置」权限",
"已进入基础包维护页(/spd/basic/operationSetting/baseMaintenance,无 iframe)",
fix_page_size_legacy=True,
page20=True,
),
ModuleCfg(
"基础管理-手术套包设置-术种维护-UI自动化测试用例-01.csv",
"SURG-TYPE",
"具备「基础设置 → 手术套包设置」权限",
"已进入术种维护页(/spd/basic/operationSetting/typeMaintenance,无 iframe)",
fix_page_size_legacy=True,
page20=True,
),
ModuleCfg(
"基础管理-手术套包设置-标准套包维护-UI自动化测试用例-01.csv",
"SURG-STD",
"具备「基础设置 → 手术套包设置」权限",
"已进入标准套包维护页(/spd/basic/operationSetting/standardMaintenance,无 iframe)",
),
ModuleCfg(
"基础管理-医院资料-公告管理-UI自动化测试用例-01.csv",
"BASIC-NTC",
"具备医院资料权限",
"已进入公告管理页(/spd/basic/hospital/paltform/notice,无 iframe)",
page20=True,
column_settings=True,
),
ModuleCfg(
"基础管理-医院资料-院区管理-UI自动化测试用例-01.csv",
"BASIC-BRN",
"具备医院资料权限",
"已进入院区管理页(/spd/basic/hospital/paltform/area,无 iframe)",
page20=True,
column_settings=True,
),
ModuleCfg(
"基础管理-医院资料-财务月份设置-UI自动化测试用例-01.csv",
"BASIC-FMN",
"具备医院资料权限",
"已进入财务月份设置页(/spd/basic/hospital/balance/month,无 iframe)",
page20=True,
column_settings=True,
),
ModuleCfg(
"基础管理-医院资料-医院信息登记-UI自动化测试用例-01.csv",
"BASIC-HOS",
"具备医院资料权限",
"已进入医院信息登记页(/spd/basic/hospital/paltform/info,无 iframe)",
),
ModuleCfg(
"基础管理-用户管理-用户管理-UI自动化测试用例-01.csv",
"USER-USR",
"具备「基础设置 → 用户管理 → 用户管理」权限",
"已进入用户管理页(/spd/basic/user/platform/person,无 iframe)",
page20=True,
column_settings=True,
),
ModuleCfg(
"基础管理-用户管理-医共体用户管理-UI自动化测试用例-01.csv",
"USER-UNI",
"具备「基础设置 → 用户管理 → 医共体用户管理」权限",
"已进入医共体用户管理页(/spd/basic/user/platform/personCom,无 iframe)",
page20=True,
),
ModuleCfg(
"基础管理-用户管理-角色管理-UI自动化测试用例-01.csv",
"USER-ROL",
"具备「基础设置 → 用户管理 → 角色管理」权限",
"已进入角色管理页(/spd/basic/user/platform/role,无 iframe)",
),
ModuleCfg(
"基础管理-准入管理-物料准入管理-UI自动化测试用例-01.csv",
"ADMIT-MAT",
"具备「基础设置 → 准入管理」权限",
"已进入物料准入管理页(/spd/basic/dataSync/dataSync/productApply/ZRlist,无 iframe)",
sort_page_key="admit-material",
page20=True,
),
ModuleCfg(
"基础管理-准入管理-产品准入申请-UI自动化测试用例-01.csv",
"ADMIT-PAP",
"具备「基础设置 → 准入管理」权限",
"已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe)",
sort_page_key="admit-product-apply",
page20=True,
),
ModuleCfg(
"基础管理-准入管理-准入任务分配-UI自动化测试用例-01.csv",
"ADMIT-TSK",
"具备「基础设置 → 准入管理」权限",
"已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe)",
sort_page_key="admit-task",
page20=True,
),
ModuleCfg(
"基础管理-准入管理-供货目录申请-UI自动化测试用例-01.csv",
"ADMIT-SUP",
"具备「基础设置 → 准入管理」权限",
"已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe)",
page20=True,
column_settings=True,
),
ModuleCfg(
"基础管理-准入管理-物料准入单列表-UI自动化测试用例-01.csv",
"ADMIT-MLS",
"具备「基础设置 → 准入管理」权限",
"已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe)",
page20=True,
column_settings=True,
),
ModuleCfg(
"基础管理-准入管理-产品准入单列表-UI自动化测试用例-01.csv",
"ADMIT-PLS",
"具备「基础设置 → 准入管理」权限",
"已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe)",
page20=True,
column_settings=True,
),
]
FIELDS = [
"用例编号",
"功能模块",
"功能测试点",
"用例标题",
"优先级",
"前置条件",
"测试步骤",
"预期结果",
"实际结果",
]
def normalize_h3(fp: str, title: str) -> str:
if " · 页面进入" in fp or fp.endswith("页面进入"):
return fp # row will be dropped
fp = fp.replace(" · 筛选与查询", " · 快捷筛选查询")
fp = fp.replace(" · 筛选查询", " · 快捷筛选查询")
if " · 列表与分页" in fp or " · 列表分页" in fp:
base = fp.split(" · ")[0] + " · "
if any(
k in title
for k in (
"翻",
"跳至",
"20条",
"每页",
"页大小",
"上一页",
PAGE20_TITLE,
"切换每页",
)
):
return base + "分页"
if "列设置" in title:
return base + "列表"
return base + "列表"
if COL_SET_TITLE in title or "列设置" in title:
parts = fp.split(" · ")
if len(parts) >= 2:
return parts[0] + " · 列表"
if "列升序降序切换" in title:
parts = fp.split(" · ")
if len(parts) >= 2:
return parts[0] + " · 列表"
return fp
def should_drop_row(title: str, cfg: ModuleCfg) -> bool:
if FORBIDDEN_TITLE_RE.search(title):
return True
if cfg.drop_single_field_queries and re.match(r"^按.+查询$", title.strip()):
return True
return False
def fix_legacy_page_size(row: dict) -> None:
t = row.get("用例标题", "")
if "页大小" in t or "分页与页大小" in t:
row["用例标题"] = PAGE20_TITLE
row["优先级"] = "中"
row["前置条件"] = PAGE20_PRE.format(
perm=row["前置条件"].split(";")[2] if ";" in row["前置条件"] else "",
url_note="",
)
row["测试步骤"] = PAGE20_STEPS
row["预期结果"] = PAGE20_RESULT
def renumber(rows: list[dict], prefix: str) -> list[tuple[str, str]]:
mapping: list[tuple[str, str]] = []
for i, row in enumerate(rows, start=1):
old = row["用例编号"]
new = f"{prefix}-{i:03d}"
mapping.append((old, new))
row["用例编号"] = new
return mapping
def has_title(rows: list[dict], title: str) -> bool:
return any(r["用例标题"] == title for r in rows)
def append_page20(rows: list[dict], cfg: ModuleCfg, fp_prefix: str) -> None:
if has_title(rows, PAGE20_TITLE):
for r in rows:
if r["用例标题"] == PAGE20_TITLE:
r["测试步骤"] = PAGE20_STEPS
r["预期结果"] = PAGE20_RESULT
r["功能测试点"] = normalize_h3(
r["功能测试点"].replace("列表与分页", "分页"), PAGE20_TITLE
)
return
fp = fp_prefix + " · 分页"
rows.append(
{
"用例编号": "",
"功能模块": rows[0]["功能模块"] if rows else "",
"功能测试点": fp,
"用例标题": PAGE20_TITLE,
"优先级": "中",
"前置条件": PAGE20_PRE.format(perm=cfg.perm, url_note=cfg.url_fragment),
"测试步骤": PAGE20_STEPS,
"预期结果": PAGE20_RESULT,
"实际结果": "",
}
)
def append_column_settings(rows: list[dict], cfg: ModuleCfg, fp_prefix: str) -> None:
if has_title(rows, COL_SET_TITLE) or any("列设置" in r["用例标题"] for r in rows):
return
fp = fp_prefix + " · 列表"
rows.append(
{
"用例编号": "",
"功能模块": rows[0]["功能模块"] if rows else "",
"功能测试点": fp,
"用例标题": COL_SET_TITLE,
"优先级": "低",
"前置条件": COL_SET_PRE.format(perm=cfg.perm, url_note=cfg.url_fragment),
"测试步骤": COL_SET_STEPS,
"预期结果": COL_SET_RESULT,
"实际结果": "",
}
)
def append_sort_columns(rows: list[dict], cfg: ModuleCfg, fp_prefix: str, sort_cols: list[str]) -> None:
for col in sort_cols:
title = f"{col}列升序降序切换"
if has_title(rows, title):
continue
fp = fp_prefix + " · 列表"
rows.append(
{
"用例编号": "",
"功能模块": rows[0]["功能模块"] if rows else "",
"功能测试点": fp,
"用例标题": title,
"优先级": "中",
"前置条件": COL_SET_PRE.format(perm=cfg.perm, url_note=cfg.url_fragment).replace(
"存在可隐藏的非核心列", "至少 2 条数据,否则 skip"
),
"测试步骤": SORT_STEP_TEMPLATE.format(col=col),
"预期结果": "排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。",
"实际结果": "",
}
)
def process_cfg(cfg: ModuleCfg, sort_data: dict) -> list[tuple[str, str]]:
path = TESTCASE / cfg.csv_name
with path.open(encoding="utf-8", newline="") as f:
rows = list(csv.DictReader(f))
mapping_drop = [r["用例编号"] for r in rows if should_drop_row(r["用例标题"], cfg)]
out: list[dict] = []
for row in rows:
if should_drop_row(row["用例标题"], cfg):
continue
if cfg.fix_page_size_legacy and ("页大小" in row["用例标题"] or "50 → 100" in row.get("测试步骤", "")):
fix_legacy_page_size(row)
row["功能测试点"] = normalize_h3(row["功能测试点"], row["用例标题"])
out.append(row)
module = out[0]["功能模块"] if out else ""
fp_prefix = out[0]["功能测试点"].split(" · ")[0] if out and " · " in out[0]["功能测试点"] else module
if cfg.page20:
append_page20(out, cfg, fp_prefix)
if cfg.column_settings:
append_column_settings(out, cfg, fp_prefix)
sort_cols = sort_data.get(cfg.sort_page_key, []) if cfg.sort_page_key else []
if sort_cols:
append_sort_columns(out, cfg, fp_prefix, sort_cols)
id_map = renumber(out, cfg.id_prefix)
with path.open("w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=FIELDS)
w.writeheader()
w.writerows(out)
print(f"OK {cfg.csv_name}\t{len(rows)} -> {len(out)}\tdropped {mapping_drop}")
return id_map
def main() -> None:
sort_data = json.loads(SORT_JSON.read_text(encoding="utf-8")) if SORT_JSON.exists() else {}
all_maps: dict[str, list[tuple[str, str]]] = {}
for cfg in MODULES:
all_maps[cfg.csv_name] = process_cfg(cfg, sort_data)
(ROOT / "data" / "basic-modules-id-remap.json").write_text(
json.dumps(all_maps, ensure_ascii=False, indent=2), encoding="utf-8"
)
print("wrote data/basic-modules-id-remap.json")
if __name__ == "__main__":
main()
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 科室管理 (UI-SPEC DEPTM-001~012).
* 先按菜单点击解析真实 URL,再扫描交互元素,最后由 JSON 提炼 locator YAML。
*
* 输出:
* data/scan/deptm-*-elements.json
* locators/spd/deptm-*.locator.yaml
* data/scan/deptm-menu-urls.json
* data/scan/deptm-scan-summary.json
*
* Usage:
* node scripts/scan-deptm-batch.mjs
* node scripts/scan-deptm-batch.mjs deptm-register deptm-product-alloc
*
* 只重算 YAML(不重新扫描):
* node scripts/json-to-locator-yaml.mjs --all-deptm
*/
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, 'deptm-menu-urls.json');
const SUMMARY_OUT = path.join(OUT_DIR, 'deptm-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-tree',
'.ant-list-item',
'.ant-menu-item',
];
const MENUS = [
{
pageKey: 'deptm-register',
menu: '科室登记',
pageTitle: '基础设置 > 科室管理 > 科室登记',
specId: 'DEPTM-001',
skipQueryClick: false,
interactivePhases: [
openFirstFilterDropdown,
openAdvancedFilter,
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /配\s*置/, 'configOpened'),
],
},
{
pageKey: 'deptm-category',
menu: '科室分类维护',
pageTitle: '基础设置 > 科室管理 > 科室分类维护',
specId: 'DEPTM-002',
skipQueryClick: true,
interactivePhases: [
selectLeftFirstItem,
openFirstFilterDropdown,
(page) => openNamedButton(page, /添\s*加/, 'dialogOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /日\s*志/, 'logOpened'),
],
},
{
pageKey: 'deptm-product-alloc',
menu: '科室产品分配',
pageTitle: '基础设置 > 科室管理 > 科室产品分配',
specId: 'DEPTM-003',
skipQueryClick: true,
interactivePhases: [
selectLeftFirstItem,
openFirstFilterDropdown,
openAdvancedFilter,
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openRowAction(page, /详\s*情/, 'detailOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
],
},
{
pageKey: 'deptm-zone-product',
menu: '科室库区产品设置',
pageTitle: '基础设置 > 科室管理 > 科室库区产品设置',
specId: 'DEPTM-004',
skipQueryClick: true,
interactivePhases: [
selectLeftFirstItem,
openFirstFilterDropdown,
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
],
},
{
pageKey: 'deptm-follow-limit',
menu: '科室跟台限额任务',
pageTitle: '基础设置 > 科室管理 > 科室跟台限额任务',
specId: 'DEPTM-005',
skipQueryClick: false,
interactivePhases: [
openFirstFilterDropdown,
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openRowAction(page, /详\s*情/, 'detailOpened'),
],
},
{
pageKey: 'deptm-req-template',
menu: '请领模板维护',
pageTitle: '基础设置 > 科室管理 > 请领模板维护',
specId: 'DEPTM-006',
skipQueryClick: true,
interactivePhases: [
selectLeftFirstItem,
openFirstFilterDropdown,
(page) => openNamedButton(page, /添加产品/, 'dialogOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
],
},
{
pageKey: 'deptm-auto-req',
menu: '自动请领排程',
pageTitle: '基础设置 > 科室管理 > 自动请领排程',
specId: 'DEPTM-007',
skipQueryClick: false,
interactivePhases: [
openFirstFilterDropdown,
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
],
},
{
pageKey: 'deptm-staff',
menu: '科室人员查询',
pageTitle: '基础设置 > 科室管理 > 科室人员查询',
specId: 'DEPTM-008',
skipQueryClick: true,
interactivePhases: [
expandFirstTreeNode,
selectLeftFirstItem,
],
},
{
pageKey: 'deptm-minmax',
menu: '科室上下限建议',
pageTitle: '基础设置 > 科室管理 > 科室上下限建议',
specId: 'DEPTM-009',
skipQueryClick: true,
interactivePhases: [
openFirstFilterDropdown,
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /调\s*整/, 'adjustOpened'),
],
},
{
pageKey: 'deptm-budget',
menu: '科室预算管理',
pageTitle: '基础设置 > 科室管理 > 科室预算管理',
specId: 'DEPTM-010',
skipQueryClick: false,
interactivePhases: [
openFirstFilterDropdown,
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openRowAction(page, /详\s*情/, 'detailOpened'),
],
},
{
pageKey: 'deptm-lab-machine',
menu: '检验机器管理',
pageTitle: '基础设置 > 科室管理 > 检验机器管理',
specId: 'DEPTM-011',
skipQueryClick: true,
interactivePhases: [
expandFirstTreeNode,
selectLeftFirstItem,
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
],
},
{
pageKey: 'deptm-delivery',
menu: '科室送货计划',
pageTitle: '基础设置 > 科室管理 > 科室送货计划',
specId: 'DEPTM-012',
skipQueryClick: true,
interactivePhases: [
selectLeftFirstItem,
(page) => openNamedButton(page, /添加科室库区/, 'dialogOpened'),
],
},
];
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 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
.locator('.ant-drawer-open, .ant-modal')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(600);
return { advancedFilterOpened: true };
}
async function expandFirstTreeNode(page) {
await closeOverlays(page);
const switcher = page.locator('.ant-tree-switcher:not(.ant-tree-switcher-noop)').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 };
}
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 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) {
await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 });
await page.waitForTimeout(500);
const title = page.locator('.ant-menu-submenu-title').filter({ hasText: /^科室管理$/ }).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('登录态失效,无法打开科室管理菜单');
}
await expandGroupMenu(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;
}
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-tree, .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 node
/**
* Batch scan: 基础设置 > 接口管理 (UI-SPEC IFACE-001~016).
* 先按菜单点击解析真实 URL,再扫描交互元素,最后由 JSON 提炼 locator YAML。
*
* 输出:
* data/scan/iface-*-elements.json
* locators/spd/iface-*.locator.yaml
* data/scan/iface-menu-urls.json
* data/scan/iface-scan-summary.json
*
* Usage:
* node scripts/scan-iface-batch.mjs
* node scripts/scan-iface-batch.mjs iface-api-log iface-push-price
*
* 只重算 YAML(不重新扫描):
* node scripts/json-to-locator-yaml.mjs --all-iface
*/
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, 'iface-menu-urls.json');
const SUMMARY_OUT = path.join(OUT_DIR, 'iface-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-tree',
'.ant-list-item',
'.ant-menu-item',
];
function ifaceDefaultPhases() {
return [
expandCollapsedFilters,
openFirstFilterDropdown,
openAdvancedFilter,
(page) => openRowAction(page, /详\s*情|查\s*看/, 'detailOpened'),
(page) => openRowAction(page, /编\s*辑|对\s*码|重\s*试|日\s*志/, 'rowActionOpened'),
(page) => openNamedButton(page, /推\s*送|同\s*步|导\s*出|新\s*增/, 'toolbarOpened'),
];
}
const MENUS = [
{
pageKey: 'iface-his-price',
menu: 'HIS价对照查询',
pageTitle: '基础设置 > 接口管理 > HIS价对照查询',
specId: 'IFACE-001',
skipQueryClick: false,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-dept-req-ok',
menu: '代为科室请领(已确认)',
pageTitle: '基础设置 > 接口管理 > 代为科室请领(已确认)',
specId: 'IFACE-002',
skipQueryClick: false,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-subdept-req',
menu: '代二级科室请领产品',
pageTitle: '基础设置 > 接口管理 > 代二级科室请领产品',
specId: 'IFACE-003',
skipQueryClick: false,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-low-billing',
menu: '低值计费接口查询',
pageTitle: '基础设置 > 接口管理 > 低值计费接口查询',
specId: 'IFACE-004',
skipQueryClick: false,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-insurance',
menu: '医保信息核对',
pageTitle: '基础设置 > 接口管理 > 医保信息核对',
specId: 'IFACE-005',
skipQueryClick: false,
scjsShell: true,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-vendor-code',
menu: '厂商对码',
pageTitle: '基础设置 > 接口管理 > 厂商对码',
specId: 'IFACE-006',
skipQueryClick: false,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-peripheral',
menu: '外围产品变更',
pageTitle: '基础设置 > 接口管理 > 外围产品变更',
specId: 'IFACE-007',
skipQueryClick: false,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-ext-detail',
menu: '外部变更明细信息查询',
pageTitle: '基础设置 > 接口管理 > 外部变更明细信息查询',
specId: 'IFACE-008',
skipQueryClick: false,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-platform-diff',
menu: '平台产品价格差异查询',
pageTitle: '基础设置 > 接口管理 > 平台产品价格差异查询',
specId: 'IFACE-009',
skipQueryClick: false,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-api-log',
menu: '接口日志',
pageTitle: '基础设置 > 接口管理 > 接口日志',
specId: 'IFACE-010',
skipQueryClick: false,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-push-price',
menu: '推送物价',
pageTitle: '基础设置 > 接口管理 > 推送物价',
specId: 'IFACE-011',
skipQueryClick: false,
scjsShell: true,
interactivePhases: [
expandCollapsedFilters,
openFirstFilterDropdown,
openAdvancedFilter,
(page) => openNamedButton(page, /推\s*送/, 'pushOpened'),
(page) => openRowAction(page, /详\s*情|查\s*看|重\s*试/, 'detailOpened'),
],
},
{
pageKey: 'iface-charge-code',
menu: '收费项目对码',
pageTitle: '基础设置 > 接口管理 > 收费项目对码',
specId: 'IFACE-012',
skipQueryClick: false,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-cabinet-zone',
menu: '智能柜与库区对码',
pageTitle: '基础设置 > 接口管理 > 智能柜与库区对码',
specId: 'IFACE-013',
skipQueryClick: false,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-mat-push',
menu: '物资字典推送记录',
pageTitle: '基础设置 > 接口管理 > 物资字典推送记录',
specId: 'IFACE-014',
skipQueryClick: false,
scjsShell: true,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-req-query',
menu: '请领单接口查询',
pageTitle: '基础设置 > 接口管理 > 请领单接口查询',
specId: 'IFACE-015',
skipQueryClick: false,
scjsShell: true,
interactivePhases: ifaceDefaultPhases(),
},
{
pageKey: 'iface-campus-code',
menu: '院区对码',
pageTitle: '基础设置 > 接口管理 > 院区对码',
specId: 'IFACE-016',
skipQueryClick: false,
interactivePhases: ifaceDefaultPhases(),
},
];
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 locatorRoots(page) {
return [page, ...page.frames().filter((f) => f !== page.mainFrame())];
}
function firstDataRow(root) {
return root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
}
async function expandCollapsedFilters(page) {
await closeOverlays(page);
for (const root of locatorRoots(page)) {
const btn = root.getByRole('button', { name: /展\s*开/ }).first();
if (await btn.isVisible().catch(() => false)) {
await btn.click({ timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(800);
return { filterExpanded: true };
}
}
return { filterExpanded: false, skipRescan: 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 openFirstFilterDropdown(page) {
await closeOverlays(page);
for (const root of locatorRoots(page)) {
const select = root.locator('.ant-form .ant-select, .ant-form-item .ant-select').first();
if (!(await select.isVisible().catch(() => false))) continue;
await select.click({ timeout: 10_000 });
await root
.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 };
}
return { dropdownOpened: false, skipRescan: true };
}
async function openAdvancedFilter(page) {
await closeOverlays(page);
for (const root of locatorRoots(page)) {
const btn = root.getByText(/高级筛选/).first();
if (!(await btn.isVisible().catch(() => false))) continue;
await btn.click({ timeout: 10_000 }).catch(() => undefined);
await root
.locator('.ant-drawer-open, .ant-modal')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(600);
return { advancedFilterOpened: true };
}
return { advancedFilterOpened: false, skipRescan: true };
}
async function expandFirstTreeNode(page) {
await closeOverlays(page);
const switcher = page.locator('.ant-tree-switcher:not(.ant-tree-switcher-noop)').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 };
}
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);
for (const root of locatorRoots(page)) {
const btn = root.getByRole('button', { name: pattern }).first();
if (!(await btn.isVisible().catch(() => false))) continue;
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 };
}
return { [flag]: false, skipRescan: true };
}
async function openRowAction(page, pattern, flag) {
await closeOverlays(page);
for (const root of locatorRoots(page)) {
const row = firstDataRow(root);
if (!(await row.isVisible().catch(() => false))) continue;
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))) continue;
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 };
}
return { [flag]: false, skipRescan: true };
}
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) {
await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 });
await page.waitForTimeout(500);
const title = page.locator('.ant-menu-submenu-title').filter({ hasText: /^接口管理$/ }).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('登录态失效,无法打开接口管理菜单');
}
await expandGroupMenu(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;
}
function pageCfg(targetUrl, item) {
return {
targetUrl,
useIframe: false,
iframeMatch: item.scjsShell ? 'scjs' : '',
iframeLabel: item.scjsShell ? 'scjsIframe' : '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-tree, .ant-list-item',
waitUntil: item.scjsShell ? 'networkidle' : 'domcontentloaded',
navigateSettleMs: item.scjsShell ? 12_000 : 5_000,
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);
});
......@@ -54,7 +54,8 @@ const SCAN_SCRIPT = `(() => {
'.ant-modal-close,.advance-filter,.ant-cascader,.ant-drawer-footer .ant-btn,' +
'.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';
'.ant-tree-checkbox,.ant-tree-switcher,.ant-tree-title,' +
'.ant-switch,.ant-radio-button-wrapper,.ant-segmented-item,.ant-segmented';
function isVisible(el) {
const style = window.getComputedStyle(el);
......@@ -82,6 +83,21 @@ const SCAN_SCRIPT = `(() => {
const t = wrap.querySelector('.ant-form-item-label, label, .label, span');
if (t && t !== el) return norm(t.textContent).slice(0, 80);
}
// ant-switch 常无 aria-label / 自身文本,取相邻文案(如「仅查看价格差异明细」)
if (el.classList.contains('ant-switch') || el.getAttribute('role') === 'switch') {
const host = el.closest('label, .ant-space-item, .ant-form-item, span, div') || el.parentElement;
if (host) {
const clone = host.cloneNode(true);
clone.querySelectorAll('.ant-switch,button,[role="switch"]').forEach((n) => n.remove());
const nearby = norm(clone.textContent).slice(0, 80);
if (nearby) return nearby;
}
const prev = el.previousElementSibling;
if (prev) {
const nearby = norm(prev.textContent).slice(0, 80);
if (nearby) return nearby;
}
}
return '';
}
......@@ -103,6 +119,8 @@ const SCAN_SCRIPT = `(() => {
if (el.classList.contains('ant-tabs-tab')) return 'tab';
if (el.classList.contains('advance-filter')) return 'filter_trigger';
if (el.classList.contains('ant-cascader')) return 'cascader';
if (el.classList.contains('ant-switch') || el.getAttribute('role') === 'switch') return 'switch';
if (el.classList.contains('ant-radio-button-wrapper') || el.classList.contains('ant-segmented-item')) return 'tab';
if (el.classList.contains('ant-pagination-item')) return 'pagination_item';
if (el.classList.contains('ant-pagination-options-size-changer')) return 'pagination_size';
if (el.closest('.ant-pagination-options-quick-jumper')) return 'pagination_jumper';
......@@ -188,6 +206,12 @@ const SCAN_SCRIPT = `(() => {
locators.push({ strategy: 'role', role: 'button', name: btnText, priority: 6 });
}
}
if (el.classList.contains('ant-switch') || role === 'switch') {
if (label) {
locators.push({ strategy: 'role', role: 'switch', name: label, priority: 5 });
}
locators.push({ strategy: 'css', value: '.ant-switch', priority: 9 });
}
if (el.classList.contains('advance-filter')) {
locators.push({ strategy: 'css', value: '.advance-filter', priority: 2 });
}
......@@ -341,7 +365,8 @@ function sanitizeUrl(url) {
function frameLabelFromUrl(url) {
if (!url || url === 'main') return 'main';
if (url.includes(CONFIG.iframeMatch)) return CONFIG.iframeLabel;
if (CONFIG.iframeMatch && url.includes(CONFIG.iframeMatch)) return CONFIG.iframeLabel || 'iframe';
if (url.includes('/scjs/') || url.includes('scjs#')) return 'scjsIframe';
if (url.includes('/report/centerPages/sheet')) return 'reportContentIframe';
if (url.includes('/report/centerPages/entry')) return 'reportEntryIframe';
if (url.includes('/report/centerPages/out')) return 'reportOutIframe';
......@@ -354,12 +379,20 @@ function frameLabelFromUrl(url) {
return sanitizeUrl(url);
}
function frameLocatorExpr(frameLabel) {
if (!frameLabel || frameLabel === 'main') return 'page';
if (frameLabel === 'scjsIframe' || (CONFIG.iframeMatch && CONFIG.iframeMatch.includes('scjs'))) {
return `page.frameLocator('iframe[src*="scjs"]')`;
}
if (CONFIG.iframeMatch) {
return `page.frameLocator('iframe[src*="${CONFIG.iframeMatch}"]')`;
}
return `page.frameLocator('iframe')`;
}
function toPlaywrightExpr(loc, frameLabel) {
if (!loc) return null;
const prefix =
frameLabel && frameLabel !== 'main'
? `page.frameLocator('iframe[src*="${CONFIG.iframeMatch}"]')`
: 'page';
const prefix = frameLocatorExpr(frameLabel);
switch (loc.strategy) {
case 'testId':
return `${prefix}.getByTestId('${loc.value.replace(/'/g, "\\'")}')`;
......@@ -764,6 +797,8 @@ function buildYaml(scan) {
key = 'totalText';
} else if (el.type === 'button' || el.tag === 'button' || el.className?.includes('ant-btn')) {
key = slugify(el.text || baseName) + 'Btn';
} else if (el.type === 'switch') {
key = slugify(el.label || el.text || baseName) + 'Switch';
} else if (el.type === 'filter_trigger') {
key = 'advancedFilterBtn';
} else if (el.type === 'text' || el.type === 'search' || el.placeholder) {
......@@ -818,8 +853,12 @@ function buildYaml(scan) {
lines.push(` element_type: ${el.type}`);
lines.push(` section: ${JSON.stringify(section.replace(/token=[^&]+/g, 'token=${token}'))}`);
if (frameLabel !== 'main') {
const srcHint =
frameLabel === 'scjsIframe' || (CONFIG.iframeMatch && CONFIG.iframeMatch.includes('scjs'))
? 'scjs'
: CONFIG.iframeMatch || '';
lines.push(` frame: ${JSON.stringify(frameLabel)}`);
lines.push(` frame_selector: iframe[src*="${CONFIG.iframeMatch}"]`);
if (srcHint) lines.push(` frame_selector: iframe[src*="${srcHint}"]`);
}
if (rec) {
lines.push(` strategy: ${rec.strategy}`);
......@@ -876,6 +915,13 @@ export function writeYamlFromScanJson(jsonPath, config = {}) {
if (!meta.iframeMatch && scan.url?.includes('/report/')) {
meta.iframeMatch = scan.url.split('/report/')[1]?.split('?')[0] || '';
}
if (!meta.iframeMatch) {
const scjs = scan.frames?.find((f) => (f.url || '').includes('/scjs/') || (f.label || '').includes('scjs'));
if (scjs) {
meta.iframeMatch = 'scjs';
meta.iframeLabel = meta.iframeLabel || 'scjsIframe';
}
}
}
const yaml = buildYamlFromScanJson(scan, meta);
mkdirSync(path.dirname(yamlOut), { recursive: true });
......@@ -933,10 +979,12 @@ async function findInAnyFrame(page, locatorFactory) {
return null;
}
async function waitReportReady(reportFrame) {
async function waitReportReady(page, reportFrame) {
const selectors = [
...CONFIG.waitSelectors,
CONFIG.waitSelector,
'iframe[src*="scjs"]',
'iframe[src*="iframe=true"]',
'#form_item_goodsName',
'#form_item_goodsCode',
'#form_item_lmmName',
......@@ -946,14 +994,25 @@ async function waitReportReady(reportFrame) {
'#form_item_statPeriod',
'.ant-form-item input',
'.ant-form',
'.ant-table',
'.ant-btn',
].filter(Boolean);
const unique = [...new Set(selectors)];
for (const sel of unique) {
try {
await reportFrame.locator(sel).first().waitFor({ state: 'visible', timeout: 12_000 });
return sel;
} catch {
/* try next */
const framesToTry =
CONFIG.useIframe === false
? [
reportFrame,
...page.frames().filter((f) => f !== page.mainFrame()),
]
: [reportFrame];
for (const frame of framesToTry) {
for (const sel of unique) {
try {
await frame.locator(sel).first().waitFor({ state: 'visible', timeout: 12_000 });
return { selector: sel, frame };
} catch {
/* try next */
}
}
}
throw new Error(`Report iframe not ready; tried: ${unique.join(', ')}`);
......@@ -1030,11 +1089,14 @@ async function closeScanOverlays(page) {
}
async function prepareReportPage(page) {
const reportFrame = CONFIG.useIframe === false
let reportFrame = CONFIG.useIframe === false
? page
: page.frameLocator(`iframe[src*="${CONFIG.iframeMatch}"]`);
const readySelector = await waitReportReady(reportFrame);
console.log(` content ready (${readySelector})`);
const ready = await waitReportReady(page, reportFrame);
if (CONFIG.useIframe === false && ready.frame !== page) {
reportFrame = ready.frame;
}
console.log(` content ready (${ready.selector})`);
const meta = { queryExecuted: false, advancedFilterOpened: false, rowExpanded: false };
if (CONFIG.skipQueryClick !== true) {
......@@ -1099,7 +1161,7 @@ export async function runScan(overrides = {}) {
waitUntil: CONFIG.waitUntil || 'networkidle',
timeout: 120_000,
});
await page.waitForTimeout(5000);
await page.waitForTimeout(CONFIG.navigateSettleMs ?? 5000);
const prepMeta = await prepareReportPage(page);
......
"""从 data/scan/*-elements.json 与 data/spd-admin-sort-columns.json 探测列表能力。"""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCAN_DIR = ROOT / "data" / "scan"
SORT_COLUMNS_JSON = ROOT / "data" / "spd-admin-sort-columns.json"
_SKIP_SORT_LABELS = frozenset({"操作", "序号", "#", ""})
def _scan_sort_labels(page_key: str) -> list[str]:
path = SCAN_DIR / f"{page_key}-elements.json"
if not path.exists():
return []
data = json.loads(path.read_text(encoding="utf-8"))
seen: list[str] = []
for el in data.get("elements", []):
cn = el.get("className") or ""
if "ant-table-column-sorter" not in cn:
continue
label = (el.get("label") or el.get("text") or "").strip()
if not label or label in _SKIP_SORT_LABELS or label.startswith("操作"):
continue
if label not in seen:
seen.append(label)
return seen
def _configured_sort_columns(page_key: str) -> list[str]:
if not SORT_COLUMNS_JSON.exists():
return []
data = json.loads(SORT_COLUMNS_JSON.read_text(encoding="utf-8"))
cols = data.get(page_key) or []
return [str(c).strip() for c in cols if str(c).strip()]
def sortable_column_labels(page_key: str) -> list[str]:
"""可排序列名:以 spd-admin-sort-columns.json 为准,扫描结果仅作补全去重。"""
configured = _configured_sort_columns(page_key)
if configured:
return configured
return _scan_sort_labels(page_key)
def list_table_caps(page_key: str) -> dict[str, bool]:
path = SCAN_DIR / f"{page_key}-elements.json"
if not path.exists():
sort_cols = sortable_column_labels(page_key)
return {
"sortable": len(sort_cols) > 0,
"column_settings": False,
}
text = path.read_text(encoding="utf-8")
sort_cols = sortable_column_labels(page_key)
return {
"sortable": "ant-table-column-sorter" in text or len(sort_cols) > 0,
"column_settings": (
"anticon-setting" in text
or "ant-pro-table-list-toolbar-setting" in text
or "column-height" in text
),
}
#!/usr/bin/env python3
"""安全同步六模块 spec:删 §3.5、ID 重映射、标题对齐 CSV、追加缺失的 20条/页/列设置/列排序。"""
from __future__ import annotations
import csv
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
REMAP = json.loads((ROOT / "data/basic-modules-id-remap.json").read_text(encoding="utf-8"))
CSV_TO_SPEC: dict[str, tuple[str, str, bool]] = {
# csv -> (spec_rel, fixture, query_before_list_ops)
"基础管理-库房管理-库区设置-UI自动化测试用例-01.csv": ("tests/spd/warehouse-zone.spec.ts", "warehouseZonePage", False),
"基础管理-库房管理-探针绑定-UI自动化测试用例-01.csv": ("tests/spd/warehouse-probe.spec.ts", "warehouseProbePage", True),
"基础管理-流程设置-审批流程设置-UI自动化测试用例-01.csv": ("tests/spd/flow-def.spec.ts", "flowDefPage", False),
"基础管理-流程设置-审批流实例-UI自动化测试用例-01.csv": ("tests/spd/flow-inst.spec.ts", "flowInstPage", True),
"基础管理-手术套包设置-基础包维护-UI自动化测试用例-01.csv": ("tests/spd/surgery-base-pkg.spec.ts", "surgeryBasePkgPage", False),
"基础管理-手术套包设置-术种维护-UI自动化测试用例-01.csv": ("tests/spd/surgery-oper-type.spec.ts", "surgeryOperTypePage", False),
"基础管理-手术套包设置-标准套包维护-UI自动化测试用例-01.csv": ("tests/spd/surgery-std-pkg.spec.ts", "surgeryStdPkgPage", False),
"基础管理-医院资料-公告管理-UI自动化测试用例-01.csv": ("tests/spd/basic-notice.spec.ts", "basicNoticePage", True),
"基础管理-医院资料-院区管理-UI自动化测试用例-01.csv": ("tests/spd/basic-branch.spec.ts", "basicBranchPage", False),
"基础管理-医院资料-财务月份设置-UI自动化测试用例-01.csv": ("tests/spd/basic-finance-month.spec.ts", "basicFinanceMonthPage", True),
"基础管理-医院资料-医院信息登记-UI自动化测试用例-01.csv": ("tests/spd/basic-hospital-info.spec.ts", "basicHospitalInfoPage", False),
"基础管理-用户管理-用户管理-UI自动化测试用例-01.csv": ("tests/spd/basic-user.spec.ts", "basicUserPage", True),
"基础管理-用户管理-医共体用户管理-UI自动化测试用例-01.csv": ("tests/spd/basic-user-union.spec.ts", "basicUserUnionPage", True),
"基础管理-用户管理-角色管理-UI自动化测试用例-01.csv": ("tests/spd/basic-role.spec.ts", "basicRolePage", True),
"基础管理-准入管理-物料准入管理-UI自动化测试用例-01.csv": ("tests/spd/admit-material.spec.ts", "admitMaterialPage", True),
"基础管理-准入管理-产品准入申请-UI自动化测试用例-01.csv": ("tests/spd/admit-product-apply.spec.ts", "admitProductApplyPage", True),
"基础管理-准入管理-准入任务分配-UI自动化测试用例-01.csv": ("tests/spd/admit-task.spec.ts", "admitTaskPage", True),
"基础管理-准入管理-供货目录申请-UI自动化测试用例-01.csv": ("tests/spd/admit-supply.spec.ts", "admitSupplyPage", True),
"基础管理-准入管理-物料准入单列表-UI自动化测试用例-01.csv": ("tests/spd/admit-material-list.spec.ts", "admitMaterialListPage", True),
"基础管理-准入管理-产品准入单列表-UI自动化测试用例-01.csv": ("tests/spd/admit-product-list.spec.ts", "admitProductListPage", True),
}
DROP_TITLE = re.compile(r"菜单进入|未登录访问拦截")
TEST_HEAD = re.compile(r"test\('([A-Z]+-[A-Z0-9]+-\d+)\s+([^']*)'")
def load_csv_cases(csv_name: str) -> dict[str, tuple[str, str]]:
path = ROOT / "testcase" / csv_name
with path.open(encoding="utf-8", newline="") as f:
rows = list(csv.DictReader(f))
return {r["用例编号"]: (r["功能测试点"], r["用例标题"]) for r in rows if r.get("用例编号")}
def remove_test_block(text: str, case_id: str) -> str:
pattern = re.compile(rf"\n(\s*)test\('{re.escape(case_id)}\s+[^']*'[^)]*\)[^{{]*\{{", re.MULTILINE)
m = pattern.search(text)
if not m:
return text
start = m.start()
i = m.end()
depth = 1
while i < len(text) and depth > 0:
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
i += 1
while i < len(text) and text[i] in " \t\n;":
if text[i] == ";":
i += 1
break
i += 1
return text[:start] + text[i:]
def remove_describe_if_name(text: str, pred) -> str:
pattern = re.compile(r"\n(\s*)test\.describe\('([^']*)',\s*\(\)\s*=>\s*\{")
while True:
hit = None
for m in pattern.finditer(text):
if pred(m.group(2)):
hit = m
break
if not hit:
return text
start = hit.start()
i = hit.end()
depth = 1
while i < len(text) and depth > 0:
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
i += 1
while i < len(text) and text[i] in " \t\n":
i += 1
if text[i : i + 2] == ");":
i += 2
text = text[:start] + text[i:]
return text
def apply_remap(text: str, pairs: list[list[str]]) -> str:
todo = [(o, n) for o, n in pairs if o and o != n]
for old, new in sorted(todo, key=lambda x: len(x[0]), reverse=True):
text = re.sub(rf"test\('{re.escape(old)}\s", f"test('{new} ", text)
return text
def sync_titles(text: str, cases: dict[str, tuple[str, str]]) -> str:
def repl(m: re.Match[str]) -> str:
cid, old_title = m.group(1), m.group(2)
if cid not in cases:
return m.group(0)
new_title = cases[cid][1]
if old_title == new_title:
return m.group(0)
return m.group(0).replace(f"{cid} {old_title}", f"{cid} {new_title}", 1)
return TEST_HEAD.sub(repl, text)
def sort_col_from_title(title: str) -> str | None:
if not title.endswith("列升序降序切换"):
return None
return title[: -len("列升序降序切换")]
def gen_test_body(fixture: str, title: str, need_query: bool) -> str:
open_q = f" await {fixture}.open();\n"
if need_query:
q = f" await {fixture}.query();\n"
else:
q = ""
if title == "切换20条每页页面正常且列表同步":
return (
open_q
+ q
+ f" const {{ total, rowsBefore, rowsAfter }} = await {fixture}.switchToPageSizeTwenty();\n"
+ " const expectedRows = Math.min(total, 20);\n"
+ " expect(rowsAfter, '当前页行数应与 20 条/页及总条数一致').toBe(expectedRows);\n"
+ " expect(rowsAfter, '切换后可见行应随每页条数变化').not.toBe(rowsBefore);\n"
+ f" expect(await {fixture}.getTotalCount(), '总条数应不变').toBe(total);\n"
+ f" await expect({fixture}.pageSizeChanger(), '每页条数').toContainText(/20/);\n"
)
if title == "列设置隐藏后再恢复":
return open_q + q + f" await {fixture}.executeColumnSettingsToggleRestoreTest();\n"
col = sort_col_from_title(title)
if col:
return open_q + q + f" await {fixture}.expectColumnSortAscDesc('{col}');\n"
return ""
def append_missing(text: str, cases: dict[str, tuple[str, str]], fixture: str, need_query: bool) -> str:
present = set(re.findall(r"test\('([A-Z]+-[A-Z0-9]+-\d+)", text))
missing = [cid for cid in cases if cid not in present]
if not missing:
return text
chunks: list[str] = []
by_h3: dict[str, list[str]] = {}
for cid in missing:
h3, title = cases[cid]
body = gen_test_body(fixture, title, need_query)
if not body:
continue
by_h3.setdefault(h3, []).append(
f" test('{cid} {title}', async ({{ {fixture} }}) => {{\n{body} }});\n"
)
for h3, tests in by_h3.items():
chunks.append(f"\n test.describe('{h3}', () => {{\n")
chunks.extend(tests)
chunks.append(" });\n")
insert_at = text.rfind("\n});")
if insert_at < 0:
return text + "".join(chunks)
return text[:insert_at] + "".join(chunks) + text[insert_at:]
def patch_file(csv_name: str, spec_rel: str, fixture: str, need_query: bool) -> None:
if csv_name.endswith("探针绑定"):
pass
spec_path = ROOT / spec_rel
if not spec_path.exists():
print(f"skip missing {spec_rel}")
return
cases = load_csv_cases(csv_name)
text = spec_path.read_text(encoding="utf-8")
text = remove_describe_if_name(text, lambda n: "页面进入" in n)
text = remove_describe_if_name(text, lambda n: n.strip() == "未登录")
for m in list(TEST_HEAD.finditer(text)):
cid, title = m.group(1), m.group(2)
if DROP_TITLE.search(title) or cid not in cases:
text = remove_test_block(text, cid)
pairs = REMAP.get(csv_name, [])
text = apply_remap(text, pairs)
text = sync_titles(text, cases)
text = text.replace("changePageSize(100)", "switchToPageSizeTwenty()")
text = text.replace("快捷快捷筛选查询", "快捷筛选查询")
text = re.sub(
r"switchToPageSizeTwenty\(\);\s*\n\s*expect\(rowsOnPage[^)]+\)\.toBeLessThanOrEqual\(100\)",
"switchToPageSizeTwenty();\n const expectedRows = Math.min(total, 20);\n expect(rowsAfter, '当前页行数应与 20 条/页及总条数一致').toBe(expectedRows)",
text,
)
text = re.sub(
r"const \{ total, rowsOnPage \} = await (\w+)\.switchToPageSizeTwenty\(\)",
r"const { total, rowsBefore, rowsAfter } = await \1.switchToPageSizeTwenty()",
text,
)
text = append_missing(text, cases, fixture, need_query)
spec_path.write_text(text, encoding="utf-8")
print(f"synced {spec_rel}")
def main() -> None:
for csv_name, (spec_rel, fixture, need_query) in CSV_TO_SPEC.items():
if "探针绑定" in csv_name:
print(f"skip manual {spec_rel}")
continue
patch_file(csv_name, spec_rel, fixture, need_query)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""将 docs/ 下页面截图同步至 knowledge-base/08-UI专项规范库(统计报表、准入、厂商、用户、库房、流程等)。"""
"""将 docs/ 下页面截图同步至 knowledge-base/08-UI专项规范库(统计报表、准入、厂商、用户、库房、流程、科室管理、接口管理等)。"""
from __future__ import annotations
......@@ -88,7 +88,7 @@ ADMIT_PAGES: list[dict] = [
"filters": "新增日期(区间)、进院供应商、耗材类型、产品名称、规格型号、注册证号、器械注册人",
"status_tabs": "全部、待申请、审批中、已通过、已驳回、已作废、生效失败、已生效",
"toolbar": "**关联产品**、**生成申请单**、**一键生成**、**作废**;列设置",
"list_note": "含准入类型(新品入院/产品增规/C码变更等)、产品编号/名称、规格型号、器械注册人、耗材类型、默认进院供应商;行操作 **详情**、**日志**、**编辑**(视状态)",
"list_note": "含准入类型(新品入院/产品增规/C码变更等)、物料编号/名称、规格型号、器械注册人、耗材类型、默认进院供应商;行操作 **详情**、**日志**、**编辑**(视状态)",
},
{
"id": "ADMIT-002",
......@@ -624,13 +624,462 @@ FLOW_PAGES: list[dict] = [
},
]
PAGES: list[dict] = STAT_PAGES + ADMIT_PAGES + VENDOR_PAGES + USER_PAGES + WH_PAGES + FLOW_PAGES
# 基础设置 → 科室管理(docs/基础管理-科室管理-*)
DEPTM_PAGES: list[dict] = [
{
"id": "DEPTM-001",
"name": "科室登记",
"folder": "基础管理-科室管理-科室登记",
"path": "/spd/basic/dept/platform/dept/list",
"scan_key": "deptm-register",
"filters": "院区名称、科室名称(科室编码/科室名称)、科室类型;**高级筛选**",
"status_tabs": "无独立状态 Tab",
"toolbar": "**新增**;列设置",
"list_note": (
"列:序号、院区、科室编码、科室名称、科室类型、特殊科室类别、是否打包科室、"
"科室地址、HIS映射科室;行操作 **编辑**、**配置**"
),
},
{
"id": "DEPTM-002",
"name": "科室分类维护",
"folder": "基础管理-科室管理-科室分类维护",
"path": "/spd/basic/dept/platform/dept/calatog",
"scan_key": "deptm-category",
"filters": "以截图为准",
"status_tabs": "以截图为准(若有)",
"toolbar": "以截图工具栏为准",
"list_note": "科室分类树或列表与维护操作以截图为准",
},
{
"id": "DEPTM-003",
"name": "科室产品分配",
"folder": "基础管理-科室管理-科室产品分配",
"path": "/spd/basic/dept/platform/dept/product",
"scan_key": "deptm-product-alloc",
"filters": "物料编号/名称、规格型号、耗材类型;**高级筛选**",
"status_tabs": "无独立状态 Tab",
"toolbar": "**新增**、**打印**、**导出**、**批量修改**、**Excel导入**(提示:导入模板以本页导出表为准);列设置",
"list_note": (
"左右分栏:左侧科室树(可搜科室名称),右侧为当前科室产品列表;"
"列含规格型号、器械注册人、默认销售商、单位、字典价、耗材分类、器械注册证号、"
"专科专用耗材、状态(启用等);行操作 **详情**、**编辑**;支持行勾选"
),
},
{
"id": "DEPTM-004",
"name": "科室库区产品设置",
"folder": "基础管理-科室管理-科室库区产品设置",
"path": "/spd/basic/dept/platform/dept/areaProduct",
"scan_key": "deptm-zone-product",
"filters": "以截图为准",
"status_tabs": "以截图为准(若有)",
"toolbar": "以截图工具栏为准",
"list_note": "科室库区与产品绑定列表以截图为准",
},
{
"id": "DEPTM-005",
"name": "科室跟台限额任务",
"folder": "基础管理-科室管理-科室跟台限额任务",
"path": "/spd/basic/dept/platform/dept/limit",
"scan_key": "deptm-follow-limit",
"filters": "以截图为准",
"status_tabs": "以截图状态 Tab 为准(若有)",
"toolbar": "以截图工具栏为准",
"list_note": "跟台限额任务列表与行内操作以截图为准",
},
{
"id": "DEPTM-006",
"name": "请领模板维护",
"folder": "基础管理-科室管理-请领模板维护",
"path": "/spd/basic/dept/platform/dept/apply",
"scan_key": "deptm-req-template",
"filters": "以截图为准",
"status_tabs": "以截图为准(若有)",
"toolbar": "以截图工具栏为准",
"list_note": "请领模板列表、明细与维护操作以截图为准",
},
{
"id": "DEPTM-007",
"name": "自动请领排程",
"folder": "基础管理-科室管理-自动请领排程",
"path": "/spd/basic/dept/platform/dept/schedule",
"scan_key": "deptm-auto-req",
"filters": "以截图为准",
"status_tabs": "以截图为准(若有)",
"toolbar": "以截图工具栏为准",
"list_note": "自动请领排程配置与任务列表以截图为准",
},
{
"id": "DEPTM-008",
"name": "科室人员查询",
"folder": "基础管理-科室管理-科室人员查询",
"path": "/spd/basic/dept/platform/dept/person",
"scan_key": "deptm-staff",
"filters": "以截图为准",
"status_tabs": "以截图为准(若有)",
"toolbar": "以截图工具栏为准",
"list_note": "科室人员列表与查询条件以截图为准",
},
{
"id": "DEPTM-009",
"name": "科室上下限建议",
"folder": "基础管理-科室管理-科室上下限建议",
"path": "/spd/basic/dept/platform/dept/deptLimitOld",
"scan_key": "deptm-minmax",
"filters": "以截图为准",
"status_tabs": "以截图为准(若有)",
"toolbar": "以截图工具栏为准",
"list_note": "上下限建议列表与采纳/维护操作以截图为准",
},
{
"id": "DEPTM-010",
"name": "科室预算管理",
"folder": "基础管理-科室管理-科室预算管理",
"path": "/spd/basic/dept/platform/dept/budget",
"scan_key": "deptm-budget",
"filters": "以截图为准",
"status_tabs": "以截图状态 Tab 为准(若有)",
"toolbar": "以截图工具栏为准",
"list_note": "科室预算列表与编辑/审批相关操作以截图为准",
},
{
"id": "DEPTM-011",
"name": "检验机器管理",
"folder": "基础管理-科室管理-检验机器管理",
"path": "/spd/basic/dept/platform/dept/machine",
"scan_key": "deptm-lab-machine",
"filters": "以截图为准",
"status_tabs": "以截图为准(若有)",
"toolbar": "以截图工具栏为准",
"list_note": "检验机器登记列表与绑定操作以截图为准",
},
{
"id": "DEPTM-012",
"name": "科室送货计划",
"folder": "基础管理-科室管理-科室送货计划",
"path": "/spd/basic/dept/platform/dept/deptPlan",
"scan_key": "deptm-delivery",
"filters": "以截图为准",
"status_tabs": "以截图为准(若有)",
"toolbar": "以截图工具栏为准",
"list_note": "科室送货计划列表与维护操作以截图为准",
},
]
# 基础设置 → 接口管理(docs/基础管理-接口管理-*)
IFACE_PAGES: list[dict] = [
{
"id": "IFACE-001",
"name": "HIS价对照查询",
"folder": "基础管理-接口管理-HIS价对照查询",
"path": "/spd/basic/interface/interface/priceCompare",
"scan_key": "iface-his-price",
},
{
"id": "IFACE-002",
"name": "代为科室请领(已确认)",
"folder": "基础管理-接口管理-代为科室请领(已确认)",
"path": "/spd/basic/interface/replace-dept-receive",
"scan_key": "iface-dept-req-ok",
},
{
"id": "IFACE-003",
"name": "代二级科室请领产品",
"folder": "基础管理-接口管理-代二级科室请领产品",
"path": "/spd/basic/interface/deptBusiness/checkBuy",
"scan_key": "iface-subdept-req",
},
{
"id": "IFACE-004",
"name": "低值计费接口查询",
"folder": "基础管理-接口管理-低值计费接口查询",
"path": "/spd/basic/interface/interface/lowBillingSearch",
"scan_key": "iface-low-billing",
},
{
"id": "IFACE-005",
"name": "医保信息核对",
"folder": "基础管理-接口管理-医保信息核对",
"path": "/spd/basic/interface/scjs/yc/ybGoodsCheck",
"scan_key": "iface-insurance",
"iframe_note": "内嵌 scjs `#/yc/ybGoodsCheck` iframe",
},
{
"id": "IFACE-006",
"name": "厂商对码",
"folder": "基础管理-接口管理-厂商对码",
"path": "/spd/basic/interface/interface/companyCode",
"scan_key": "iface-vendor-code",
},
{
"id": "IFACE-007",
"name": "外围产品变更",
"folder": "基础管理-接口管理-外围产品变更",
"path": "/spd/basic/interface/interface/peripheralProductChanges",
"scan_key": "iface-peripheral",
},
{
"id": "IFACE-008",
"name": "外部变更明细信息查询",
"folder": "基础管理-接口管理-外部变更明细信息查询",
"path": "/spd/basic/interface/interface/peripheralChangeDetail",
"scan_key": "iface-ext-detail",
},
{
"id": "IFACE-009",
"name": "平台产品价格差异查询",
"folder": "基础管理-接口管理-平台产品价格差异查询",
"path": "/spd/basic/interface/interface/platformPriceCompare",
"scan_key": "iface-platform-diff",
},
{
"id": "IFACE-010",
"name": "接口日志",
"folder": "基础管理-接口管理-接口日志",
"path": "/spd/basic/interface/interface/log",
"scan_key": "iface-api-log",
},
{
"id": "IFACE-011",
"name": "推送物价",
"folder": "基础管理-接口管理-推送物价",
"path": "/spd/basic/interface/scjs/yc/priceverification",
"scan_key": "iface-push-price",
"iframe_note": "内嵌 scjs `#/yc/priceverification` iframe",
},
{
"id": "IFACE-012",
"name": "收费项目对码",
"folder": "基础管理-接口管理-收费项目对码",
"path": "/spd/basic/interface/interface/hisCode",
"scan_key": "iface-charge-code",
},
{
"id": "IFACE-013",
"name": "智能柜与库区对码",
"folder": "基础管理-接口管理-智能柜与库区对码",
"path": "/spd/basic/interface/interface/cabinet-area",
"scan_key": "iface-cabinet-zone",
},
{
"id": "IFACE-014",
"name": "物资字典推送记录",
"folder": "基础管理-接口管理-物资字典推送记录",
"path": "/spd/basic/interface/scjs/yc/pushGoods",
"scan_key": "iface-mat-push",
"iframe_note": "内嵌 scjs `#/yc/pushGoods` iframe",
},
{
"id": "IFACE-015",
"name": "请领单接口查询",
"folder": "基础管理-接口管理-请领单接口查询",
"path": "/spd/basic/interface/scjs/yc/deptBuy",
"scan_key": "iface-req-query",
"iframe_note": "内嵌 scjs `#/yc/deptBuy` iframe",
},
{
"id": "IFACE-016",
"name": "院区对码",
"folder": "基础管理-接口管理-院区对码",
"path": "/spd/basic/interface/interface/branchMapping",
"scan_key": "iface-campus-code",
},
]
PAGES: list[dict] = (
STAT_PAGES
+ ADMIT_PAGES
+ VENDOR_PAGES
+ USER_PAGES
+ WH_PAGES
+ FLOW_PAGES
+ DEPTM_PAGES
+ IFACE_PAGES
)
def png_files(folder: Path) -> list[Path]:
return sorted(p for p in folder.iterdir() if p.suffix.lower() == ".png")
def render_deptm_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.get("scan_key", "")
page_url = (
f"`http://spdtest.cmic.com.cn:8080{page['path']}`"
if page.get("path")
else f"待元素扫描补充(菜单名:`{page['name']}`)"
)
scan_row = (
f"| 元素扫描 JSON | `data/scan/{scan_key}-elements.json` |\n| 定位器 YAML | `locators/spd/{scan_key}.locator.yaml` |\n"
if scan_key
else ""
)
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_url} |
| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
{scan_row}| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |
## 主界面
![{page['name']}主界面]({kb_dir}/page-main.png)
### 筛选区
- 字段:{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. 左右分栏页(如科室产品分配)先选中左侧科室树节点,再断言右侧列表范围。
## 测试关注点
1. 筛选 + 高级筛选组合后,列表条数与底部分页「共 N 条」一致。
2. 院区/科室树切换后,右侧列表或表单数据范围与所选节点一致,不串用上一节点数据。
3. 导入/批量修改/配置类写操作与当前勾选行、筛选条件一致;失败时有明确提示。
"""
def render_iface_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.get("scan_key", "")
page_url = (
f"`http://spdtest.cmic.com.cn:8080{page['path']}`"
if page.get("path")
else f"待元素扫描补充(菜单名:`{page['name']}`)"
)
scan_row = (
f"| 元素扫描 JSON | `data/scan/{scan_key}-elements.json` |\n| 定位器 YAML | `locators/spd/{scan_key}.locator.yaml` |\n"
if scan_key
else ""
)
filters = page.get("filters", "以截图为准")
status_tabs = page.get("status_tabs", "以截图为准(若有)")
toolbar = page.get("toolbar", "以截图工具栏为准")
list_note = page.get("list_note", "主表列、对码/推送/日志状态与行内操作以截图为准")
iframe_cell = page.get("iframe_note") or "无(整页渲染,非统计报表 iframe)"
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_url} |
| iframe 选择器 | {iframe_cell} |
{scan_row}| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |
## 主界面
![{page['name']}主界面]({kb_dir}/page-main.png)
### 筛选区
- 字段:{filters}
- 操作:**查询**、**重置**;部分页面含 **收起/展开**、**高级筛选**
### 状态 Tab(若有)
- {status_tabs}
### 列表与工具栏
- {toolbar}
- {list_note}
- 底部分页:「共 N 条」、页码与每页条数切换
## 自动化建议
1. 元素扫描 JSON 与定位器 YAML 已生成;下一步补 Page Object(键名 `{scan_key or '待扫描'}`)。
2. 菜单导航:`page.getByRole('menuitem', {{ name: '{page['name']}', exact: true }}).click()`(注意与同组「对码/查询/日志」类菜单区分)。
3. 涉及推送、对码、重试类操作:断言接口状态/结果列与筛选条件一致,并校验失败时有明确提示或日志可追溯。
## 测试关注点
1. 筛选 + 高级筛选组合后,列表条数与底部分页「共 N 条」一致。
2. 对码/推送/核对类页面:关键编码(HIS、收费项目、院区、厂商等)与对照结果列一致,不串行。
3. 日志/查询类页面:时间区间、单号、状态筛选与列表字段一致;详情/重试(若有)与当前行数据匹配。
"""
def render_admit_spec(page: dict, spec_id: str) -> str:
title = f"准入管理-{page['name']}页面说明"
menu = f"**基础设置** → **准入管理** → **{page['name']}**"
......@@ -1099,6 +1548,10 @@ author: ""
def render_spec(page: dict, spec_id: str) -> str:
if spec_id.startswith("IFACE-"):
return render_iface_spec(page, spec_id)
if spec_id.startswith("DEPTM-"):
return render_deptm_spec(page, spec_id)
if spec_id.startswith("ADMIT-"):
return render_admit_spec(page, spec_id)
if spec_id.startswith("VENDOR-"):
......
用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果
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. 点击「查 询」并等待列表稳定
ADMIT-PLS-001,基础设置 · 准入管理,产品准入单列表 · 筛选与状态,默认查询列表有数据或空态明确,高,测试环境可访问;已通过 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}`
ADMIT-PLS-002,基础设置 · 准入管理,产品准入单列表 · 筛选与状态,按物料名称筛选,高,测试环境可访问;已通过 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. 点击「重 置」
ADMIT-PLS-003,基础设置 · 准入管理,产品准入单列表 · 筛选与状态,重置恢复默认,中,测试环境可访问;已通过 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`
ADMIT-PLS-004,基础设置 · 准入管理,产品准入单列表 · 筛选与状态,无匹配结果空态(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. 断言表头含准入单号、进院供应商、二级供应商、物料名称、注册证号、耗材类型、产品条数、状态、备注、附件、操作
ADMIT-PLS-005,基础设置 · 准入管理,产品准入单列表 · 筛选与状态,列表核心列与附件计数,高,测试环境可访问;已通过 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. 点击「已通过」
ADMIT-PLS-006,基础设置 · 准入管理,产品准入单列表 · 筛选与状态,切换已通过 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
ADMIT-PLS-007,基础设置 · 准入管理,产品准入单列表 · 筛选与状态,与物料准入单列表不串 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}`
ADMIT-PLS-008,基础设置 · 准入管理,产品准入单列表 · 单据详情,打开详情后返回,高,测试环境可访问;已通过 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. 读取详情明细分页总数或当前行数
ADMIT-PLS-009,基础设置 · 准入管理,产品准入单列表 · 单据详情,详情产品条数与列表一致,高,测试环境可访问;已通过 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. 点击「单据日志」
ADMIT-PLS-010,基础设置 · 准入管理,产品准入单列表 · 单据详情,单据日志可打开,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已打开单据详情,"1. 点击「单据日志」
2. 断言日志层可见
3. 关闭后仍在该单据",单据日志可查看;详情不被销毁。,
ADMIT-PLS-012,基础设置 · 准入管理,产品准入单列表 · 单据详情,已通过单据不确认通过,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已通过 Tab 至少 1 条并打开详情,"1. 「通过」禁用或不可见时,断言状态保持已通过
ADMIT-PLS-011,基础设置 · 准入管理,产品准入单列表 · 单据详情,已通过单据不确认通过,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);已通过 Tab 至少 1 条并打开详情,"1. 「通过」禁用或不可见时,断言状态保持已通过
2. 若可点且出现确认框,点「取 消」
3. 返回列表,断言状态仍为已通过",不把已通过单据再次审批成功。无已通过数据则 skip。,
ADMIT-PLS-013,基础设置 · 准入管理,产品准入单列表 · 筛选与状态,多条件组合查询(制单日期等6项),高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开,"1. 进入页面并等待列表加载
ADMIT-PLS-012,基础设置 · 准入管理,产品准入单列表 · 筛选与状态,多条件组合查询(制单日期等6项),高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开,"1. 进入页面并等待列表加载
2. 「制单日期」日期区间选择当天
3. 「销售商」填写关键字
4. 「耗材类型」下拉选择任一可选项
......@@ -42,3 +39,11 @@ ADMIT-PLS-013,基础设置 · 准入管理,产品准入单列表 · 筛选与状
7. 「供应商」填写关键字
8. 点击「查询」
9. 检查列表返回结果",多条件同时生效:制单日期、销售商、耗材类型、器械注册人、注册证号、供应商 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系,待执行
ADMIT-PLS-013,基础设置 · 准入管理,产品准入单列表 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 准入管理」权限;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip","1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 点击「查 询」,记录分页「共 N 条」与当前页可见行数
3. 分页区切换每页条数为「20 条/页」
4. 断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;筛选区、表头可见无报错",切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。,
ADMIT-PLS-014,基础设置 · 准入管理,产品准入单列表 · 列表,列设置隐藏后再恢复,低,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入产品准入单列表页(/spd/basic/dataSync/dataSync/productOrder/list,无 iframe);列表表头已加载;存在可隐藏的非核心列,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 点击「查 询」并等待列表稳定
3. 打开列设置,取消勾选一列非核心字段并关闭,断言表头隐藏
4. 再次打开列设置勾选恢复并关闭,断言表头恢复",勾选状态与表头一致。,
用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果
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,基础设置 · 准入管理,产品准入申请 · 状态与列表,列表核心列完整,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据,1. 断言表头含准入单号、产品编号、物料名称、规格型号、注册规格、注册型号、器械注册人、单位、操作,核心列可见;操作含「详情」「日志」。,
ADMIT-PAP-007,基础设置 · 准入管理,产品准入申请 · 状态与列表,切换待申请 Tab,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe),"1. 点击「待申请」
2. 断言有数据时行不属于已生效/已作废;计数为 0 时为空态",待申请 Tab 与列表状态一致。,
ADMIT-PAP-008,基础设置 · 准入管理,产品准入申请 · 状态与列表,翻至第 2 页,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);分页总数大于每页条数,"1. 记录第 1 页首行准入单号
2. 点击页码「2」或「下一页」
3. 断言页码高亮为 2,首行单号与第 1 页不同",翻页成功且数据更换。不足两页则 skip。,
ADMIT-PAP-009,基础设置 · 准入管理,产品准入申请 · 状态与列表,跳至指定页,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);总页数不少于 2,分页存在「跳至」,"1. 在跳至输入框填入 `2` 并回车
2. 断言当前页为 2",跳转到第 2 页。无跳至或仅 1 页则 skip。,
ADMIT-PAP-010,基础设置 · 准入管理,产品准入申请 · 新增与导入,打开新增产品后返回,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe),"1. 点击「新增产品」
2. 断言可见物料名称、器械注册人、规格型号、注册证号及返回
3. 点击返回,不保存",新增页字段可见;返回后列表不新增记录。,
ADMIT-PAP-011,基础设置 · 准入管理,产品准入申请 · 新增与导入,新增取消不落库,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe),"1. 记录分页总数 `${sampleTotal}`
2. 打开新增,物料名称填 `${uniqProductName}`(建议 `AUTO-PAP-${timestamp}`)后返回不保存
3. 按该名称查询,断言无此行,总数不变",取消后不产生新产品。,
ADMIT-PAP-012,基础设置 · 准入管理,产品准入申请 · 详情、编辑与作废,打开详情后返回,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据,"1. 记录首行 `${sampleProductName}`
2. 点击「详情」,断言名称一致
3. 返回列表",详情与该行一致;返回后仍在产品准入申请列表。,
ADMIT-PAP-013,基础设置 · 准入管理,产品准入申请 · 详情、编辑与作废,待申请编辑回填,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);待申请至少 1 行含「编辑」,"1. 读取该行 `${sampleProductName}`、`${sampleSpec}`
2. 点击「编辑」
3. 断言名称与规格型号回填一致
4. 返回不保存",编辑回填与列表行一致。无编辑按钮则 skip。,
ADMIT-PAP-014,基础设置 · 准入管理,产品准入申请 · 详情、编辑与作废,编辑取消不保存,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);存在可编辑行,"1. 打开编辑,把物料名称改为 `${uniqProductName}` 后返回不保存
2. 再次打开,断言名称仍为原值",取消后数据不变。,
ADMIT-PAP-015,基础设置 · 准入管理,产品准入申请 · 详情、编辑与作废,作废确认后取消,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);待申请至少 1 条可勾选,"1. 勾选 1 行并点击「作 废」
2. 断言确认框出现
3. 点击「取 消」
4. 断言该行仍在,状态未变为已作废",取消作废不改变状态。本条不点击确定。,
ADMIT-PAP-016,基础设置 · 准入管理,产品准入申请 · 详情、编辑与作废,已通过行不提供编辑,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);已通过 Tab 计数大于 0,"1. 切换「已通过」
2. 断言首行无「编辑」,或打开后保存被拒绝",已通过行不能改写物料名称。计数为 0 则 skip。,
ADMIT-PAP-001,基础设置 · 准入管理,产品准入申请 · 快捷筛选查询,默认查询列表有数据或空态明确,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe),1. 点击「查 询」并等待列表稳定2. 断言出现数据行,或「暂无数据」且分页总数为 0,查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。,
ADMIT-PAP-002,基础设置 · 准入管理,产品准入申请 · 快捷筛选查询,新增日期与销售商(自定义)等七项组合查询,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条且存在含「新增日期」与物料名称/耗材类型的首行样本,否则 skip,1. 默认查询后从首行读取 `${sampleCreateDate}`、`${sampleCustomSeller}`、`${sampleConsumableType}`、`${sampleProductName}`、`${sampleSpec}`、`${sampleCertNo}`、`${sampleRegistrant}`2. 点击「重 置」后展开筛选区3. 「新增日期」区间设为 `${sampleCreateDate}` 单日4. 「销售商(自定义)」填入 `${sampleCustomSeller}`(列表列为默认进院供应商/销售商(自定义)时同源取值)5. 「耗材类型」选择 `${sampleConsumableType}`6. 「物料名称」填入 `${sampleProductName}` 关键字7. 「规格型号」「注册证号」「器械注册人」分别填入对应样本(为空则跳过该项)8. 点击「查 询」9. 断言结果行同时满足已填各条件,组合查询无报错;结果与新增日期、销售商(自定义)、耗材类型、物料名称及已填扩展条件一致。,
ADMIT-PAP-003,基础设置 · 准入管理,产品准入申请 · 快捷筛选查询,重置恢复默认,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe),1. 填入 `${sampleProductName}`2. 点击「重 置」3. 断言筛选恢复默认,筛选字段恢复默认;列表按默认规则刷新。,
ADMIT-PAP-004,基础设置 · 准入管理,产品准入申请 · 状态与列表,列表核心列完整,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据,1. 断言表头含准入单号、物料编号、物料名称、规格型号、注册规格、注册型号、器械注册人、单位、操作,核心列可见;操作含「详情」「日志」。,
ADMIT-PAP-005,基础设置 · 准入管理,产品准入申请 · 状态与列表,切换待申请 Tab,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe),1. 点击「待申请」2. 断言有数据时行不属于已生效/已作废;计数为 0 时为空态,待申请 Tab 与列表状态一致。,
ADMIT-PAP-006,基础设置 · 准入管理,产品准入申请 · 状态与列表,翻至第 2 页,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);分页总数大于每页条数,1. 记录第 1 页首行准入单号2. 点击页码「2」或「下一页」3. 断言页码高亮为 2,首行单号与第 1 页不同,翻页成功且数据更换。不足两页则 skip。,
ADMIT-PAP-007,基础设置 · 准入管理,产品准入申请 · 状态与列表,跳至指定页,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);总页数不少于 2,分页存在「跳至」,1. 在跳至输入框填入 `2` 并回车2. 断言当前页为 2,跳转到第 2 页。无跳至或仅 1 页则 skip。,
ADMIT-PAP-008,基础设置 · 准入管理,产品准入申请 · 新增与导入,打开新增产品后返回,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe),1. 点击「新增产品」2. 断言可见物料名称、器械注册人、规格型号、注册证号及返回3. 点击返回,不保存,新增页字段可见;返回后列表不新增记录。,
ADMIT-PAP-009,基础设置 · 准入管理,产品准入申请 · 新增与导入,新增取消不落库,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe),1. 记录分页总数 `${sampleTotal}`2. 打开新增,物料名称填 `${uniqProductName}`(建议 `AUTO-PAP-${timestamp}`)后返回不保存3. 按该名称查询,断言无此行,总数不变,取消后不产生新产品。,
ADMIT-PAP-010,基础设置 · 准入管理,产品准入申请 · 详情、编辑与作废,打开详情后返回,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询至少 1 条数据,1. 记录首行 `${sampleProductName}`2. 点击「详情」,断言名称一致3. 返回列表,详情与该行一致;返回后仍在产品准入申请列表。,
ADMIT-PAP-011,基础设置 · 准入管理,产品准入申请 · 详情、编辑与作废,待申请编辑回填,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);待申请至少 1 行含「编辑」,1. 读取该行 `${sampleProductName}`、`${sampleSpec}`2. 点击「编辑」3. 断言名称与规格型号回填一致4. 返回不保存,编辑回填与列表行一致。无编辑按钮则 skip。,
ADMIT-PAP-012,基础设置 · 准入管理,产品准入申请 · 详情、编辑与作废,编辑取消不保存,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);存在可编辑行,1. 打开编辑,把物料名称改为 `${uniqProductName}` 后返回不保存2. 再次打开,断言名称仍为原值,取消后数据不变。,
ADMIT-PAP-013,基础设置 · 准入管理,产品准入申请 · 详情、编辑与作废,作废确认后取消,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);待申请至少 1 条可勾选,1. 勾选 1 行并点击「作 废」2. 断言确认框出现3. 点击「取 消」4. 断言该行仍在,状态未变为已作废,取消作废不改变状态。本条不点击确定。,
ADMIT-PAP-014,基础设置 · 准入管理,产品准入申请 · 详情、编辑与作废,已通过行不提供编辑,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);已通过 Tab 计数大于 0,1. 切换「已通过」2. 断言首行无「编辑」,或打开后保存被拒绝,已通过行不能改写物料名称。计数为 0 则 skip。,
ADMIT-PAP-015,基础设置 · 准入管理,产品准入申请 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 准入管理」权限;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip","1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录2. 点击「查 询」,记录分页「共 N 条」与当前页可见行数3. 分页区切换每页条数为「20 条/页」4. 断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;筛选区、表头可见无报错",切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。,
ADMIT-PAP-016,基础设置 · 准入管理,产品准入申请 · 列表,物料编号列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录2. 默认查询后列表有数据3. 横向滚动使「物料编号」列表头可见(宽表时)4. 第一次点击该列排序控件并校验升序指示器5. 第二次点击并校验降序指示器,排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-PAP-017,基础设置 · 准入管理,产品准入申请 · 列表,物料名称列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录2. 默认查询后列表有数据3. 横向滚动使「物料名称」列表头可见(宽表时)4. 第一次点击该列排序控件并校验升序指示器5. 第二次点击并校验降序指示器,排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-PAP-018,基础设置 · 准入管理,产品准入申请 · 列表,规格型号列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录2. 默认查询后列表有数据3. 横向滚动使「规格型号」列表头可见(宽表时)4. 第一次点击该列排序控件并校验升序指示器5. 第二次点击并校验降序指示器,排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-PAP-019,基础设置 · 准入管理,产品准入申请 · 列表,器械注册人列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录2. 默认查询后列表有数据3. 横向滚动使「器械注册人」列表头可见(宽表时)4. 第一次点击该列排序控件并校验升序指示器5. 第二次点击并校验降序指示器,排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-PAP-020,基础设置 · 准入管理,产品准入申请 · 列表,单位列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录2. 默认查询后列表有数据3. 横向滚动使「单位」列表头可见(宽表时)4. 第一次点击该列排序控件并校验升序指示器5. 第二次点击并校验降序指示器,排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-PAP-021,基础设置 · 准入管理,产品准入申请 · 列表,耗材类型列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录2. 默认查询后列表有数据3. 横向滚动使「耗材类型」列表头可见(宽表时)4. 第一次点击该列排序控件并校验升序指示器5. 第二次点击并校验降序指示器,排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-PAP-022,基础设置 · 准入管理,产品准入申请 · 列表,列设置隐藏后再恢复,低,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入产品准入申请页(/spd/basic/dataSync/dataSync/productApply/list,无 iframe);列表表头已加载;存在可隐藏的非核心列,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 点击「查 询」并等待列表稳定
3. 打开列设置,取消勾选一列非核心字段并关闭,断言表头隐藏
4. 再次打开列设置勾选恢复并关闭,断言表头恢复",勾选状态与表头一致。,
用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果
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. 点击「查 询」并等待列表稳定
ADMIT-SUP-001,基础设置 · 准入管理,供货目录申请 · 筛选与状态,默认查询列表有数据或空态明确,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),"1. 点击「查 询」并等待列表稳定
2. 断言出现数据行,或「暂无数据」且分页总数为 0",查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。,
ADMIT-SUP-003,基础设置 · 准入管理,供货目录申请 · 筛选与状态,变更单号无匹配空态(mock反例),中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),"1. 变更单号填入 `__NOT_EXIST_ORDER__99999`;若筛选区无此框,则在物料名称填同一 mock
ADMIT-SUP-002,基础设置 · 准入管理,供货目录申请 · 筛选与状态,变更单号无匹配空态(mock反例),中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),"1. 变更单号填入 `__NOT_EXIST_ORDER__99999`;若筛选区无此框,则在物料名称填同一 mock
2. 点击「查 询」
3. 断言空态且分页总数为 0",无匹配时不报错;总数为 0。,
ADMIT-SUP-004,基础设置 · 准入管理,供货目录申请 · 筛选与状态,列表核心列完整,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询至少 1 条数据,1. 断言表头含申请单号、申请人、申请时间、进院供应商、二级供应商、业务模式、是否默认、备注、操作,核心列可见;操作含「编辑」或「详情」(随状态变化)。,
ADMIT-SUP-005,基础设置 · 准入管理,供货目录申请 · 筛选与状态,切换待提交 Tab 且计数一致,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),"1. 点击「待提交」
ADMIT-SUP-003,基础设置 · 准入管理,供货目录申请 · 筛选与状态,列表核心列完整,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询至少 1 条数据,1. 断言表头含申请单号、申请人、申请时间、进院供应商、二级供应商、业务模式、是否默认、备注、操作,核心列可见;操作含「编辑」或「详情」(随状态变化)。,
ADMIT-SUP-004,基础设置 · 准入管理,供货目录申请 · 筛选与状态,切换待提交 Tab 且计数一致,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),"1. 点击「待提交」
2. 读取括号数量
3. 断言分页「共 N 条」等于该数量;有数据时状态为待提交",待提交 Tab 与分页总数一致。计数为 0 时为空态。,
ADMIT-SUP-006,基础设置 · 准入管理,供货目录申请 · 新增与编辑,新增放弃不落库,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),"1. 记录总数 `${sampleTotal}`
ADMIT-SUP-005,基础设置 · 准入管理,供货目录申请 · 新增与编辑,新增放弃不落库,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),"1. 记录总数 `${sampleTotal}`
2. 打开新增,备注填 `${uniqRemark}`(建议 `AUTO-SUP-${timestamp}`)后返回不提交
3. 断言总数不变,列表无该备注",未提交不产生申请单。,
ADMIT-SUP-007,基础设置 · 准入管理,供货目录申请 · 新增与编辑,业务模式与是否默认只切换不提交,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);已打开新增页,"1. 展开业务模式、是否默认,断言存在可选项
2. 各选一项后返回,不提交
3. 断言列表总数不变",选项可见;不提交则无新单。,
ADMIT-SUP-008,基础设置 · 准入管理,供货目录申请 · 新增与编辑,待提交编辑回填,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);待提交至少 1 行含「编辑」,"1. 读取该行 `${sampleHospitalSupplier}`、`${sampleBizMode}`、`${sampleDefaultFlag}`
ADMIT-SUP-006,基础设置 · 准入管理,供货目录申请 · 新增与编辑,待提交编辑回填,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);待提交至少 1 行含「编辑」,"1. 读取该行 `${sampleHospitalSupplier}`、`${sampleBizMode}`、`${sampleDefaultFlag}`
2. 点击「编辑」
3. 断言进院供应商、业务模式、是否默认与该行一致
4. 返回不提交",编辑回填与列表行一致。无待提交或无编辑则 skip。,
ADMIT-SUP-009,基础设置 · 准入管理,供货目录申请 · 新增与编辑,编辑取消不保存,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);存在可编辑的待提交行,"1. 打开编辑,把备注改为 `${uniqRemark}` 后返回不提交
ADMIT-SUP-007,基础设置 · 准入管理,供货目录申请 · 新增与编辑,编辑取消不保存,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);存在可编辑的待提交行,"1. 打开编辑,把备注改为 `${uniqRemark}` 后返回不提交
2. 再次打开,断言备注仍为原值",取消后备注不变。,
ADMIT-SUP-010,基础设置 · 准入管理,供货目录申请 · 批量提交与详情,未勾选时批量提交不可用,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),无勾选行,"1. 断言「批量提交」计数为 0,或按钮不可用
ADMIT-SUP-008,基础设置 · 准入管理,供货目录申请 · 批量提交与详情,未勾选时批量提交不可用,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),无勾选行,"1. 断言「批量提交」计数为 0,或按钮不可用
2. 若仍可点击,断言出现请选择提示,且无单据离开待提交",未勾选不提交任何申请单。,
ADMIT-SUP-011,基础设置 · 准入管理,供货目录申请 · 批量提交与详情,勾选后批量提交取消,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);待提交至少 1 条,"1. 勾选 1 行,记录申请单号与状态
ADMIT-SUP-009,基础设置 · 准入管理,供货目录申请 · 批量提交与详情,勾选后批量提交取消,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);待提交至少 1 条,"1. 勾选 1 行,记录申请单号与状态
2. 点击「批量提交」
3. 若出现确认框,点「取 消」
4. 断言该单仍为待提交",取消批量提交不改变状态。本条不点击确定。无待提交则 skip。,
ADMIT-SUP-012,基础设置 · 准入管理,供货目录申请 · 筛选与状态,多条件组合查询(制单日期等6项),高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开,"1. 进入页面并等待列表加载
ADMIT-SUP-010,基础设置 · 准入管理,供货目录申请 · 筛选与状态,多条件组合查询(制单日期等6项),高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开,"1. 进入页面并等待列表加载
2. 「制单日期」日期区间选择当天
3. 「销售商」下拉选择任一可选项
4. 「供应商」下拉选择任一可选项
......@@ -38,3 +32,42 @@ ADMIT-SUP-012,基础设置 · 准入管理,供货目录申请 · 筛选与状态
7. 「规格型号」填写关键字
8. 点击「查询」
9. 检查列表返回结果",多条件同时生效:制单日期、销售商、供应商、变更单号、物料名称、规格型号 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系,待执行
ADMIT-SUP-011,基础设置 · 准入管理,供货目录申请 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 准入管理」权限;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip","1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 点击「查 询」,记录分页「共 N 条」与当前页可见行数
3. 分页区切换每页条数为「20 条/页」
4. 断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;筛选区、表头可见无报错",切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。,
ADMIT-SUP-012,基础设置 · 准入管理,供货目录申请 · 筛选与状态,重置恢复默认,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),"1. 点击「查 询」
2. 在变更单号或物料名称填入关键字(若无文本框则选中进院供应商)
3. 点击「重 置」
4. 断言筛选恢复默认;列表刷新无报错",筛选恢复默认;列表按默认规则刷新。,
ADMIT-SUP-013,基础设置 · 准入管理,供货目录申请 · 批量提交与详情,详情页供应商与列表一致,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);默认查询至少 1 条含「详情」,"1. 从首行读取 `${sampleHospitalSupplier}`、`${sampleSecondarySupplier}`
2. 点击「详情」
3. 断言详情页进院供应商、二级供应商与列表该行一致
4. 返回列表",详情页两级供应商与列表行一致。无详情则 skip。,
ADMIT-SUP-014,基础设置 · 准入管理,供货目录申请 · 列表,列设置隐藏后再恢复,低,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);列表表头已加载;存在可隐藏的非核心列,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 点击「查 询」并等待列表稳定
3. 打开列设置,取消勾选一列非核心字段并关闭,断言表头隐藏
4. 再次打开列设置勾选恢复并关闭,断言表头恢复",勾选状态与表头一致。,
ADMIT-SUP-015,基础设置 · 准入管理,供货目录申请 · 批量提交与详情,仅待提交 Tab 行复选框可勾选,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe);待提交 Tab 至少 1 条;且「已通过」「审批中」「已驳回」或「全部」中至少一 Tab 有数据,"1. 点击「查 询」
2. 在首个有数据的非待提交 Tab,抽样断言行前复选框不可勾选
3. 切换「待提交」,勾选首行复选框并断言勾选成功
4. 取消勾选",仅待提交列表行前复选框可勾选;其它 Tab 不可勾选。缺对比数据或非待提交无数据则 skip。,
ADMIT-SUP-016,基础设置 · 准入管理,供货目录申请 · 明细查询切换,可正常切换至供货目录申请明细查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;已进入供货目录申请页(/spd/basic/dataSync/dataSync/supplyApply/list,无 iframe),"1. 打开页面并点击「查 询」等待列表稳定
2. 点击顶部 Tab「供货目录申请明细查询」
3. 断言 URL 仍含 supplyApply/list、该 Tab 高亮、明细筛选区(物料名称/生产厂家/计划生效日期/申请单号)可见
4. 断言表头含申请单号、物料名称,分页「共 N 条」与结果一致",切换后明细 Tab 高亮且数据区刷新;筛选字段与列可见;无报错。,
ADMIT-SUP-017,基础设置 · 准入管理,供货目录申请明细查询 · 快捷筛选查询,物料名称、生产厂家、计划生效日期、申请单号组合查询,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已切换至供货目录申请明细查询 Tab;列表至少 1 条,"1. 读取首行申请单号与计划生效时间,取计划生效时间当天作为日期样本
2. 「物料名称」填 管、「生产厂家」填 医疗、「计划生效日期」选该天、「申请单号」填首行单号
3. 点击「查 询」并等待列表稳定
4. 断言至少 2 个条件生效;结果行或明确空态;无报错",四项条件同时录入;查询后列表按条件刷新并呈现匹配数据或空态。,
ADMIT-SUP-018,基础设置 · 准入管理,供货目录申请明细查询 · 快捷筛选查询,重置恢复默认,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已切换至供货目录申请明细查询 Tab,"1. 在「申请单号」(或「生产厂家」)填入关键字
2. 点击「重 置」
3. 断言该输入框已清空,列表按默认规则刷新且无报错",筛选恢复默认;列表刷新无报错。,
ADMIT-SUP-019,基础设置 · 准入管理,供货目录申请明细查询 · 列表,列设置功能,低,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已切换至供货目录申请明细查询 Tab;列表表头已加载;存在可隐藏的非核心列,"1. 打开表格右侧齿轮(列设置)
2. 取消勾选一列非核心字段(申请人/业务模式/是否默认)后关闭,断言表头隐藏
3. 再次打开列设置,断言勾选状态为未选
4. 勾选恢复并关闭,断言表头恢复",勾选状态与表头一致;无报错。,
ADMIT-SUP-020,基础设置 · 准入管理,供货目录申请明细查询 · 导出,当前查询条件下导出不报错,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已切换至供货目录申请明细查询 Tab;查询结果至少 1 条,"1. 点击「查 询」等待列表稳定
2. 点击「导出」
3. 若出现二次确认则确认
4. 断言出现下载/导出任务/成功提示之一,且无错误提示、页面仍在该 Tab",导出有反馈;页面不白屏、不跳登录页。,
用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果
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. 点击「查 询」并等待列表稳定
ADMIT-TSK-001,基础设置 · 准入管理,准入任务分配 · 快捷筛选查询,默认查询列表有数据或空态明确,高,测试环境可访问;已通过 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}`
ADMIT-TSK-002,基础设置 · 准入管理,准入任务分配 · 快捷筛选查询,待分类与已分类切换,高,测试环境可访问;已通过 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. 点击「重 置」
ADMIT-TSK-003,基础设置 · 准入管理,准入任务分配 · 快捷筛选查询,重置恢复默认,中,测试环境可访问;已通过 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`
ADMIT-TSK-004,基础设置 · 准入管理,准入任务分配 · 快捷筛选查询,无匹配结果空态(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. 断言表头含准入类型、产品编号、物料名称、规格型号、规格、型号、器械注册人、操作
ADMIT-TSK-005,基础设置 · 准入管理,准入任务分配 · 列表,列表核心列完整,高,测试环境可访问;已通过 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 页首行物料名称或产品编号
ADMIT-TSK-006,基础设置 · 准入管理,准入任务分配 · 分页,翻至第 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` 并回车
ADMIT-TSK-007,基础设置 · 准入管理,准入任务分配 · 分页,跳至指定页,中,测试环境可访问;已通过 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. 点击「批量分配」
ADMIT-TSK-008,基础设置 · 准入管理,准入任务分配 · 分页,第 1 页上一页不可用,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);当前在第 1 页,1. 断言「上一页」禁用,或点击后仍停留在第 1 页,第 1 页不能再向前翻。,
ADMIT-TSK-009,基础设置 · 准入管理,准入任务分配 · 分配操作,未勾选时批量分配不可用,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类页无勾选行,"1. 点击「批量分配」
2. 断言按钮不可用,或提示先选择数据
3. 断言待分类计数不变",未勾选不分配任何行。,
ADMIT-TSK-012,基础设置 · 准入管理,准入任务分配 · 分配操作,勾选后批量分配取消,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 条,"1. 勾选 1 行,记录待分类计数
ADMIT-TSK-010,基础设置 · 准入管理,准入任务分配 · 分配操作,勾选后批量分配取消,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);待分类至少 1 条,"1. 勾选 1 行,记录待分类计数
2. 点击「批量分配」
3. 若打开分配层或确认框,点取消或关闭
4. 断言该行仍在待分类,计数不变",取消批量分配不改变分类。本条不确认分配。,
ADMIT-TSK-013,基础设置 · 准入管理,准入任务分配 · 分配操作,已分类行不再按待分类分配,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);已分类计数大于 0,"1. 切换到「已分类」
ADMIT-TSK-011,基础设置 · 准入管理,准入任务分配 · 分配操作,已分类行不再按待分类分配,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);已分类计数大于 0,"1. 切换到「已分类」
2. 断言首行无「分配类型」,或点击后被拒绝且该行仍留在已分类
3. 断言待分类计数不因此增加",已分类数据不会被这次操作退回待分类。已分类为 0 则 skip。,
ADMIT-TSK-014,基础设置 · 准入管理,准入任务分配 · 筛选与分类,多条件组合查询(新增日期等11项),高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开,"1. 进入页面并等待列表加载
ADMIT-TSK-012,基础设置 · 准入管理,准入任务分配 · 快捷筛选查询,多条件组合查询(新增日期等11项),高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开,"1. 进入页面并等待列表加载
2. 「新增日期」日期区间选择当天
3. 「操作日期」日期区间选择当天
4. 「销售商」填写关键字
......@@ -47,3 +41,57 @@ ADMIT-TSK-014,基础设置 · 准入管理,准入任务分配 · 筛选与分类
12. 「准入状态」下拉选择任一可选项
13. 点击「查询」
14. 检查列表返回结果",多条件同时生效:新增日期、操作日期、销售商、物料名称、注册证号、操作人、器械注册人、耗材类型、规格型号、准入类型、准入状态 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系,待执行
ADMIT-TSK-013,基础设置 · 准入管理,准入任务分配 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip","1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 点击「查 询」,记录分页「共 N 条」与当前页可见行数
3. 分页区切换每页条数为「20 条/页」
4. 断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;筛选区、表头可见无报错",切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。,
ADMIT-TSK-014,基础设置 · 准入管理,准入任务分配 · 列表,物料编号列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 默认查询后列表有数据
3. 横向滚动使「物料编号」列表头可见(宽表时)
4. 第一次点击该列排序控件并校验升序指示器
5. 第二次点击并校验降序指示器",排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-TSK-015,基础设置 · 准入管理,准入任务分配 · 列表,物料名称列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 默认查询后列表有数据
3. 横向滚动使「物料名称」列表头可见(宽表时)
4. 第一次点击该列排序控件并校验升序指示器
5. 第二次点击并校验降序指示器",排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-TSK-016,基础设置 · 准入管理,准入任务分配 · 列表,规格型号列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 默认查询后列表有数据
3. 横向滚动使「规格型号」列表头可见(宽表时)
4. 第一次点击该列排序控件并校验升序指示器
5. 第二次点击并校验降序指示器",排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-TSK-017,基础设置 · 准入管理,准入任务分配 · 列表,器械注册人列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 默认查询后列表有数据
3. 横向滚动使「器械注册人」列表头可见(宽表时)
4. 第一次点击该列排序控件并校验升序指示器
5. 第二次点击并校验降序指示器",排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-TSK-018,基础设置 · 准入管理,准入任务分配 · 列表,单位列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 默认查询后列表有数据
3. 横向滚动使「单位」列表头可见(宽表时)
4. 第一次点击该列排序控件并校验升序指示器
5. 第二次点击并校验降序指示器",排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-TSK-019,基础设置 · 准入管理,准入任务分配 · 列表,耗材类型列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 默认查询后列表有数据
3. 横向滚动使「耗材类型」列表头可见(宽表时)
4. 第一次点击该列排序控件并校验升序指示器
5. 第二次点击并校验降序指示器",排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-TSK-020,基础设置 · 准入管理,准入任务分配 · 列表,注册证号列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 默认查询后列表有数据
3. 横向滚动使「注册证号」列表头可见(宽表时)
4. 第一次点击该列排序控件并校验升序指示器
5. 第二次点击并校验降序指示器",排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-TSK-021,基础设置 · 准入管理,准入任务分配 · 列表,SPD新增日期列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 默认查询后列表有数据
3. 横向滚动使「SPD新增日期」列表头可见(宽表时)
4. 第一次点击该列排序控件并校验升序指示器
5. 第二次点击并校验降序指示器",排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-TSK-022,基础设置 · 准入管理,准入任务分配 · 列表,最后操作时间列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 默认查询后列表有数据
3. 横向滚动使「最后操作时间」列表头可见(宽表时)
4. 第一次点击该列排序控件并校验升序指示器
5. 第二次点击并校验降序指示器",排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
ADMIT-TSK-023,基础设置 · 准入管理,准入任务分配 · 列表,准入状态列升序降序切换,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 准入管理」权限;已进入准入任务分配页(/spd/basic/dataSync/dataSync/accessKind/list,无 iframe);列表表头已加载;至少 2 条数据,否则 skip,"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 默认查询后列表有数据
3. 横向滚动使「准入状态」列表头可见(宽表时)
4. 第一次点击该列排序控件并校验升序指示器
5. 第二次点击并校验降序指示器",排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。,
用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果
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. 点击「查 询」并等待列表稳定
ADMIT-MLS-001,基础设置 · 准入管理,物料准入单列表 · 筛选与状态,默认查询列表有数据或空态明确,高,测试环境可访问;已通过 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. 填入上一条使用的关键字
ADMIT-MLS-002,基础设置 · 准入管理,物料准入单列表 · 筛选与状态,重置恢复默认,中,测试环境可访问;已通过 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`
ADMIT-MLS-003,基础设置 · 准入管理,物料准入单列表 · 筛选与状态,无匹配结果空态(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. 点击「已通过」
ADMIT-MLS-004,基础设置 · 准入管理,物料准入单列表 · 筛选与状态,列表核心列完整,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);默认查询至少 1 条数据,1. 断言表头含准入类型、准入单号、进院供应商、二级供应商、物料名称、注册证号、耗材类型、产品条数、状态、操作,核心列可见;操作列为「详情」。,
ADMIT-MLS-005,基础设置 · 准入管理,物料准入单列表 · 筛选与状态,切换已通过 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}`
ADMIT-MLS-006,基础设置 · 准入管理,物料准入单列表 · 单据详情,打开详情后返回,高,测试环境可访问;已通过 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. 点击「审批进程」
ADMIT-MLS-007,基础设置 · 准入管理,物料准入单列表 · 单据详情,详情页准入单号和列表一致,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);默认查询至少 1 条数据,"1. 从首行读取 `${sampleOrderNo}`
2. 点击「详情」
3. 断言详情页展示的准入单号与 `${sampleOrderNo}` 一致",详情页准入单号与列表首行「准入单号」一致。,
ADMIT-MLS-008,基础设置 · 准入管理,物料准入单列表 · 单据详情,审批进程可打开,中,测试环境可访问;已通过 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. 点击「导 出」
ADMIT-MLS-009,基础设置 · 准入管理,物料准入单列表 · 单据详情,详情导出不白屏,中,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已打开单据详情,"1. 点击「导 出」
2. 断言出现下载,或出现导出任务/成功提示
3. 断言页面仍为该单据详情,不白屏",导出有反馈;详情页仍在。无导出按钮则 skip。,
ADMIT-MLS-012,基础设置 · 准入管理,物料准入单列表 · 单据详情,已通过单据不确认通过,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已通过 Tab 至少 1 条,并打开其详情,"1. 若「通过」不可见或禁用,断言该状态保持已通过后返回
ADMIT-MLS-010,基础设置 · 准入管理,物料准入单列表 · 单据详情,已通过单据不确认通过,高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);已通过 Tab 至少 1 条,并打开其详情,"1. 若「通过」不可见或禁用,断言该状态保持已通过后返回
2. 若「通过」可点,点击后出现确认框则点「取 消」
3. 返回列表,断言该单状态仍为已通过",本条不把已通过单据再次审批成功。已通过 Tab 无数据则 skip。,
ADMIT-MLS-013,基础设置 · 准入管理,物料准入单列表 · 筛选与状态,多条件组合查询(制单日期等6项),高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开,"1. 进入页面并等待列表加载
ADMIT-MLS-011,基础设置 · 准入管理,物料准入单列表 · 筛选与状态,多条件组合查询(制单日期等6项),高,测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 · 准入管理」权限;已进入准入管理对应功能页;筛选区已展开,"1. 进入页面并等待列表加载
2. 「制单日期」日期区间选择当天
3. 「销售商」填写关键字
4. 「耗材类型」下拉选择任一可选项
......@@ -43,3 +37,7 @@ ADMIT-MLS-013,基础设置 · 准入管理,物料准入单列表 · 筛选与状
7. 「供应商」填写关键字
8. 点击「查询」
9. 检查列表返回结果",多条件同时生效:制单日期、销售商、耗材类型、器械注册人、注册证号、供应商 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系,待执行
ADMIT-MLS-012,基础设置 · 准入管理,物料准入单列表 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState 登录(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 准入管理」权限;已进入物料准入单列表页(/spd/basic/dataSync/dataSync/productOrder/zrlist,无 iframe);默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip","1. 确认 chromium 已加载 storageState(auth.setup,正例账号 test_1),无需在用例内重复登录
2. 点击「查 询」,记录分页「共 N 条」与当前页可见行数
3. 分页区切换每页条数为「20 条/页」
4. 断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;筛选区、表头可见无报错",切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。,
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