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

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

新模块用例尚未具备通关凭证,本次按临时备份跳过本地门禁。
parent f6eec849
#!/usr/bin/env node
/** 临时诊断:查看角色管理页真实 DOM 结构(角色列表 / 权限树容器) */
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { existsSync } from 'node:fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
if (existsSync(path.join(ROOT, '.env'))) process.loadEnvFile(path.join(ROOT, '.env'));
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
storageState: path.join(ROOT, 'auth/user.json'),
viewport: { width: 1920, height: 1080 },
});
const page = await ctx.newPage();
await page.goto(`${BASE}/spd/basic/user/platform/role`, { waitUntil: 'domcontentloaded', timeout: 120000 });
await page.waitForTimeout(6000);
const info = await page.evaluate(() => {
const out = {};
out.title = document.title;
out.containers = [...document.querySelectorAll('[class*="role"], [class*="Role"], .ant-tree, .ant-table, .ant-list, [class*="perm"], [class*="menu"]')]
.slice(0, 60)
.map((el) => ({
tag: el.tagName,
cls: el.className && String(el.className).slice(0, 90),
children: el.children.length,
text: (el.textContent || '').trim().slice(0, 60),
}));
out.treeCount = document.querySelectorAll('.ant-tree').length;
out.treeNodeCount = document.querySelectorAll('.ant-tree-treenode').length;
out.tableRows = document.querySelectorAll('.ant-table-tbody tr').length;
out.bodyLen = document.body.innerHTML.length;
return out;
});
console.log(JSON.stringify(info, null, 1));
await browser.close();
#!/usr/bin/env node
/** 临时诊断 v2:定位角色管理页左侧角色列表与右侧权限树的真实结构 */
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { existsSync } from 'node:fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
if (existsSync(path.join(ROOT, '.env'))) process.loadEnvFile(path.join(ROOT, '.env'));
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
storageState: path.join(ROOT, 'auth/user.json'),
viewport: { width: 1920, height: 1080 },
});
const page = await ctx.newPage();
await page.goto(`${BASE}/spd/basic/user/platform/role`, { waitUntil: 'domcontentloaded', timeout: 120000 });
await page.waitForTimeout(6000);
const info = await page.evaluate(() => {
const trees = [...document.querySelectorAll('.ant-tree')].map((t, i) => {
const r = t.getBoundingClientRect();
const visible = r.width > 0 && r.height > 0;
return {
i,
visible,
rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
treenodes: t.querySelectorAll('.ant-tree-treenode').length,
firstTexts: [...t.querySelectorAll('.ant-tree-title')].slice(0, 6).map((n) => (n.textContent || '').trim().slice(0, 18)),
cls: String(t.className).slice(0, 80),
};
});
// 左侧角色区域的备选容器
const roleCandidates = [...document.querySelectorAll('.ant-table, .ant-list, .ant-tabs, [class*="role"], [class*="user"]')]
.filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && r.x < 700;
})
.slice(0, 12)
.map((el) => {
const r = el.getBoundingClientRect();
return {
tag: el.tagName,
cls: String(el.className).slice(0, 90),
rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
text: (el.textContent || '').trim().slice(0, 50),
};
});
// 左侧区域所有 ant-tree-treenode 的可见性抽样
const allNodes = [...document.querySelectorAll('.ant-tree-treenode')];
const visibleNodes = allNodes.filter((n) => {
const r = n.getBoundingClientRect();
return r.width > 0 && r.height > 0;
});
const sample = visibleNodes.slice(0, 8).map((n) => {
const r = n.getBoundingClientRect();
return {
rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
text: (n.textContent || '').trim().slice(0, 20),
checkbox: !!n.querySelector('.ant-tree-checkbox'),
ownCheckbox: !!n.querySelector('.ant-tree-checkbox:not(.ant-tree-checkbox-indeterminate)'),
};
});
return { title: document.title, trees, totalNodes: allNodes.length, visibleNodes: visibleNodes.length, sample, roleCandidates };
});
console.log(JSON.stringify(info, null, 1));
await browser.close();
#!/usr/bin/env node
/** 临时诊断 v3:确认 .ant-tree 内 checkbox 是否命中交互选择器,以及左侧角色列表结构 */
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { existsSync } from 'node:fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
if (existsSync(path.join(ROOT, '.env'))) process.loadEnvFile(path.join(ROOT, '.env'));
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
storageState: path.join(ROOT, 'auth/user.json'),
viewport: { width: 1920, height: 1080 },
});
const page = await ctx.newPage();
await page.goto(`${BASE}/spd/basic/user/platform/role`, { waitUntil: 'domcontentloaded', timeout: 120000 });
await page.waitForTimeout(6000);
const info = await page.evaluate(() => {
const INTERACTIVE =
'a[href],button,input,select,textarea,[role="button"],[role="link"],[role="tab"],' +
'[role="menuitem"],[role="checkbox"],[role="radio"],[role="combobox"],[role="switch"],' +
'[role="slider"],[contenteditable="true"],[tabindex]:not([tabindex="-1"]),' +
'.ant-btn,.ant-select,.ant-picker,.ant-input,.ant-checkbox-wrapper,.ant-radio-wrapper,' +
'.ant-pagination,.ant-pagination-item,.ant-pagination-next,.ant-pagination-prev,' +
'.ant-pagination-options-size-changer,.ant-pagination-options-quick-jumper input,' +
'.ant-table-column-sorter,.ant-dropdown-trigger,.ant-tabs-tab,.ant-drawer-close,' +
'.ant-modal-close,.advance-filter,.ant-cascader,.ant-drawer-footer .ant-btn,' +
'.ant-table-row-expand-icon,.ant-table-row-expand-icon-cell';
const hits = document.querySelectorAll(INTERACTIVE);
const inTree = [...hits].filter((el) => el.closest('.ant-tree'));
const treeClasses = [...new Set(inTree.slice(0, 10).map((el) => el.tagName + '.' + String(el.className).slice(0, 50)))];
// 左侧:x < 560 的可见可交互元素(角色列表区域)
const left = [...hits].filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && r.x < 560 && !el.closest('.ant-menu');
});
const leftSample = left.slice(0, 15).map((el) => {
const r = el.getBoundingClientRect();
return {
tag: el.tagName,
cls: String(el.className).slice(0, 60),
rect: [Math.round(r.x), Math.round(r.y)],
text: (el.textContent || '').trim().slice(0, 24),
};
});
return {
totalHits: hits.length,
inTreeHits: inTree.length,
treeClasses,
leftCount: left.length,
leftSample,
};
});
console.log(JSON.stringify(info, null, 1));
await browser.close();
import path from 'node:path';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { existsSync } from 'node:fs';
const ROOT = process.cwd();
if (existsSync(path.join(ROOT, '.env'))) process.loadEnvFile(path.join(ROOT, '.env'));
const BASE = process.env.BASE_URL;
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ storageState: path.join(ROOT,'auth/user.json'), viewport:{width:1920,height:1080} });
const page = await ctx.newPage();
await page.goto(`${BASE}/spd/basic/user/platform/role`, { waitUntil:'domcontentloaded', timeout:120000 });
await page.waitForTimeout(6000);
const info = await page.evaluate(() => {
const node = document.querySelector('.ant-tree-treenode');
const cb = document.querySelector('.ant-tree-checkbox');
const sw = document.querySelector('.ant-tree-switcher');
return {
nodeCls: node && String(node.className),
cbCls: cb && String(cb.className),
cbOuter: cb && cb.outerHTML.slice(0,200),
swCls: sw && String(sw.className),
titleTag: document.querySelector('.ant-tree-title')?.tagName,
currentRole: [...document.querySelectorAll('*')].filter(e=>/当前角色|角色名称/.test(e.textContent||'') && e.children.length===0).slice(0,3).map(e=>e.textContent.trim()),
};
});
console.log(JSON.stringify(info,null,1));
await browser.close();
import path from 'node:path';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { existsSync } from 'node:fs';
const ROOT = process.cwd();
if (existsSync(path.join(ROOT, '.env'))) process.loadEnvFile(path.join(ROOT, '.env'));
const BASE = process.env.BASE_URL;
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ storageState: path.join(ROOT,'auth/user.json'), viewport:{width:1920,height:1080} });
const page = await ctx.newPage();
await page.goto(`${BASE}/spd/basic/user/platform/role`, { waitUntil:'domcontentloaded', timeout:120000 });
await page.waitForTimeout(6000);
const info = await page.evaluate(() => {
const tree = document.querySelector('.menu-role-tree');
const all = [...tree.querySelectorAll('.ant-tree-treenode')];
const classes = all.slice(0,6).map(n=>n.className);
const sample = all.slice(0,12).map(n=>({
cls: (n.className.match(/level-\d+/)||['?'])[0],
text: (n.querySelector('.ant-tree-title')?.textContent||'').trim().slice(0,14),
}));
return { hasLevelClass: /level-/.test(all[1]?.className||''), classes, sample };
});
console.log(JSON.stringify(info,null,1));
await browser.close();
# -*- coding: utf-8 -*-
"""列出每个基础管理页面扫描产物中的筛选类字段(输入框 / select / 日期 / 下拉触发器)。"""
import glob
import json
import os
import re
import sys
SKIP = re.compile(r"^(确 认|确 定|取 消|查 询|重 置|筛 选|新增|添加|导 出|保 存|关 闭|刷 新)$")
def load(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def fields(doc):
out = []
for e in doc.get("elements", []):
tag = (e.get("tag") or "").lower()
typ = (e.get("type") or "").lower()
eid = e.get("id") or ""
label = (e.get("label") or "").strip()
ph = (e.get("placeholder") or "").strip()
if eid.startswith(("form_item_", "table_search_form_", "basic_", "search_")):
out.append(("id", eid, label or ph))
elif tag == "input" and typ not in ("hidden", "button", "checkbox", "radio"):
out.append(("input", ph or label, e.get("name") or ""))
elif tag == "select" or typ == "select":
out.append(("select", ph or label, ""))
elif "date" in (e.get("className") or "") or "日期" in (label + ph):
out.append(("date", label or ph, ""))
seen, uniq = set(), []
for it in out:
key = (it[1], it[2])
if key in seen or not (it[1] or it[2]):
continue
if SKIP.match(it[1].strip()):
continue
seen.add(key)
uniq.append(it)
return uniq
for path in sorted(glob.glob("data/scan/*-elements.json")):
if not os.path.basename(path).startswith(("basic-", "admit-", "vendor-", "surgery-")):
continue
doc = load(path)
fs = fields(doc)
print(f"\n### {os.path.basename(path)} ({len(fs)} 个)")
for kind, a, b in fs[:40]:
print(f" {kind:7s} {a:36s} {b}")
"""为已实现 filterCombination 的页面补「组合查询」用例行(CSV + spec 注释同步)。
字段名直接取自各 PO 的 filterCombination,且已在真实环境验证全部生效。
"""
from __future__ import annotations
import csv
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# (CSV 文件名片段, 用例编号前缀, PO 文件, spec 文件, fixture, 前置条件里的 path 描述)
TARGETS = [
("产品准入单列表", "ADMIT-PLS", "admit-product-list.page.ts", "admit-product-list.spec.ts", "admitProductListPage"),
("物料准入单列表", "ADMIT-MLS", "admit-material-list.page.ts", "admit-material-list.spec.ts", "admitMaterialListPage"),
("供货目录申请", "ADMIT-SUP", "admit-supply.page.ts", "admit-supply.spec.ts", "admitSupplyPage"),
("准入任务分配", "ADMIT-TSK", "admit-task.page.ts", "admit-task.spec.ts", "admitTaskPage"),
("基础包维护", "SURG-BASE", "surgery-base-pkg.page.ts", "surgery-base-pkg.spec.ts", "surgeryBasePkgPage"),
("标准套包维护", "SURG-STD", "surgery-std-pkg.page.ts", "surgery-std-pkg.spec.ts", "surgeryStdPkgPage"),
("供应商资质管理", "VENDOR-QUL", "vendor-qual.page.ts", "vendor-qual.spec.ts", "vendorQualPage"),
("授权变更初审", "VENDOR-AF1", "vendor-auth-first.page.ts", "vendor-audit.spec.ts", "vendorAuthFirstPage"),
("授权变更终审", "VENDOR-AF2", "vendor-auth-final.page.ts", "vendor-audit.spec.ts", "vendorAuthFinalPage"),
("注册证变更初审", "VENDOR-CF1", "vendor-cert-first.page.ts", "vendor-audit.spec.ts", "vendorCertFirstPage"),
("注册证变更终审", "VENDOR-CF2", "vendor-cert-final.page.ts", "vendor-audit.spec.ts", "vendorCertFinalPage"),
("注册证差异对比", "VENDOR-CDF", "vendor-cert-diff.page.ts", "vendor-cert-diff.spec.ts", "vendorCertDiffPage"),
("评价指标维护", "VENDOR-EVI", "vendor-eval-indicator.page.ts", "vendor-eval-indicator.spec.ts", "vendorEvalIndicatorPage"),
("评价方案维护", "VENDOR-EVS", "vendor-eval-scheme.page.ts", "vendor-eval-scheme.spec.ts", "vendorEvalSchemePage"),
]
def po_fields(po: str) -> list[tuple[str, str]]:
text = (ROOT / "pages/spd" / po).read_text(encoding="utf-8")
block = re.search(r"async filterCombination\(\).*?\n \}", text, re.S)
if not block:
raise SystemExit(f"{po} 缺少 filterCombination")
return re.findall(r"label: '([^']+)', kind: '(\w+)'", block.group(0))
def spec_group(spec: str, prefix: str) -> str:
"""取 spec 中已存在用例所属的 describe 标题,保证新增用例落在同一分组下。"""
text = (ROOT / "tests/spd" / spec).read_text(encoding="utf-8")
found = None
last = None
for line in text.splitlines():
m = re.match(r"\s*test\.describe\('([^']+)'", line)
if m:
found = m.group(1)
if re.search(rf"test\('{prefix}-", line) and found:
last = found
if last and "页面进入" not in last:
return last
return f"{prefix} 筛选与列表"
def build_row(prefix: str, next_no: int, module: str, fields: list[tuple[str, str]], page_desc: str) -> list[str]:
names = [f for f, _ in fields]
steps = ["1. 进入页面并等待列表加载"]
for i, (name, kind) in enumerate(fields, start=2):
if kind == "select":
steps.append(f"{i}. 「{name}」下拉选择任一可选项")
elif kind == "dateRange":
steps.append(f"{i}. 「{name}」日期区间选择当天")
else:
steps.append(f"{i}. 「{name}」填写关键字")
steps.append(f"{len(fields) + 2}. 点击「查询」")
steps.append(f"{len(fields) + 3}. 检查列表返回结果")
cond = (
f"测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「{module}」权限;"
f"已进入{page_desc};筛选区已展开"
)
expect = (
f"多条件同时生效:{('、'.join(names))} 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),"
f"列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系"
)
return [
f"{prefix}-{next_no:03d}",
module,
f"{module.split(' · ')[-1]} · 组合查询",
f"多条件组合查询({names[0]}等{len(names)}项)",
"高",
cond,
"\n".join(steps),
expect,
"待执行",
]
def append_csv(path: Path, row: list[str]) -> None:
with path.open("a", encoding="utf-8", newline="") as fh:
csv.writer(fh, quoting=csv.QUOTE_ALL, lineterminator="\n").writerow(row)
def main() -> None:
for frag, prefix, po, spec, fixture in TARGETS:
matches = list((ROOT / "testcase").glob(f"基础管理-*{frag}-UI自动化测试用例-01.csv"))
if len(matches) != 1:
raise SystemExit(f"CSV 匹配异常:{frag} -> {matches}")
csv_path = matches[0]
rows = list(csv.reader(csv_path.open(encoding="utf-8")))
if any(r and r[0].startswith(f"{prefix}-") and "组合查询" in r[2] for r in rows[1:]):
print(f"跳过(已有组合查询):{csv_path.name}")
continue
ids = [int(r[0].split("-")[-1]) for r in rows[1:] if r and r[0].startswith(prefix + "-")]
next_no = max(ids) + 1
module = rows[1][1]
fields = po_fields(po)
page_desc = f"{module.split(' · ')[-1]}页"
append_csv(csv_path, build_row(prefix, next_no, module, fields, page_desc))
group = spec_group(spec, prefix)
print(f"{prefix}-{next_no:03d} {csv_path.name} | {len(fields)} 项 | spec 分组:{group} | {fixture}")
if __name__ == "__main__":
main()
"""把「组合查询」用例插入对应 spec 的筛选分组末尾(幂等,可重复执行)。"""
from __future__ import annotations
import csv
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# CSV 片段 -> (spec 文件, 目标 describe 标题, fixture 名)
TARGETS = {
"产品准入单列表": ("admit-product-list.spec.ts", "产品准入单列表 · 筛选与状态", "admitProductListPage"),
"物料准入单列表": ("admit-material-list.spec.ts", "物料准入单列表 · 筛选与状态", "admitMaterialListPage"),
"供货目录申请": ("admit-supply.spec.ts", "供货目录申请 · 筛选与状态", "admitSupplyPage"),
"准入任务分配": ("admit-task.spec.ts", "准入任务分配 · 筛选与分类", "admitTaskPage"),
"基础包维护": ("surgery-base-pkg.spec.ts", "基础包维护 · 右侧筛选与列表", "surgeryBasePkgPage"),
"标准套包维护": ("surgery-std-pkg.spec.ts", "标准套包维护 · 筛选与构成列表", "surgeryStdPkgPage"),
"供应商资质管理": ("vendor-qual.spec.ts", "供应商资质管理 · 筛选与 Tab", "vendorQualPage"),
"授权变更初审": ("vendor-audit.spec.ts", "授权变更初审 · 筛选与列表", "vendorAuthFirstPage"),
"授权变更终审": ("vendor-audit.spec.ts", "授权变更终审 · 筛选与列表", "vendorAuthFinalPage"),
"注册证变更初审": ("vendor-audit.spec.ts", "注册证变更初审 · 筛选与列表", "vendorCertFirstPage"),
"注册证变更终审": ("vendor-audit.spec.ts", "注册证变更终审 · 筛选与列表", "vendorCertFinalPage"),
"注册证差异对比": ("vendor-cert-diff.spec.ts", "注册证差异对比 · 筛选与对比", "vendorCertDiffPage"),
"评价指标维护": ("vendor-eval-indicator.spec.ts", "评价指标维护 · 树与列表", "vendorEvalIndicatorPage"),
"评价方案维护": ("vendor-eval-scheme.spec.ts", "评价方案维护 · 筛选与维护", "vendorEvalSchemePage"),
}
def find_close_brace(lines: list[str], start: int) -> int:
"""从 describe 起始行找到与之配对的收尾 ` });`。"""
depth = 0
for idx in range(start, len(lines)):
depth += lines[idx].count("{") - lines[idx].count("}")
if depth <= 0 and idx > start:
return idx
raise SystemExit("describe 未闭合")
def main() -> None:
for frag, (spec, group, fixture) in TARGETS.items():
csv_path = next((ROOT / "testcase").glob(f"基础管理-*{frag}-UI自动化测试用例-01.csv"))
rows = [r for r in csv.reader(csv_path.open(encoding="utf-8")) if r]
row = next((r for r in rows[1:] if "组合查询" in r[3]), None)
if not row:
print(f"跳过(CSV 无组合查询行):{csv_path.name}")
continue
case_id, title = row[0], row[3]
spec_path = ROOT / "tests/spd" / spec
text = spec_path.read_text(encoding="utf-8")
if case_id in text:
print(f"跳过(spec 已有):{case_id}")
continue
lines = text.splitlines()
start = next((i for i, l in enumerate(lines) if l.strip().startswith(f"test.describe('{group}'")), None)
if start is None:
raise SystemExit(f"{spec} 未找到分组「{group}」")
close = find_close_brace(lines, start)
block = (
f" test('{case_id} {title}', async ({{ {fixture} }}) => {{\n"
f" await {fixture}.open();\n"
f" await {fixture}.filterCombination();\n"
f" }});"
)
lines.insert(close, block)
spec_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"{case_id} -> {spec} · {group}")
if __name__ == "__main__":
main()
# -*- coding: utf-8 -*-
"""把基础管理 23 份 CSV 对齐样板「出入库明细查询」的写法(仅整形,不改用例语义)。
对齐项(样板 testcase/统计报表-科室出入库报表-出入库明细查询-UI自动化测试用例-01.csv):
1. 前置条件:去掉明文账号 / 密码,统一「已通过 auth.setup 写入 storageState 登录」;
页面统一写成「进入 xxx 页(/路径,无 iframe)」,去掉域名与截图 / 扫描类运行时说明;
若前置已带路径则不做替换(幂等)。
2. 测试步骤:去掉「确认 chromium 已加载 storageState …」「使用用户名 test_1、密码 …」这类
与测试执行器相关的开场步骤,按 1..N 重新编号。
3. 用例编号 / 标题 / 功能测试点 / 优先级 / 预期结果 / 实际结果 一律不动。
用法:
python3.12 scripts/align-basic-cases-format.py # 写盘
python3.12 scripts/align-basic-cases-format.py --check # 只报告
"""
from __future__ import annotations
import csv
import io
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CASE_DIR = ROOT / "testcase"
LOGIN_OK = "已通过 auth.setup 写入 storageState 登录"
# 页面:模块显示名 / 菜单路径 / URL 路径;godoc 里对齐 data/*.data.ts
PAGES: dict[str, dict[str, str]] = {
"公告管理": {"menu": "基础设置 → 医院资料 → 公告管理", "path": "/spd/basic/hospital/paltform/notice"},
"医院信息登记": {"menu": "基础设置 → 医院资料 → 医院信息登记", "path": "/spd/basic/hospital/paltform/info"},
"财务月份设置": {"menu": "基础设置 → 医院资料 → 财务月份设置", "path": "/spd/basic/hospital/balance/month"},
"院区管理": {"menu": "基础设置 → 医院资料 → 院区管理", "path": "/spd/basic/hospital/paltform/area"},
"物料准入管理": {"menu": "基础设置 → 准入管理 → 物料准入管理", "path": "/spd/basic/dataSync/dataSync/productApply/ZRlist"},
"产品准入申请": {"menu": "基础设置 → 准入管理 → 产品准入申请", "path": "/spd/basic/dataSync/dataSync/productApply/list"},
"物料准入单列表": {"menu": "基础设置 → 准入管理 → 物料准入单列表", "path": "/spd/basic/dataSync/dataSync/productOrder/zrlist"},
"产品准入单列表": {"menu": "基础设置 → 准入管理 → 产品准入单列表", "path": "/spd/basic/dataSync/dataSync/productOrder/list"},
"供货目录申请": {"menu": "基础设置 → 准入管理 → 供货目录申请", "path": "/spd/basic/dataSync/dataSync/supplyApply/list"},
"准入任务分配": {"menu": "基础设置 → 准入管理 → 准入任务分配", "path": "/spd/basic/dataSync/dataSync/accessKind/list"},
"厂商查询": {"menu": "基础设置 → 厂商管理 → 厂商查询", "path": "/spd/basic/mfrs/paltform/mfrsName/factory"},
"供应商资质管理": {"menu": "基础设置 → 厂商管理 → 供应商资质管理", "path": "/spd/basic/mfrs/basic/qcManage"},
"授权变更初审": {"menu": "基础设置 → 厂商管理 → 授权变更初审", "path": "/spd/basic/mfrs/paltform/mfrsName/authAudit"},
"授权变更终审": {"menu": "基础设置 → 厂商管理 → 授权变更终审", "path": "/spd/basic/mfrs/paltform/mfrsName/authAuditFinal"},
"注册证变更初审": {"menu": "基础设置 → 厂商管理 → 注册证变更初审", "path": "/spd/basic/mfrs/paltform/mfrsName/regCertAudit"},
"注册证变更终审": {"menu": "基础设置 → 厂商管理 → 注册证变更终审", "path": "/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal"},
"注册证差异对比": {"menu": "基础设置 → 厂商管理 → 注册证差异对比", "path": "/spd/basic/mfrs/scjs/yc/certCompare"},
"评价方案维护": {"menu": "基础设置 → 厂商管理 → 评价方案维护", "path": "/spd/basic/mfrs/paltform/mfrsName/evaluationPlan"},
"评价指标维护": {"menu": "基础设置 → 厂商管理 → 评价指标维护", "path": "/spd/basic/mfrs/paltform/mfrsName/indicators"},
"供应商节假日管理": {"menu": "基础设置 → 厂商管理 → 供应商节假日管理", "path": "/spd/basic/mfrs/basic/prov/holiday"},
"基础包维护": {"menu": "基础设置 → 手术套包设置 → 基础包维护", "path": "/spd/basic/operationSetting/baseMaintenance"},
"术种维护": {"menu": "基础设置 → 手术套包设置 → 术种维护", "path": "/spd/basic/operationSetting/typeMaintenance"},
"标准套包维护": {"menu": "基础设置 → 手术套包设置 → 标准套包维护", "path": "/spd/basic/operationSetting/standardMaintenance"},
}
CRED_RE = re.compile(
r"(正例账号[^)]*)" # (正例账号 xxx / ****)
r"|用户名\s*\S+、密码\s*[^\s;;,,))]+" # 用户名 xxx、密码 ****
r"|密码\s*[A-Za-z0-9!@#$%^&*._-]{6,}" # 单独的明文密码片段
)
RUNTIME_STEP_RE = re.compile(
r"^\s*\d+\.\s*(确认 chromium|使用用户名|使用 test\.use|storageState 已有效|浏览器上下文)",
re.M,
)
PAGE_PRE_RE = re.compile(r"已(?:进入|直接访问)([^;;((]+?)页(?:([^)]*))?")
NUMBER_RE = re.compile(r"^\s*(\d+)\.\s*(.*)$", re.M)
URL_BACKTICK_RE = re.compile(r"`https?://[^/`]+(/[^`]*)`")
def page_of(filename: str) -> str:
tail = filename.replace("-UI自动化测试用例-01.csv", "")
return tail.rsplit("-", 1)[-1]
def has_path(pre: str) -> bool:
return bool(re.search(r"(/|(http|/spd/", pre))
PATH_PRE_RE = re.compile(r"已进入([^;;(]+?)页([^)]*)")
BAD_FRAG_RE = re.compile(r";;|登录已通过")
PATH_TAIL_RE = re.compile(r",无 iframe)$")
def _balanced(text: str) -> bool:
return text.count("(") == text.count(")")
def clean_pre(pre: str, page: str) -> str:
info = PAGES[page]
# 1) 先去掉凭据,统一成「已通过 auth.setup 写入 storageState 登录」
text = CRED_RE.sub(LOGIN_OK, pre)
# 旧写法「已通过 auth.setup 写入 storageState 登录 已登录并进入 …页」→ 只保留一份
text = text.replace(f"{LOGIN_OK} 已登录并进入", f"{LOGIN_OK};已进入")
text = text.replace(f"{LOGIN_OK}已登录并进入", f"{LOGIN_OK};已进入")
# 2) 页面括号统一成样板写法(先于分句,避免括号被拆开)
text = re.sub(r"((?:https?://|/spd/)[^)]*)", f"({info['path']},无 iframe)", text)
text = PATH_PRE_RE.sub(lambda m: f"已进入{m.group(1)}页({info['path']},无 iframe)", text)
# 3) 分句去重
kept: list[str] = []
seen_login = False
for part in (p.strip() for p in text.split(";")):
if not part:
continue
if LOGIN_OK in part:
extra = part.replace(LOGIN_OK, "").strip("() ")
if seen_login:
if not extra:
continue
part = extra
else:
part = f"{LOGIN_OK}" + (f"({extra})" if extra and not extra.startswith("无 iframe") else "")
seen_login = True
if re.fullmatch(r"已登录.*", part) or part.startswith("已登录且具备"):
continue
if part.startswith("并进入"):
part = "已进入" + part[len("并进入"):]
kept.append(part)
text = ";".join(kept)
text = re.sub(r";{2,}", ";", text).strip(";")
# 4) 未写页面时补一句「已进入 xxx 页(路径,无 iframe)」;未登录反例只写目标 URL
if not has_path(text):
parts = [p for p in text.split(";") if p.strip()]
if "无登录态" in text or "未带登录态" in text or "清除登录态" in text:
parts.append(f"直接访问{page} URL({info['path']},无 iframe)")
return ";".join(dict.fromkeys(parts))
anchor = f"已进入{page}页({info['path']},无 iframe)"
insert_at = 1
for i, part in enumerate(parts):
if "权限" in part:
insert_at = i + 1
parts.insert(insert_at, anchor)
text = ";".join(parts)
# 5) 页面句只保留第一处;其后的「已进入…页」分句整体丢弃
parts = [p.strip() for p in text.split(";") if p.strip()]
out: list[str] = []
page_seen = False
for part in parts:
is_page_part = bool(PATH_PRE_RE.search(part)) or (
part.startswith("已进入") and page in part
)
if is_page_part:
if page_seen:
continue
page_seen = True
out.append(part)
text = ";".join(out)
# 6) 收尾收敛:未登录反例统一「直接访问{页面} URL(路径,无 iframe)」
text = re.sub(rf"直接访问\s*{re.escape(page)}\s*URL([^)]*),?", f"直接访问{page} URL({info['path']},无 iframe)", text)
page_hits = re.findall(rf"已进入(?:{re.escape(page)}|{re.escape(info['path'])}|[^;]*?页)", text)
if len(page_hits) > 1:
raise ValueError(f"[{page}] 前置出现多个页面句:{text}")
if BAD_FRAG_RE.search(text) or not _balanced(text):
raise ValueError(f"[{page}] 前置整形后仍异常:{text}")
return text
def clean_steps(steps: str) -> str:
lines = steps.split("\n")
kept = [ln for ln in lines if not RUNTIME_STEP_RE.match(ln)]
out = []
for i, ln in enumerate(kept, 1):
m = NUMBER_RE.match(ln)
body = m.group(2) if m else ln.strip()
if body:
body = URL_BACKTICK_RE.sub(r"`\1`", body)
out.append(f"{i}. {body}")
return "\n".join(out)
def main() -> None:
check_only = "--check" in sys.argv
changed = 0
for path in sorted(CASE_DIR.glob("基础管理-*-UI自动化测试用例-01.csv")):
page = page_of(path.name)
if page not in PAGES:
print(f"跳过未知页面:{path.name}")
continue
with path.open(encoding="utf-8-sig", newline="") as fh:
rows = list(csv.reader(fh))
head, body = rows[0], rows[1:]
if head[:9] != ["用例编号", "功能模块", "功能测试点", "用例标题", "优先级", "前置条件", "测试步骤", "预期结果", "实际结果"]:
raise SystemExit(f"{path.name} 表头与 9 列规范不一致:{head}")
new_rows = []
for r in body:
if not r:
continue
row = list(r) + [""] * (9 - len(r))
row[5] = clean_pre(row[5], page)
row[6] = clean_steps(row[6])
new_rows.append(row[:9])
buf = io.StringIO()
writer = csv.writer(buf, quoting=csv.QUOTE_ALL, lineterminator="\n")
writer.writerow(head[:9])
writer.writerows(new_rows)
text = buf.getvalue()
old = path.read_text(encoding="utf-8")
if old.lstrip("\ufeff") == text:
continue
changed += 1
print(f"{'待更新' if check_only else '已写入'} {path.name}")
if not check_only:
path.write_text(text, encoding="utf-8", newline="")
print(f"共 {changed} 份{'需要' if check_only else '已'}更新")
if __name__ == "__main__":
main()
#!/usr/bin/env node
/** CSV 用例编号+标题必须出现在对应 spec 中。 */
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PAIRS = [
['testcase/基础管理-医院资料-公告管理-UI自动化测试用例-01.csv', 'tests/spd/basic-notice.spec.ts'],
['testcase/基础管理-医院资料-医院信息登记-UI自动化测试用例-01.csv', 'tests/spd/basic-hospital-info.spec.ts'],
['testcase/基础管理-医院资料-财务月份设置-UI自动化测试用例-01.csv', 'tests/spd/basic-finance-month.spec.ts'],
['testcase/基础管理-医院资料-院区管理-UI自动化测试用例-01.csv', 'tests/spd/basic-branch.spec.ts'],
['testcase/基础管理-准入管理-物料准入管理-UI自动化测试用例-01.csv', 'tests/spd/admit-material.spec.ts'],
['testcase/基础管理-准入管理-产品准入申请-UI自动化测试用例-01.csv', 'tests/spd/admit-product-apply.spec.ts'],
['testcase/基础管理-准入管理-物料准入单列表-UI自动化测试用例-01.csv', 'tests/spd/admit-material-list.spec.ts'],
['testcase/基础管理-准入管理-产品准入单列表-UI自动化测试用例-01.csv', 'tests/spd/admit-product-list.spec.ts'],
['testcase/基础管理-准入管理-供货目录申请-UI自动化测试用例-01.csv', 'tests/spd/admit-supply.spec.ts'],
['testcase/基础管理-准入管理-准入任务分配-UI自动化测试用例-01.csv', 'tests/spd/admit-task.spec.ts'],
['testcase/基础管理-用户管理-用户管理-UI自动化测试用例-01.csv', 'tests/spd/basic-user.spec.ts'],
['testcase/基础管理-用户管理-医共体用户管理-UI自动化测试用例-01.csv', 'tests/spd/basic-user-union.spec.ts'],
['testcase/基础管理-用户管理-角色管理-UI自动化测试用例-01.csv', 'tests/spd/basic-role.spec.ts'],
];
function parseCsv(text) {
const rows = [];
let row = [];
let cell = '';
let i = 0;
let quoted = false;
const pushCell = () => {
row.push(cell);
cell = '';
};
const pushRow = () => {
if (row.length) rows.push(row);
row = [];
};
while (i < text.length) {
const ch = text[i];
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"';
i += 2;
continue;
}
if (ch === '"') {
quoted = false;
i += 1;
continue;
}
cell += ch;
i += 1;
continue;
}
if (ch === '"') {
quoted = true;
i += 1;
continue;
}
if (ch === ',') {
pushCell();
i += 1;
continue;
}
if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i += 1;
pushCell();
pushRow();
i += 1;
continue;
}
cell += ch;
i += 1;
}
if (cell || row.length) {
pushCell();
pushRow();
}
return rows;
}
const missing = [];
for (const [csvRel, specRel] of PAIRS) {
const csvText = readFileSync(path.join(ROOT, csvRel), 'utf8').replace(/^\uFEFF/, '');
const spec = readFileSync(path.join(ROOT, specRel), 'utf8');
const table = parseCsv(csvText);
const header = table[0] || [];
const idIdx = header.indexOf('用例编号');
const titleIdx = header.indexOf('用例标题');
const pointIdx = header.indexOf('功能测试点');
const modIdx = header.indexOf('功能模块');
for (const cols of table.slice(1)) {
if (!cols[idIdx]) continue;
const title = `${cols[idIdx]} ${cols[titleIdx]}`;
if (!spec.includes(title)) missing.push(`${specRel}: ${title}`);
if (cols[pointIdx] && !spec.includes(cols[pointIdx])) {
missing.push(`${specRel}: describe ${cols[pointIdx]}`);
}
if (cols[modIdx] && !spec.includes(cols[modIdx])) {
missing.push(`${specRel}: module ${cols[modIdx]}`);
}
}
}
if (missing.length) {
console.error('[gate] CSV 与 spec 不一致:');
for (const item of missing) console.error(` ${item}`);
process.exit(1);
}
console.log('[gate] CSV ↔ spec 标题一致');
#!/usr/bin/env node
/** 手术套包设置 CSV 用例编号+标题+功能测试点+功能模块 必须出现在对应 spec 中。 */
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PAIRS = [
['testcase/基础管理-手术套包设置-基础包维护-UI自动化测试用例-01.csv', 'tests/spd/surgery-base-pkg.spec.ts'],
['testcase/基础管理-手术套包设置-术种维护-UI自动化测试用例-01.csv', 'tests/spd/surgery-oper-type.spec.ts'],
['testcase/基础管理-手术套包设置-标准套包维护-UI自动化测试用例-01.csv', 'tests/spd/surgery-std-pkg.spec.ts'],
];
function parseCsv(text) {
const rows = [];
let row = [];
let cell = '';
let i = 0;
let quoted = false;
const pushCell = () => {
row.push(cell);
cell = '';
};
const pushRow = () => {
if (row.length) rows.push(row);
row = [];
};
while (i < text.length) {
const ch = text[i];
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"';
i += 2;
continue;
}
if (ch === '"') {
quoted = false;
i += 1;
continue;
}
cell += ch;
i += 1;
continue;
}
if (ch === '"') {
quoted = true;
i += 1;
continue;
}
if (ch === ',') {
pushCell();
i += 1;
continue;
}
if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i += 1;
pushCell();
pushRow();
i += 1;
continue;
}
cell += ch;
i += 1;
}
if (cell || row.length) {
pushCell();
pushRow();
}
return rows;
}
const missing = [];
let total = 0;
for (const [csvRel, specRel] of PAIRS) {
const csvText = readFileSync(path.join(ROOT, csvRel), 'utf8').replace(/^\uFEFF/, '');
const spec = readFileSync(path.join(ROOT, specRel), 'utf8');
const table = parseCsv(csvText);
const header = table[0] || [];
const idIdx = header.indexOf('用例编号');
const titleIdx = header.indexOf('用例标题');
const pointIdx = header.indexOf('功能测试点');
const modIdx = header.indexOf('功能模块');
for (const cols of table.slice(1)) {
if (!cols[idIdx]) continue;
total += 1;
const title = `${cols[idIdx]} ${cols[titleIdx]}`;
if (!spec.includes(title)) missing.push(`${specRel}: ${title}`);
if (cols[pointIdx] && !spec.includes(cols[pointIdx])) {
missing.push(`${specRel}: describe ${cols[pointIdx]}`);
}
if (cols[modIdx] && !spec.includes(cols[modIdx])) {
missing.push(`${specRel}: module ${cols[modIdx]}`);
}
}
}
if (missing.length) {
console.error('[gate] 手术套包设置 CSV 与 spec 不一致:');
for (const item of missing) console.error(` ${item}`);
process.exit(1);
}
console.log(`[gate] 手术套包设置 CSV ↔ spec 一致(${total} 条)`);
#!/usr/bin/env node
/** 用户管理 CSV 用例编号+标题+功能测试点+功能模块 必须出现在对应 spec 中。 */
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PAIRS = [
['testcase/基础管理-用户管理-用户管理-UI自动化测试用例-01.csv', 'tests/spd/basic-user.spec.ts'],
['testcase/基础管理-用户管理-医共体用户管理-UI自动化测试用例-01.csv', 'tests/spd/basic-user-union.spec.ts'],
['testcase/基础管理-用户管理-角色管理-UI自动化测试用例-01.csv', 'tests/spd/basic-role.spec.ts'],
];
function parseCsv(text) {
const rows = [];
let row = [];
let cell = '';
let i = 0;
let quoted = false;
const pushCell = () => {
row.push(cell);
cell = '';
};
const pushRow = () => {
if (row.length) rows.push(row);
row = [];
};
while (i < text.length) {
const ch = text[i];
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"';
i += 2;
continue;
}
if (ch === '"') {
quoted = false;
i += 1;
continue;
}
cell += ch;
i += 1;
continue;
}
if (ch === '"') {
quoted = true;
i += 1;
continue;
}
if (ch === ',') {
pushCell();
i += 1;
continue;
}
if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i += 1;
pushCell();
pushRow();
i += 1;
continue;
}
cell += ch;
i += 1;
}
if (cell || row.length) {
pushCell();
pushRow();
}
return rows;
}
const missing = [];
let total = 0;
for (const [csvRel, specRel] of PAIRS) {
const csvText = readFileSync(path.join(ROOT, csvRel), 'utf8').replace(/^\uFEFF/, '');
const spec = readFileSync(path.join(ROOT, specRel), 'utf8');
const table = parseCsv(csvText);
const header = table[0] || [];
const idIdx = header.indexOf('用例编号');
const titleIdx = header.indexOf('用例标题');
const pointIdx = header.indexOf('功能测试点');
const modIdx = header.indexOf('功能模块');
for (const cols of table.slice(1)) {
if (!cols[idIdx]) continue;
total += 1;
const title = `${cols[idIdx]} ${cols[titleIdx]}`;
if (!spec.includes(title)) missing.push(`${specRel}: ${title}`);
if (cols[pointIdx] && !spec.includes(cols[pointIdx])) {
missing.push(`${specRel}: describe ${cols[pointIdx]}`);
}
if (cols[modIdx] && !spec.includes(cols[modIdx])) {
missing.push(`${specRel}: module ${cols[modIdx]}`);
}
}
}
if (missing.length) {
console.error('[gate] 用户管理 CSV 与 spec 不一致:');
for (const item of missing) console.error(` ${item}`);
process.exit(1);
}
console.log(`[gate] 用户管理 CSV ↔ spec 一致(${total} 条)`);
#!/usr/bin/env node
/** 厂商管理 CSV 用例编号+标题+功能测试点 必须出现在对应 spec 中。 */
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PAIRS = [
['testcase/基础管理-厂商管理-厂商查询-UI自动化测试用例-01.csv', 'tests/spd/vendor-query.spec.ts'],
['testcase/基础管理-厂商管理-供应商资质管理-UI自动化测试用例-01.csv', 'tests/spd/vendor-qual.spec.ts'],
['testcase/基础管理-厂商管理-授权变更初审-UI自动化测试用例-01.csv', 'tests/spd/vendor-audit.spec.ts'],
['testcase/基础管理-厂商管理-授权变更终审-UI自动化测试用例-01.csv', 'tests/spd/vendor-audit.spec.ts'],
['testcase/基础管理-厂商管理-注册证变更初审-UI自动化测试用例-01.csv', 'tests/spd/vendor-audit.spec.ts'],
['testcase/基础管理-厂商管理-注册证变更终审-UI自动化测试用例-01.csv', 'tests/spd/vendor-audit.spec.ts'],
['testcase/基础管理-厂商管理-注册证差异对比-UI自动化测试用例-01.csv', 'tests/spd/vendor-cert-diff.spec.ts'],
['testcase/基础管理-厂商管理-评价方案维护-UI自动化测试用例-01.csv', 'tests/spd/vendor-eval-scheme.spec.ts'],
['testcase/基础管理-厂商管理-评价指标维护-UI自动化测试用例-01.csv', 'tests/spd/vendor-eval-indicator.spec.ts'],
['testcase/基础管理-厂商管理-供应商节假日管理-UI自动化测试用例-01.csv', 'tests/spd/vendor-holiday.spec.ts'],
];
function parseCsv(text) {
const rows = [];
let row = [];
let cell = '';
let i = 0;
let quoted = false;
const pushCell = () => {
row.push(cell);
cell = '';
};
const pushRow = () => {
if (row.length) rows.push(row);
row = [];
};
while (i < text.length) {
const ch = text[i];
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"';
i += 2;
continue;
}
if (ch === '"') {
quoted = false;
i += 1;
continue;
}
cell += ch;
i += 1;
continue;
}
if (ch === '"') {
quoted = true;
i += 1;
continue;
}
if (ch === ',') {
pushCell();
i += 1;
continue;
}
if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i += 1;
pushCell();
pushRow();
i += 1;
continue;
}
cell += ch;
i += 1;
}
if (cell || row.length) {
pushCell();
pushRow();
}
return rows;
}
const missing = [];
let total = 0;
for (const [csvRel, specRel] of PAIRS) {
const csvText = readFileSync(path.join(ROOT, csvRel), 'utf8').replace(/^\uFEFF/, '');
const spec = readFileSync(path.join(ROOT, specRel), 'utf8');
const table = parseCsv(csvText);
const header = table[0] || [];
const idIdx = header.indexOf('用例编号');
const titleIdx = header.indexOf('用例标题');
const pointIdx = header.indexOf('功能测试点');
const modIdx = header.indexOf('功能模块');
for (const cols of table.slice(1)) {
if (!cols[idIdx]) continue;
total += 1;
const title = `${cols[idIdx]} ${cols[titleIdx]}`;
if (!spec.includes(title)) missing.push(`${specRel}: ${title}`);
if (cols[pointIdx] && !spec.includes(cols[pointIdx])) {
missing.push(`${specRel}: describe ${cols[pointIdx]}`);
}
if (cols[modIdx] && !spec.includes(cols[modIdx])) {
missing.push(`${specRel}: module ${cols[modIdx]}`);
}
}
}
if (missing.length) {
console.error('[gate] 厂商 CSV 与 spec 不一致:');
for (const item of missing) console.error(` ${item}`);
process.exit(1);
}
console.log(`[gate] 厂商 CSV ↔ spec 一致(${total} 条)`);
#!/usr/bin/env node
/**
* 基础管理新模块(厂商 / 准入 / 医院资料 / 库房 / 流程)禁止 describeWithReuseOpen:
* 必须走 auth.setup + storageState + 每用例独立 context。
*/
import { readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const DIR = path.join(ROOT, 'tests', 'spd');
const PREFIXES = ['vendor-', 'admit-', 'basic-', 'warehouse-', 'flow-'];
const offenders = [];
for (const name of readdirSync(DIR)) {
if (!name.endsWith('.spec.ts')) continue;
if (!PREFIXES.some((p) => name.startsWith(p))) continue;
const text = readFileSync(path.join(DIR, name), 'utf8');
if (text.includes('describeWithReuseOpen')) {
offenders.push(name);
}
}
if (offenders.length) {
console.error('[gate] 基础管理新模块禁止 describeWithReuseOpen:');
for (const f of offenders) console.error(` ${f}`);
process.exit(1);
}
console.log('[gate] vendor/admit/basic/warehouse/flow 均未使用 describeWithReuseOpen');
#!/usr/bin/env node
/** 库房管理 / 流程设置 CSV 用例编号+标题+功能测试点+功能模块 必须出现在对应 spec 中。 */
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PAIRS = [
['testcase/基础管理-库房管理-库区设置-UI自动化测试用例-01.csv', 'tests/spd/warehouse-zone.spec.ts'],
['testcase/基础管理-库房管理-探针绑定-UI自动化测试用例-01.csv', 'tests/spd/warehouse-probe.spec.ts'],
['testcase/基础管理-流程设置-审批流程设置-UI自动化测试用例-01.csv', 'tests/spd/flow-def.spec.ts'],
['testcase/基础管理-流程设置-审批流实例-UI自动化测试用例-01.csv', 'tests/spd/flow-inst.spec.ts'],
];
function parseCsv(text) {
const rows = [];
let row = [];
let cell = '';
let i = 0;
let quoted = false;
const pushCell = () => {
row.push(cell);
cell = '';
};
const pushRow = () => {
if (row.length) rows.push(row);
row = [];
};
while (i < text.length) {
const ch = text[i];
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"';
i += 2;
continue;
}
if (ch === '"') {
quoted = false;
i += 1;
continue;
}
cell += ch;
i += 1;
continue;
}
if (ch === '"') {
quoted = true;
i += 1;
continue;
}
if (ch === ',') {
pushCell();
i += 1;
continue;
}
if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i += 1;
pushCell();
pushRow();
i += 1;
continue;
}
cell += ch;
i += 1;
}
if (cell || row.length) {
pushCell();
pushRow();
}
return rows;
}
const missing = [];
let total = 0;
for (const [csvRel, specRel] of PAIRS) {
const csvText = readFileSync(path.join(ROOT, csvRel), 'utf8').replace(/^\uFEFF/, '');
const spec = readFileSync(path.join(ROOT, specRel), 'utf8');
const table = parseCsv(csvText);
const header = table[0] || [];
const idIdx = header.indexOf('用例编号');
const titleIdx = header.indexOf('用例标题');
const pointIdx = header.indexOf('功能测试点');
const modIdx = header.indexOf('功能模块');
for (const cols of table.slice(1)) {
if (!cols[idIdx]) continue;
total += 1;
const title = `${cols[idIdx]} ${cols[titleIdx]}`;
if (!spec.includes(title)) missing.push(`${specRel}: ${title}`);
if (cols[pointIdx] && !spec.includes(cols[pointIdx])) {
missing.push(`${specRel}: describe ${cols[pointIdx]}`);
}
if (cols[modIdx] && !spec.includes(cols[modIdx])) {
missing.push(`${specRel}: module ${cols[modIdx]}`);
}
}
}
if (missing.length) {
console.error('[gate] 库房/流程 CSV 与 spec 不一致:');
for (const item of missing) console.error(` ${item}`);
process.exit(1);
}
console.log(`[gate] 库房/流程 CSV ↔ spec 一致(${total} 条)`);
......@@ -77,11 +77,30 @@ function suiteScope(summary) {
}
}
/** 读取跨构建失败频次统计,返回「经常失败」的用例(前 N 条)。 */
function loadFrequentFailures(limit = 10) {
const store = loadJson(path.join(ROOT, 'reports', 'flake', 'failure-counts.json'));
if (!store?.items) return { total: 0, rows: [] };
const minFails = Number(store.frequentMinFails ?? 2);
const rate = Number(store.frequentRate ?? 0.2);
const rows = Object.values(store.items)
.map((s) => ({ ...s, rate: s.runs > 0 ? s.failed / s.runs : 0 }))
.filter((s) => s.failed >= minFails && s.rate >= rate)
.sort((a, b) => b.failed - a.failed || b.rate - a.rate);
return { total: rows.length, rows: rows.slice(0, limit) };
}
function formatPassRate(passed, executed) {
if (!executed) return '-';
return `${((passed / executed) * 100).toFixed(1)}%`;
}
/** 去掉 STAT- 编号前缀,标题过长时截断。 */
function shortTestName(title, max = 60) {
const name = String(title ?? '').replace(/^STAT-[A-Z]+-\d{3,}\s*/, '');
return name.length > max ? `${name.slice(0, max - 1)}…` : name;
}
function statusLabel(summary, jenkinsResult) {
if (jenkinsResult && jenkinsResult !== 'SUCCESS' && jenkinsResult !== 'UNSTABLE') {
if (jenkinsResult === 'ABORTED') return { text: '已中止', color: '#6b7280' };
......@@ -120,6 +139,7 @@ function main() {
const duration = formatDuration(summary.durationMs);
const failed = parseFailedTests(path.join(LATEST, 'junit.xml'));
const moreFail = failTotal > failed.length ? failTotal - failed.length : 0;
const frequent = loadFrequentFailures(10);
const pwReportUrl = buildUrl ? `${buildUrl}Playwright_20Report/` : '';
const allureUrl = buildUrl ? `${buildUrl}allure` : '';
const viewUser = process.env.REPORT_VIEW_USER || '';
......@@ -162,6 +182,28 @@ function main() {
.join('')
: `<tr><td colspan="2" style="padding:8px;color:#047857;">无失败用例</td></tr>`;
const freqRows = frequent.rows
.map(
(item) => `<tr>
<td style="padding:6px 8px;border-bottom:1px solid #e5e7eb;font-family:Menlo,monospace;font-size:12px;">${escapeHtml(item.id)}</td>
<td style="padding:6px 8px;border-bottom:1px solid #e5e7eb;color:#b91c1c;font-size:12px;font-weight:600;">${item.failed}/${item.runs}(${(item.rate * 100).toFixed(0)}%)</td>
<td style="padding:6px 8px;border-bottom:1px solid #e5e7eb;color:#4b5563;font-size:12px;">${escapeHtml(shortTestName(item.title))}</td>
</tr>`,
)
.join('');
const frequentSection =
frequent.total > 0
? `<h3 style="margin:22px 0 8px;font-size:15px;">经常失败的用例(累计 ${frequent.total} 条,仅列前 ${frequent.rows.length} 条)</h3>
<p style="margin:0 0 8px;font-size:12px;color:#6b7280;">跨构建累计,失败次数与失败率双达标;建议优先修脚本或提单。</p>
<table style="border-collapse:collapse;width:100%;">
<tr style="background:#f9fafb;color:#6b7280;font-size:12px;text-align:left;">
<th style="padding:6px 8px;border-bottom:1px solid #e5e7eb;">编号</th>
<th style="padding:6px 8px;border-bottom:1px solid #e5e7eb;">失败/执行</th>
<th style="padding:6px 8px;border-bottom:1px solid #e5e7eb;">用例</th>
</tr>${freqRows}
</table>`
: '';
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>SPD UI 自动化报告</title></head>
......@@ -190,6 +232,7 @@ function main() {
${reportButtons}
<h3 style="margin:22px 0 8px;font-size:15px;">失败用例${moreFail ? `(仅列出前 ${failed.length} 条,另有 ${moreFail} 条)` : ''}</h3>
<table style="border-collapse:collapse;width:100%;">${failRows}</table>
${frequentSection}
<p style="margin:20px 0 0;font-size:12px;color:#6b7280;">完整截图与 Trace 请点上面的 Playwright 报告。此邮件由定时任务自动发送。</p>
</div>
</div>
......@@ -214,6 +257,11 @@ function main() {
`耗时: ${duration}`,
viewUser ? `报告只读账号: ${viewUser}` : '',
viewPass ? `报告只读密码: ${viewPass}` : '',
frequent.total > 0
? `经常失败的用例(累计 ${frequent.total} 条): ${frequent.rows
.map((r) => `${r.id}(${r.failed}/${r.runs})`)
.join(', ')}`
: '',
pwReportUrl ? `Playwright 报告: ${pwReportUrl}` : '',
allureUrl ? `Allure 报告: ${allureUrl}` : '',
'',
......
#!/usr/bin/env node
/**
* 失败频次榜单:读取 reports/flake/failure-counts.json,列出「经常失败」的用例。
*
* 数据来源:用例 afterEach 时按 STAT- 编号累计(见 core/failure-tracker.ts),
* reporter onEnd 刷新 reports/flake/frequent-failures.md。
*
* 用法:
* node scripts/failure-report.mjs
* node scripts/failure-report.mjs --min-fails=2 --rate=0.2
* node scripts/failure-report.mjs --top=30 --json
*/
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function parseArgs(argv) {
const out = {
store: path.join(ROOT, 'reports/flake/failure-counts.json'),
minFails: Number(process.env.FREQ_FAIL_MIN || 2),
rate: Number(process.env.FREQ_FAIL_RATE || 0.2),
top: 30,
json: false,
};
for (const arg of argv) {
if (arg === '--json') out.json = true;
else if (arg.startsWith('--store=')) out.store = path.resolve(arg.slice(8));
else if (arg.startsWith('--min-fails=')) out.minFails = Number(arg.slice(12)) || out.minFails;
else if (arg.startsWith('--rate=')) out.rate = Number(arg.slice(7)) || out.rate;
else if (arg.startsWith('--top=')) out.top = Number(arg.slice(6)) || out.top;
}
return out;
}
function shortTitle(title, max = 60) {
const name = String(title || '').replace(/^STAT-[A-Z]+-\d{3,}\s*/, '');
return name.length > max ? `${name.slice(0, max - 1)}…` : name;
}
function main() {
const opts = parseArgs(process.argv.slice(2));
if (!existsSync(opts.store)) {
console.error(`[failure-report] 未找到 ${path.relative(ROOT, opts.store)}`);
console.error('[failure-report] 先跑一次 CI/全量,用例结果会自动累计到这里。');
process.exit(2);
}
const store = JSON.parse(readFileSync(opts.store, 'utf8'));
const items = Object.values(store.items || {});
const rows = items
.map((s) => ({
...s,
rate: s.runs > 0 ? s.failed / s.runs : 0,
}))
.filter((s) => s.failed >= opts.minFails && s.rate >= opts.rate)
.sort((a, b) => b.failed - a.failed || b.rate - a.rate);
if (opts.json) {
console.log(JSON.stringify({ minFails: opts.minFails, rate: opts.rate, count: rows.length, rows }, null, 2));
return;
}
console.log(
`[failure-report] 累计 ${items.length} 个用例;经常失败(失败 ≥ ${opts.minFails} 且失败率 ≥ ${opts.rate})共 ${rows.length} 条:`,
);
if (!rows.length) {
console.log(' (暂无,需多次构建累计)');
return;
}
console.log(' 编号 失败/执行 失败率 连续 最近分类 用例');
for (const r of rows.slice(0, opts.top)) {
console.log(
` ${String(r.id).padEnd(14)} ${String(`${r.failed}/${r.runs}`).padEnd(9)} ${String(
`${(r.rate * 100).toFixed(0)}%`,
).padEnd(6)} ${String(r.consecutiveFails ?? '-').padEnd(5)} ${String(r.lastClass ?? '-').padEnd(12)} ${shortTitle(
r.title,
)}`,
);
}
}
main();
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Generate surgery testcase CSVs (9 fixed columns, RFC4180 quoting, UTF-8 no BOM)."""
import csv
import io
import os
import re
HEADER = [
"用例编号", "功能模块", "功能测试点", "用例标题", "优先级",
"前置条件", "测试步骤", "预期结果", "实际结果",
]
LOGIN_NOTE = ""
MOCK_NOTE = ""
LOGIN_OK = "已通过 auth.setup 写入 storageState 登录"
PRE_URL_BASE = ""
MODULE = "基础设置 · 手术套包设置"
MENU = "基础设置 → 手术套包设置"
def row(cid, point, title, pr, pre, steps, expect, mock=False):
return [cid, MODULE, point, title, pr, pre, steps, expect, ""]
def _renumber(steps: str) -> str:
"""去掉与执行器相关的开场步骤(LOGIN_NOTE/MOCK_NOTE),并按 1..N 重新编号。"""
out = []
for ln in steps.split("\n"):
body = re.sub(r"^\d+\.\s*", "", ln).strip()
if not body:
continue
if body.startswith("确认 chromium") or body.startswith("使用 mock 反例数据"):
continue
out.append(body)
return "\n".join(f"{i}. {b}" for i, b in enumerate(out, 1))
def build(cases, url):
out = io.StringIO()
w = csv.writer(out, quoting=csv.QUOTE_ALL, lineterminator="\n")
w.writerow(HEADER)
for c in cases:
c = [re.sub(r"§([A-Za-z0-9_]+)§", r"${\1}", cell) for cell in c]
c[5] = c[5].replace(f"{LOGIN_OK} 已登录", LOGIN_OK)
# 未登录反例:前置补目标 URL(相对路径 + 无 iframe)
if "无登录态" in c[5] and "已进入" not in c[5]:
c[5] = c[5] + f";直接访问{c[2].split(' · ')[-1]} URL({url},无 iframe)"
c[6] = _renumber(c[6])
w.writerow(c)
return out.getvalue()
# ---------------- 基础包维护 ----------------
BASE_PATH = "/spd/basic/operationSetting/baseMaintenance"
BASE_URL = BASE_PATH
base = []
def bp(cid, point, title, pr, pre, steps, expect):
base.append(row(cid, point, title, pr, pre, steps, expect))
B_PRE = f"测试环境可访问;{LOGIN_OK};具备「基础设置 → 手术套包设置」权限"
B_PAGE = f"{B_PRE};已进入基础包维护页({BASE_PATH},无 iframe)"
B_SAMPLE = f"{B_PAGE};默认列表至少 1 条数据"
bp(
"SURG-BASE-001", "基础包维护 · 左侧分类树", "分类名称搜索过滤", "高", B_PAGE,
f"{LOGIN_NOTE}\n2. 从左树读取 `§sampleCategoryName§`\n3. 在分类名称搜索框填入该关键字(即时过滤或回车,以现网为准)\n4. 断言树节点文本均包含该关键字",
"过滤后树节点名称与关键字匹配;无未捕获异常。",
)
bp(
"SURG-BASE-002", "基础包维护 · 左侧分类树", "选择分类联动右侧列表", "高", B_SAMPLE,
f"{LOGIN_NOTE}\n2. 点击 `§sampleCategoryName§` 分类节点\n3. 等待右侧列表刷新稳定\n4. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致",
"右侧仅展示该分类下基础包;总数与筛选一致。",
)
bp(
"SURG-BASE-003", "基础包维护 · 左侧分类树", "打开新增分类弹窗", "中", B_PAGE,
f"{LOGIN_NOTE}\n2. 点击「新增分类」\n3. 断言弹窗可见,含分类名称输入(#form_item_kindName)与「取 消」「确 认」\n4. 点击「取 消」关闭",
"新增分类弹窗字段完整;取消可关闭。",
)
bp(
"SURG-BASE-004", "基础包维护 · 左侧分类树", "分类搜索清空恢复全量", "低", B_PAGE,
f"{LOGIN_NOTE}\n2. 用 `§sampleCategoryName§` 过滤并记录过滤后节点数\n3. 清空分类搜索框\n4. 断言节点数回到未过滤的默认集合(或不少于过滤后节点数)",
"清空后分类树恢复默认集合。",
)
bp(
"SURG-BASE-005", "基础包维护 · 右侧筛选与列表", "默认列出基础包或空态明确", "高", B_PAGE,
f"{LOGIN_NOTE}\n2. 等待列表稳定\n3. 断言出现数据行,或「暂无数据」且分页总数为 0",
"加载无报错;有数据时「共 N 条」N≥1;无数据时空态明确。",
)
bp(
"SURG-BASE-006", "基础包维护 · 右侧筛选与列表", "产品名称筛选过滤", "高", B_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleProductName§`\n3. 在 `#table_search_form_keyword` 填入该关键字并点击「查 询」\n4. 断言结果行产品名称均包含该关键字",
"过滤结果与关键字匹配;「共 N 条」随之更新。",
)
bp(
"SURG-BASE-007", "基础包维护 · 右侧筛选与列表", "规格型号筛选过滤", "高", B_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleSpecModel§`\n3. 在 `#table_search_form_goodsSpec` 填入该关键字并点击「查 询」\n4. 断言结果行规格型号均包含该关键字",
"按规格型号过滤生效。",
)
bp(
"SURG-BASE-008", "基础包维护 · 右侧筛选与列表", "器械注册人筛选过滤", "高", B_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleMfrsName§`\n3. 在 `#table_search_form_goodsMfrsName` 填入该关键字并点击「查 询」\n4. 断言结果行器械注册人均包含该关键字",
"按器械注册人过滤生效。",
)
bp(
"SURG-BASE-009", "基础包维护 · 右侧筛选与列表", "列表核心列完整", "高", B_SAMPLE,
f"{LOGIN_NOTE}\n2. 断言表头包含序号、产品编号、产品名称、规格型号、器械注册人、单位、产地、耗材类型、操作",
"核心列均可见,操作列含「编 辑」「移 除」。",
)
bp(
"SURG-BASE-010", "基础包维护 · 右侧筛选与列表", "查询与重置", "中", B_SAMPLE,
f"{LOGIN_NOTE}\n2. 用关键字过滤后点击「重 置」\n3. 断言筛选输入框被清空、列表恢复默认集合",
"重置清空筛选并恢复默认列表。",
)
bp(
"SURG-BASE-011", "基础包维护 · 右侧筛选与列表", "分页与页大小切换", "中",
f"{B_SAMPLE};列表总数大于单页行数上限",
f"{LOGIN_NOTE}\n2. 切换每页条数(如 50 → 100)\n3. 断言单页行数不超过所选值,分页总数不变",
"页大小切换生效;总数保持稳定。",
)
bp(
"SURG-BASE-012", "基础包维护 · 右侧筛选与列表", "首页上一页不可用", "低", B_PAGE,
f"{LOGIN_NOTE}\n2. 处于第 1 页\n3. 断言「上一页」置灰或点击无效果",
"第 1 页无法回到上一页。",
)
bp(
"SURG-BASE-013", "基础包维护 · 右侧筛选与列表", "搜索无匹配空态(mock反例)", "中", B_PAGE,
f"{MOCK_NOTE}\n2. 在 `#table_search_form_keyword` 输入 mock 不存在关键字 `__NOT_EXIST_BASE__99999`\n3. 点击「查 询」\n4. 断言「暂无数据」且分页总数为 0",
"无匹配时空态明确,不白屏;总数为 0。",
)
bp(
"SURG-BASE-014", "基础包维护 · 新增基础包", "打开新增基础包弹窗", "高", B_PAGE,
f"{LOGIN_NOTE}\n2. 点击「新增基础包」\n3. 断言弹窗可见,含基础包名称(#form_item_pkgName)、所属分类(#form_item_kindId)、包编码(#form_item_pkgCode)、备注(#form_item_remark)\n4. 断言含「取 消」「确 认」",
"新增基础包弹窗字段完整(名称/所属分类/包编码/备注)。",
)
bp(
"SURG-BASE-015", "基础包维护 · 新增基础包", "新增取消不落库", "高", B_PAGE,
f"{LOGIN_NOTE}\n2. 记录当前分页总数 `§sampleTotal§`\n3. 打开新增,填写名称 `§uniqPkgName§` 后点「取 消」\n4. 断言列表总数不变,且无该名称行",
"取消后不新增基础包。",
)
bp(
"SURG-BASE-016", "基础包维护 · 新增基础包", "合法必填新增成功", "高",
f"{B_PRE};已进入基础包维护页({BASE_PATH},无 iframe);允许写基础包;名称 `§uniqPkgName§`(建议 AUTO-BASE-§timestamp§)、编码 `§uniqPkgCode§`",
f"{LOGIN_NOTE}\n2. 打开新增,填写名称、所属分类、包编码\n3. 点击「确 认」\n4. 断言弹窗关闭,列表出现该名称行且字段一致\n5. 后置:按环境约定清理该基础包,避免污染主数据",
"新增成功且列表可见;用例结束清理,不留生产脏数据。",
)
bp(
"SURG-BASE-017", "基础包维护 · 新增基础包", "基础包名称为空拦截(mock反例)", "高", B_PAGE,
f"{MOCK_NOTE}\n2. 打开新增基础包弹窗,名称留空,补齐所属分类与包编码\n3. 点击「确 认」\n4. 断言必填提示,弹窗仍在,列表无新行",
"确定被拦截;不落库。",
)
bp(
"SURG-BASE-018", "基础包维护 · 新增基础包", "所属分类为空拦截(mock反例)", "高", B_PAGE,
f"{MOCK_NOTE}\n2. 打开新增基础包弹窗,填写名称与包编码,不选所属分类\n3. 点击「确 认」\n4. 断言必填提示,弹窗仍在",
"确定被拦截;不落库。",
)
bp(
"SURG-BASE-019", "基础包维护 · 新增基础包", "重复点击确定防重", "中",
f"{B_PRE};已进入基础包维护页({BASE_PATH},无 iframe);允许写基础包;使用唯一名称 `§uniqPkgName§`",
f"{LOGIN_NOTE}\n2. 打开新增弹窗并填写合法数据\n3. 快速连点「确 认」两次\n4. 断言只产生一条基础包(或无重复名称/编码行)",
"连点只落一条;无重复脏数据。",
)
bp(
"SURG-BASE-020", "基础包维护 · 行内操作(编辑 / 移除)", "行内编辑回填", "高", B_SAMPLE,
f"{LOGIN_NOTE}\n2. 点击首行「编 辑」\n3. 断言编辑弹窗可见且回填当前行数据\n4. 点击「取 消」关闭",
"编辑弹窗回填正确;取消不影响数据。",
)
bp(
"SURG-BASE-021", "基础包维护 · 行内操作(编辑 / 移除)", "移除二次确认防误删", "高", B_SAMPLE,
f"{LOGIN_NOTE}\n2. 记录当前总数 `§sampleTotal§`\n3. 点击首行「移 除」\n4. 若出现确认框则点「取 消」\n5. 断言总数仍为 `§sampleTotal§`",
"移除有二次确认;取消不删除。",
)
bp(
"SURG-BASE-022", "基础包维护 · 行内操作(编辑 / 移除)", "新增基础包可在标准套包被引用", "中",
f"{B_PRE};已进入基础包维护页({BASE_PATH},无 iframe);已新增 `§uniqPkgName§` 且未清理;允许跨页只读访问",
f"{LOGIN_NOTE}\n2. 进入「标准套包维护」,点击「添加基础包」\n3. 在弹窗搜索框输入 `§uniqPkgName§`\n4. 断言可检索到该基础包并可勾选\n5. 后置:清理该基础包",
"新增基础包在标准套包可选;跨页数据一致。",
)
# ---------------- 术种维护 ----------------
TYPE_PATH = "/spd/basic/operationSetting/typeMaintenance"
TYPE_URL = TYPE_PATH
type_cases = []
def tp(cid, point, title, pr, pre, steps, expect):
type_cases.append(row(cid, point, title, pr, pre, steps, expect))
T_PRE = f"测试环境可访问;{LOGIN_OK};具备「基础设置 → 手术套包设置」权限"
T_PAGE = f"{T_PRE};已进入术种维护页({TYPE_PATH},无 iframe)"
T_SAMPLE = f"{T_PAGE};默认列表至少 1 条数据"
tp(
"SURG-TYPE-001", "术种维护 · 页面进入", "菜单进入术种维护页", "高", T_PAGE,
f"{LOGIN_NOTE}\n2. 按菜单「{MENU} → 术种维护」进入\n3. 断言 URL 含 `/spd/basic/operationSetting/typeMaintenance`\n4. 断言左侧分类树(术种分类名称搜索框)、右侧筛选区、状态 Tab、列表与分页可见",
"进入术种维护页;不误入基础包维护/标准套包维护;左右分栏渲染完整。",
)
tp(
"SURG-TYPE-002", "术种维护 · 页面进入", "同组页签切换后返回", "中", T_PAGE,
f"{LOGIN_NOTE}\n2. 点击页内 Tab「标准套包维护」,等待 URL 切换\n3. 返回点击 Tab「术种维护」\n4. 断言 URL 回到 typeMaintenance,左树与状态 Tab 重新渲染",
"三页签互切正常;返回后本页数据恢复。",
)
tp(
"SURG-TYPE-003", "术种维护 · 页面进入", "未登录访问拦截", "高",
"测试环境可访问;使用 test.use({ storageState: { cookies: [], origins: [] } }) 无登录态",
f"1. 不加载登录态(空 storageState)\n2. 直接访问 `{TYPE_URL}`\n3. 断言跳转登录页或不展示术种列表数据",
"未登录被拦截,不泄露术种数据。",
)
tp(
"SURG-TYPE-004", "术种维护 · 左侧术种分类树", "术种分类名称搜索过滤", "高", T_PAGE,
f"{LOGIN_NOTE}\n2. 从左树读取 `§sampleOperCategoryName§`\n3. 在分类名称搜索框填入该关键字(即时过滤或回车,以现网为准)\n4. 断言树节点文本均包含该关键字",
"过滤后树节点名称与关键字匹配;无未捕获异常。",
)
tp(
"SURG-TYPE-005", "术种维护 · 左侧术种分类树", "选择分类联动右侧列表", "高", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 点击 `§sampleOperCategoryName§` 分类节点\n3. 等待右侧列表刷新稳定\n4. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致",
"右侧仅展示该分类下术种;总数与筛选一致。",
)
tp(
"SURG-TYPE-006", "术种维护 · 左侧术种分类树", "分类树计数与下钻", "中",
f"{T_PAGE};至少 1 个带计数的分类节点",
f"{LOGIN_NOTE}\n2. 读取某分类节点括号内计数 `§nodeCount§`\n3. 点击该节点\n4. 断言右侧「共 N 条」与 `§nodeCount§` 一致",
"分类计数与该分类下术种条数一致。",
)
tp(
"SURG-TYPE-007", "术种维护 · 左侧术种分类树", "打开分类维护弹窗", "中", T_PAGE,
f"{LOGIN_NOTE}\n2. 点击左侧「新增」\n3. 断言弹窗可见,含分类名称输入(#basic_kindName)与「取 消」「保 存」\n4. 点击「取 消」关闭",
"分类维护弹窗字段完整;取消可关闭。",
)
tp(
"SURG-TYPE-008", "术种维护 · 左侧术种分类树", "分类搜索清空恢复全量", "低", T_PAGE,
f"{LOGIN_NOTE}\n2. 用 `§sampleOperCategoryName§` 过滤并记录过滤后节点数\n3. 清空分类搜索框\n4. 断言节点数回到未过滤的默认集合",
"清空后分类树恢复默认集合。",
)
tp(
"SURG-TYPE-009", "术种维护 · 筛选与状态 Tab", "术种名称筛选过滤", "高", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleOperName§`\n3. 在 `#table_search_form_operationName` 填入该关键字并点击「查 询」\n4. 断言结果行术种名称均包含该关键字",
"过滤结果与关键字匹配;「共 N 条」随之更新。",
)
tp(
"SURG-TYPE-010", "术种维护 · 筛选与状态 Tab", "病种名称筛选过滤", "高", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleDiseaseName§`\n3. 在 `#table_search_form_disease` 填入该关键字并点击「查 询」\n4. 断言结果行病种名称均包含该关键字",
"按病种名称过滤生效。",
)
tp(
"SURG-TYPE-011", "术种维护 · 筛选与状态 Tab", "状态 Tab 切换与计数一致", "高", T_PAGE,
f"{LOGIN_NOTE}\n2. 读取「全部」Tab 括号计数 `§totalCount§`\n3. 依次点击「停用」「未建分类」「全部」\n4. 每次断言列表条数与「共 N 条」和该 Tab 括号计数一致",
"Tab 切换后列表仅展示对应状态;括号计数与结果规模一致。",
)
tp(
"SURG-TYPE-012", "术种维护 · 筛选与状态 Tab", "左树 + 筛选 + Tab 组合过滤", "中", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 选择 `§sampleOperCategoryName§` 分类节点\n3. 填入术种名称关键字并点「查 询」\n4. 切到「全部」Tab\n5. 断言结果为三者交集且无未捕获异常",
"三条件叠加后结果正确,总数一致。",
)
tp(
"SURG-TYPE-013", "术种维护 · 筛选与状态 Tab", "重置清空筛选", "中", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 用关键字过滤后点击「重 置」\n3. 断言筛选输入框被清空、列表恢复默认集合",
"重置清空筛选并恢复默认列表。",
)
tp(
"SURG-TYPE-014", "术种维护 · 筛选与状态 Tab", "无匹配组合空态(mock反例)", "低", T_PAGE,
f"{MOCK_NOTE}\n2. 在 `#table_search_form_operationName` 输入 mock 不存在关键字 `__NOT_EXIST_OPER__99999`\n3. 点击「查 询」\n4. 断言「暂无数据」且分页总数为 0",
"无匹配时空态明确,不白屏;总数为 0。",
)
tp(
"SURG-TYPE-015", "术种维护 · 列表与编码展示", "列表核心列完整", "高", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 断言表头包含序号、术种名称、病种名称、诊断编码(ICD-10)、诊断名称(ICD-10)、操作编码(ICD-9-CM-3)、操作名称(ICD-9-CM-3)、操作",
"核心列均可见,操作列含「编 辑」「停用/启用」。",
)
tp(
"SURG-TYPE-016", "术种维护 · 列表与编码展示", "ICD-10 诊断编码展示完整", "中",
f"{T_SAMPLE};至少 1 条含诊断编码的数据",
f"{LOGIN_NOTE}\n2. 读取含 ICD-10 编码的行 `§sampleIcd10Code§`\n3. 断言编码文本完整(如 A02.000x010)不截断、无乱码",
"ICD-10 编码完整展示。",
)
tp(
"SURG-TYPE-017", "术种维护 · 列表与编码展示", "ICD-9-CM-3 操作编码展示完整", "低",
f"{T_SAMPLE};至少 1 条含操作编码的数据",
f"{LOGIN_NOTE}\n2. 读取含操作编码的行 `§sampleIcd9Code§`\n3. 断言操作编码(如 00.0200)与操作名称(如 心脏治疗性超声)对应展示无乱码",
"ICD-9-CM-3 编码与操作名称对应正确。",
)
tp(
"SURG-TYPE-018", "术种维护 · 列表与编码展示", "分页与页大小切换", "中",
f"{T_SAMPLE};列表总数大于单页行数上限",
f"{LOGIN_NOTE}\n2. 切换每页条数(如 50 → 100)\n3. 断言单页行数不超过所选值,分页总数不变",
"页大小切换生效;总数保持稳定。",
)
tp(
"SURG-TYPE-019", "术种维护 · 列表与编码展示", "首页上一页不可用", "低", T_PAGE,
f"{LOGIN_NOTE}\n2. 处于第 1 页\n3. 断言「上一页」置灰或点击无效果",
"第 1 页无法回到上一页。",
)
tp(
"SURG-TYPE-020", "术种维护 · 新增术种", "打开新增术种入口", "高", T_PAGE,
f"{LOGIN_NOTE}\n2. 点击「新增术种」\n3. 断言新增弹窗/页面可见,含术种名称、病种名称、分类与编码相关字段\n4. 断言含「取 消」「保 存」",
"新增术种入口字段完整。",
)
tp(
"SURG-TYPE-021", "术种维护 · 新增术种", "新增取消不落库", "高", T_PAGE,
f"{LOGIN_NOTE}\n2. 记录当前分页总数 `§sampleTotal§`\n3. 打开新增,填写名称 `§uniqOperName§` 后点「取 消」\n4. 断言列表总数不变,且无该名称行",
"取消后不新增术种。",
)
tp(
"SURG-TYPE-022", "术种维护 · 新增术种", "术种名称为空拦截(mock反例)", "高", T_PAGE,
f"{MOCK_NOTE}\n2. 打开新增术种弹窗,名称留空\n3. 点击「保 存」\n4. 断言必填提示,弹窗仍在,列表无新行",
"保存被拦截;不落库。",
)
tp(
"SURG-TYPE-023", "术种维护 · 行内操作(编辑 / 停用)", "行内编辑回填", "高", T_SAMPLE,
f"{LOGIN_NOTE}\n2. 点击首行「编 辑」\n3. 断言编辑弹窗可见且回填当前术种数据\n4. 点击「取 消」关闭",
"编辑弹窗回填正确;取消不影响数据。",
)
tp(
"SURG-TYPE-024", "术种维护 · 行内操作(编辑 / 停用)", "停用状态流转", "中",
f"{T_PAGE};存在启用状态术种(优先选 `§uniqOperName§` 自建数据);允许写术种",
f"{LOGIN_NOTE}\n2. 记录「停用」Tab 当前计数 `§disabledCount§`\n3. 点击目标行「停用」,若有确认框则点「确 定」\n4. 断言「停用」Tab 计数 +1\n5. 后置:对该术种执行「启用」恢复",
"术种状态变为停用;「停用」计数 +1;结束恢复原状态。",
)
tp(
"SURG-TYPE-025", "术种维护 · 行内操作(编辑 / 停用)", "停用二次确认防误操作", "高",
f"{T_PAGE};存在启用状态术种",
f"{LOGIN_NOTE}\n2. 记录「停用」Tab 当前计数\n3. 点击首行「停用」\n4. 若出现确认框则点「取 消」\n5. 断言计数未变化",
"停用有二次确认;取消不改状态。",
)
# ---------------- 标准套包维护 ----------------
STD_PATH = "/spd/basic/operationSetting/standardMaintenance"
STD_URL = STD_PATH
std = []
def sp(cid, point, title, pr, pre, steps, expect):
std.append(row(cid, point, title, pr, pre, steps, expect))
S_PRE = f"测试环境可访问;{LOGIN_OK};具备「基础设置 → 手术套包设置」权限"
S_PAGE = f"{S_PRE};已进入标准套包维护页({STD_PATH},无 iframe)"
S_SAMPLE = f"{S_PAGE};默认列表至少 1 条数据"
sp(
"SURG-STD-001", "标准套包维护 · 页面进入", "菜单进入标准套包维护页", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 按菜单「{MENU} → 标准套包维护」进入\n3. 断言 URL 含 `/spd/basic/operationSetting/standardMaintenance`\n4. 断言左侧分类树、右侧筛选区、构成列表与分页可见",
"进入标准套包维护页;不误入基础包维护/术种维护;左右分栏渲染完整。",
)
sp(
"SURG-STD-002", "标准套包维护 · 页面进入", "同组页签切换后返回", "中", S_PAGE,
f"{LOGIN_NOTE}\n2. 点击页内 Tab「术种维护」,等待 URL 切换\n3. 返回点击 Tab「标准套包维护」\n4. 断言 URL 回到 standardMaintenance,左树与构成列表重新渲染",
"三页签互切正常;返回后本页数据恢复。",
)
sp(
"SURG-STD-003", "标准套包维护 · 页面进入", "未登录访问拦截", "高",
"测试环境可访问;使用 test.use({ storageState: { cookies: [], origins: [] } }) 无登录态",
f"1. 不加载登录态(空 storageState)\n2. 直接访问 `{STD_URL}`\n3. 断言跳转登录页或不展示套包列表数据",
"未登录被拦截,不泄露套包数据。",
)
sp(
"SURG-STD-004", "标准套包维护 · 左侧术种分类树", "术种分类名称搜索过滤", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 从左树读取 `§sampleOperCategoryName§`\n3. 在分类搜索框填入该关键字(即时过滤或回车,以现网为准)\n4. 断言树节点文本均包含该关键字",
"过滤后树节点名称与关键字匹配;无未捕获异常。",
)
sp(
"SURG-STD-005", "标准套包维护 · 左侧术种分类树", "选择分类联动构成列表", "高", S_SAMPLE,
f"{LOGIN_NOTE}\n2. 点击 `§sampleOperCategoryName§` 分类节点\n3. 等待右侧构成列表刷新稳定\n4. 断言列表加载无报错,分页「共 N 条」与当前分类规模一致",
"右侧仅展示该分类下标准套包;总数与筛选一致。",
)
sp(
"SURG-STD-006", "标准套包维护 · 左侧术种分类树", "分类搜索清空恢复全量", "低", S_PAGE,
f"{LOGIN_NOTE}\n2. 用 `§sampleOperCategoryName§` 过滤并记录过滤后节点数\n3. 清空分类搜索框\n4. 断言节点数回到未过滤的默认集合",
"清空后分类树恢复默认集合。",
)
sp(
"SURG-STD-007", "标准套包维护 · 筛选与构成列表", "默认展示标准套包构成", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 等待列表稳定\n3. 断言按「包名称」分组出现数据行,或「暂无数据」且分页总数为 0",
"加载无报错;有数据时「共 N 条」N≥1;无数据时空态明确。",
)
sp(
"SURG-STD-008", "标准套包维护 · 筛选与构成列表", "产品名称筛选过滤", "高", S_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleProductName§`\n3. 在 `#table_search_form_keyword` 填入该关键字并点击「查 询」\n4. 断言结果行产品名称均包含该关键字",
"过滤结果与关键字匹配;「共 N 条」随之更新。",
)
sp(
"SURG-STD-009", "标准套包维护 · 筛选与构成列表", "规格型号筛选过滤", "中", S_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleSpecModel§`\n3. 在 `#table_search_form_goodsSpec` 填入该关键字并点击「查 询」\n4. 断言结果行规格型号均包含该关键字",
"按规格型号过滤生效。",
)
sp(
"SURG-STD-010", "标准套包维护 · 筛选与构成列表", "器械注册人筛选过滤", "中", S_SAMPLE,
f"{LOGIN_NOTE}\n2. 从首行读取 `§sampleMfrsName§`\n3. 在 `#table_search_form_goodsMfrsName` 填入该关键字并点击「查 询」\n4. 断言结果行器械注册人均包含该关键字",
"按器械注册人过滤生效。",
)
sp(
"SURG-STD-011", "标准套包维护 · 筛选与构成列表", "构成列表核心列完整", "高",
f"{S_SAMPLE};存在分组",
f"{LOGIN_NOTE}\n2. 断言表头/首行包含包名称、包数量/每包含量、产品编号、产品名称、规格型号、器械注册人、单位、产地、耗材类型、操作",
"核心列均可见,操作列含「编 辑」「移 除」。",
)
sp(
"SURG-STD-012", "标准套包维护 · 筛选与构成列表", "包名称展开/收起子项", "高",
f"{S_SAMPLE};存在可展开的包分组",
f"{LOGIN_NOTE}\n2. 读取首个包分组名称 `§samplePkgGroupName§`\n3. 点击展开该分组,断言出现子项行\n4. 再次点击收起,断言子项行隐藏",
"展开显示子项、收起隐藏子项;分组计数不受影响。",
)
sp(
"SURG-STD-013", "标准套包维护 · 筛选与构成列表", "查询与重置", "中", S_SAMPLE,
f"{LOGIN_NOTE}\n2. 用关键字过滤后点击「重 置」\n3. 断言筛选输入框被清空、列表恢复默认集合",
"重置清空筛选并恢复默认列表。",
)
sp(
"SURG-STD-014", "标准套包维护 · 筛选与构成列表", "分页与页大小切换", "中",
f"{S_SAMPLE};列表总数大于单页行数上限",
f"{LOGIN_NOTE}\n2. 切换每页条数(如 50 → 100)\n3. 断言单页行数不超过所选值,分页总数不变",
"页大小切换生效;总数保持稳定。",
)
sp(
"SURG-STD-015", "标准套包维护 · 筛选与构成列表", "无匹配组合空态(mock反例)", "低", S_PAGE,
f"{MOCK_NOTE}\n2. 在 `#table_search_form_keyword` 输入 mock 不存在关键字 `__NOT_EXIST_STD__99999`\n3. 点击「查 询」\n4. 断言「暂无数据」且分页总数为 0",
"无匹配时空态明确,不白屏;总数为 0。",
)
sp(
"SURG-STD-016", "标准套包维护 · 添加基础包弹窗", "打开添加基础包弹窗", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 点击「添加基础包」\n3. 断言弹窗可见,含左侧基础包分类树与右侧基础包明细\n4. 断言含「取 消」「确定(N)」",
"添加基础包弹窗字段完整;确定按钮带选择计数。",
)
sp(
"SURG-STD-017", "标准套包维护 · 添加基础包弹窗", "弹窗内筛选与选择计数联动", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 打开添加基础包弹窗,在搜索框输入基础包关键字\n3. 勾选 1 个基础包\n4. 断言「确定(N)」计数由 0 变为 1",
"筛选生效;选择数与确定按钮计数联动。",
)
sp(
"SURG-STD-018", "标准套包维护 · 添加基础包弹窗", "弹窗内构成数量可编辑", "中",
f"{S_PAGE};已勾选至少 1 个基础包",
f"{LOGIN_NOTE}\n2. 定位明细行「构成数量」步进器,点击「Increase Value」\n3. 断言数量 +1 且合计随之更新",
"构成数量可编辑;加减生效。",
)
sp(
"SURG-STD-019", "标准套包维护 · 添加基础包弹窗", "添加基础包取消不落库", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 记录当前分页总数 `§sampleTotal§`\n3. 打开添加基础包,勾选 1 个基础包后点「取 消」\n4. 断言构成列表总数不变",
"取消后不新增构成行。",
)
sp(
"SURG-STD-020", "标准套包维护 · 添加基础包弹窗", "确定按钮零选禁用", "低",
f"{S_PAGE};已打开添加基础包弹窗且未选择",
f"{LOGIN_NOTE}\n2. 断言「确定(0)」置灰或点击无效果",
"未选择时确定不可用。",
)
sp(
"SURG-STD-021", "标准套包维护 · 添加基础包弹窗", "重复添加同一基础包", "中",
f"{S_PAGE};标准套包已含某基础包(或先添加一个);允许写套包",
f"{LOGIN_NOTE}\n2. 再次打开添加基础包,选择同一基础包并确定\n3. 断言按产品规则拒绝/合并/累加,不产生重复脏行",
"重复添加不产生重复行(策略以现网为准)。",
)
sp(
"SURG-STD-022", "标准套包维护 · 添加散件弹窗", "打开添加散件弹窗", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 点击「添加散件」\n3. 断言弹窗可见并展示「产品列表」视图,含产品名称/规格型号/器械注册人/耗材类型筛选与分页",
"添加散件弹窗产品列表视图完整。",
)
sp(
"SURG-STD-023", "标准套包维护 · 添加散件弹窗", "产品列表筛选与选择散件", "高",
f"{S_PAGE};已打开添加散件弹窗",
f"{LOGIN_NOTE}\n2. 在产品名称筛选框输入 `§sampleProductName§`\n3. 勾选首个匹配散件\n4. 断言散件进入应拣明细,计数同步变化",
"筛选生效;选中散件进入应拣明细。",
)
sp(
"SURG-STD-024", "标准套包维护 · 添加散件弹窗", "散件规格/应拣明细视图", "高",
f"{S_PAGE};已勾选至少 1 个散件",
f"{LOGIN_NOTE}\n2. 切换到规格明细/应拣明细视图\n3. 断言展示耗材品种数、耗材总数,以及应拣数量、产品编号/名称/规格/注册人/单位/耗材类型/是否收费列",
"应拣明细视图字段完整。",
)
sp(
"SURG-STD-025", "标准套包维护 · 添加散件弹窗", "明细统计与实际一致", "中",
f"{S_PAGE};应拣明细含至少 1 个散件",
f"{LOGIN_NOTE}\n2. 读取「耗材品种数」`§itemKindCount§` 与「耗材总数」`§itemTotalCount§`\n3. 断言品种数 = 明细去重产品数;总数 = 应拣数量合计",
"统计口径与实际所选一致。",
)
sp(
"SURG-STD-026", "标准套包维护 · 添加散件弹窗", "添加散件取消不落库", "高", S_PAGE,
f"{LOGIN_NOTE}\n2. 记录当前分页总数 `§sampleTotal§`\n3. 打开添加散件,勾选散件后点「取 消」\n4. 断言构成列表总数不变",
"取消后不新增散件行。",
)
sp(
"SURG-STD-027", "标准套包维护 · 添加散件弹窗", "万级散件列表首屏可交互", "低",
f"{S_PAGE};添加散件产品列表总量达万级(示例「共 16299 条」)",
f"{LOGIN_NOTE}\n2. 打开添加散件弹窗\n3. 断言首屏可交互(筛选/分页可用),不卡死\n4. 避免全量断言,仅校验首行与总数格式",
"万级列表首屏可用;无超时白屏。",
)
sp(
"SURG-STD-028", "标准套包维护 · 行内操作(编辑 / 移除)", "行内编辑构成项", "高",
f"{S_SAMPLE};先展开目标包",
f"{LOGIN_NOTE}\n2. 点击子项行「编 辑」\n3. 断言编辑弹窗/表单可见并回填当前值\n4. 点击「取 消」关闭",
"编辑回填正确;取消不影响数据。",
)
sp(
"SURG-STD-029", "标准套包维护 · 行内操作(编辑 / 移除)", "移除二次确认防误删整包", "高",
f"{S_SAMPLE};先展开目标包",
f"{LOGIN_NOTE}\n2. 记录当前行数\n3. 点击子项行「移 除」\n4. 若出现确认框则点「取 消」\n5. 断言行数未变化",
"移除有二次确认;取消不删除。",
)
OUT = [
("testcase/基础管理-手术套包设置-基础包维护-UI自动化测试用例-01.csv", base, BASE_PATH),
("testcase/基础管理-手术套包设置-术种维护-UI自动化测试用例-01.csv", type_cases, TYPE_PATH),
("testcase/基础管理-手术套包设置-标准套包维护-UI自动化测试用例-01.csv", std, STD_PATH),
]
for rel, cases, url in OUT:
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="") as f:
f.write(build(cases, url))
print(f"{rel}: {len(cases)} 条")
#!/usr/bin/env python3
"""生成基础管理-用户管理 3 页结构化 MD(testcases/)与同源 CSV(testcase/)。"""
from __future__ import annotations
import csv
from collections import Counter
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MD_DIR = ROOT / "testcases"
CSV_DIR = ROOT / "testcase"
TODAY = date.today().isoformat()
CSV_FIELDS = [
"用例编号",
"功能模块",
"功能测试点",
"用例标题",
"优先级",
"前置条件",
"测试步骤",
"预期结果",
"实际结果",
]
# 正例登录账号可写入 CSV;密码只放 .env TEST_PASS / auth.setup,禁止入库
POSITIVE_USER = "test_1"
def page(**kwargs) -> dict:
return kwargs
def case(
seq: int,
h3: str,
h4: str,
prio: str,
title: str,
tp: int,
steps: list[str],
expect: str,
pre: str = "",
auto: str | None = None,
) -> dict:
return {
"seq": seq,
"h3": h3,
"h4": h4,
"prio": prio,
"title": title,
"tp": tp,
"steps": steps,
"expect": expect,
"pre": pre,
"auto": auto,
}
PAGES = [
page(
code="USER-USR",
prefix="USR",
name="用户管理",
path="/spd/basic/user/platform/person",
scan="basic-user",
count=219,
spec="UI-SPEC-SPD-USER-001",
spec_file="basic-user.spec.ts",
tp_total=81,
write_note=(
"新增/编辑只打开后取消,不提交保存;授权只打开后取消;"
"重置密码只验证确认框后取消,不确认改密。禁止写死生产账号。"
),
nav_note=(
"一级菜单与叶子菜单均名「用户管理」,须点「基础设置 → 用户管理(子菜单)→ 用户管理(叶子)」;"
"建议 `openPath('/spd/basic/user/platform/person')` 并断言 URL,勿误入医共体/角色管理。"
),
samples=(
"查询后从首行读取 `${sampleUserName}`、`${sampleLoginName}`、`${sampleDept}`、`${sampleRole}`;"
"科室为 `--` 时换一行;mock:`__NOT_EXIST_USER__99999`;"
"写操作用 `${uniqLogin}`(建议 `auto.usr.${timestamp}`),取消不落库。"
),
skip="真实保存用户、禁用后登录、真实改密、Excel 导入文件、接口失败注入、会话过期、无菜单权限",
cases=[
case(
1, "页面进入", "功能验证点", "P0", "菜单进入用户管理页", 1,
[
"按菜单「基础设置 → 用户管理(子菜单)→ 用户管理(叶子)」进入,或 `openPath` 打开 `/spd/basic/user/platform/person`",
"断言 URL 含 `/spd/basic/user/platform/person`",
"断言筛选区、工具栏「新 增」「Excel导入」、列表列头与分页可见",
],
"进入用户管理页;不误入医共体用户管理或角色管理;无报错遮罩。",
auto="USER-USR-001",
),
case(
2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
[
"清除登录态后直接打开 `/spd/basic/user/platform/person`",
"断言跳转登录或不展示用户业务列表",
],
"未登录不可见用户数据。",
),
case(
3, "筛选与查询", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
[
"点击「查 询」并等待列表稳定",
"断言出现数据行,或「暂无数据」且分页总数为 0",
],
"查询无报错;有数据时分页「共 N 条」N≥1;无数据时空态明确。",
auto="USER-USR-002",
),
case(
4, "筛选与查询", "功能验证点", "P0", "姓名或登录账号关键字查询", 10,
[
"从首行读取 `${sampleUserName}` 或 `${sampleLoginName}` 取关键字",
"在「姓名」框(占位「姓名、登录账号」)填入关键字,点击「查 询」",
"断言结果行姓名或登录账号匹配该关键字",
],
"查询结果与已填关键字匹配;无未捕获异常。",
pre="默认查询至少 1 条且姓名或登录账号非空", auto="USER-USR-003",
),
case(
5, "筛选与查询", "功能验证点", "P0", "科室下拉筛选", 11,
[
"取首条所属科室非 `--` 的行,读取 `${sampleDept}`",
"科室下拉选择该值,点击「查 询」",
"断言结果行「所属科室」与所选一致,且不含未分配 `--` 行",
],
"所属科室列与筛选条件一致。",
pre="列表存在所属科室非空行,否则 skip", auto="USER-USR-004",
),
case(
6, "筛选与查询", "功能验证点", "P0", "姓名+科室+角色组合查询", 13,
[
"读取 `${sampleUserName}`、`${sampleDept}`、`${sampleRole}`(角色可从该行截断前列名)",
"填写姓名、科室、角色后点击「查 询」",
"断言结果同时满足已填条件",
],
"组合查询无报错;结果同时满足全部已填条件。",
pre="默认查询至少 1 条", auto="USER-USR-005",
),
case(
7, "筛选与查询", "功能验证点", "P1", "重置恢复默认", 14,
[
"填写姓名等筛选条件后点击「重 置」",
"断言筛选字段恢复默认,列表按默认规则刷新",
],
"条件恢复默认;列表刷新。",
auto="USER-USR-006",
),
case(
8, "筛选与查询", "功能验证点", "P1", "展开高级筛选", 15,
[
"点击「高级筛选」",
"断言展开后面板可见姓名/科室/角色/状态及「重 置」「筛 选」",
],
"高级筛选面板展开;关键字段与筛选按钮可见。",
auto="USER-USR-007",
),
case(
9, "筛选与查询", "功能验证点", "P0", "高级筛选按状态过滤", 16,
[
"展开高级筛选,状态下拉选择「启用」或「禁用」",
"点击「筛 选」",
"断言列表仅保留对应状态用户(无匹配则空态且总数为 0)",
],
"按状态过滤成功;无报错。",
auto="USER-USR-008",
),
case(
10, "筛选与查询", "异常验证点", "P1", "无匹配结果空态(mock反例)", 22,
[
"姓名框填入 `__NOT_EXIST_USER__99999`",
"点击「查 询」",
"断言空态或「暂无数据」,分页总数为 0",
],
"无匹配时不报错;空态明确;总数为 0。",
auto="USER-USR-009",
),
case(
11, "列表与分页", "功能验证点", "P0", "列表核心列完整", 25,
[
"断言表头含序号、姓名、登录账号、联系方式、所属科室、医院员工编号、角色、科室任职数、操作",
"断言操作列可见「编辑」「授权」「重置密码」",
],
"核心列完整;行内三个操入口可见。",
auto="USER-USR-010",
),
case(
12, "列表与分页", "功能验证点", "P1", "列表不含所属医院列", 26,
[
"断言表头不含「所属医院」",
],
"本页无所属医院列,与医共体用户管理页区分。",
auto="USER-USR-011",
),
case(
13, "列表与分页", "功能验证点", "P1", "分页翻至第 2 页", 31,
[
"记录第 1 页首行登录账号",
"点击页码「2」或「下一页」",
"断言页码高亮为 2,首行账号与第 1 页不同",
],
"翻页成功;数据更换。",
pre="默认查询总条数大于一页,否则 skip", auto="USER-USR-012",
),
case(
14, "新增用户", "功能验证点", "P0", "打开新增用户弹窗", 41,
[
"点击「新 增」",
"断言弹出用户表单,可见账号、姓名、联系方式、医院员工编号、启用/禁用、「取 消」「确 定」",
],
"新增弹窗可见;关键字段与按钮齐全。",
auto="USER-USR-013",
),
case(
15, "新增用户", "功能验证点", "P1", "新增取消不落库", 43,
[
"打开新增,账号填 `${uniqLogin}`,姓名填任意合法值",
"点击「取 消」",
"按账号查询,断言无该记录",
],
"弹窗关闭;列表不出现 `${uniqLogin}`。",
auto="USER-USR-014",
),
case(
16, "新增用户", "异常验证点", "P0", "必填为空拦截", 47,
[
"打开新增,账号或姓名留空后点击「确 定」",
"断言出现必填提示,弹窗仍打开",
],
"确定被拦截;不落库;弹窗不关闭。",
auto="USER-USR-015",
),
case(
17, "新增用户", "异常验证点", "P0", "登录账号重复拦截", 48,
[
"打开新增,账号填入已存在的 `${sampleLoginName}`,补齐其它必填后点「确 定」",
"断言保存失败并提示,弹窗仍打开",
"查询该账号,断言原用户未被覆盖",
],
"重复账号被拒绝;不覆盖原用户。",
pre="默认查询至少 1 条且登录账号非空", auto="USER-USR-016",
),
case(
18, "编辑用户", "功能验证点", "P0", "打开编辑用户弹窗并回填", 54,
[
"点击首行「编辑」",
"断言弹窗标题含「编辑用户」",
"断言账号、姓名与该行 `${sampleLoginName}`、`${sampleUserName}` 一致(允许规范化空白)",
],
"编辑弹窗可见;回填与列表行一致。",
pre="列表至少 1 条", auto="USER-USR-017",
),
case(
19, "编辑用户", "功能验证点", "P1", "编辑取消不保存", 58,
[
"打开首行编辑,记录原姓名",
"将姓名改为临时值后点「取 消」",
"再次打开编辑,断言姓名仍为原值",
],
"取消后列表与回看均为修改前姓名。",
pre="列表至少 1 条", auto="USER-USR-018",
),
case(
20, "编辑用户", "边界验证点", "P1", "编辑时账号只读", 61,
[
"打开首行编辑",
"断言登录账号不可改(disabled/readonly 或无法输入)",
],
"编辑态登录账号只读。",
pre="列表至少 1 条", auto="USER-USR-019",
),
case(
21, "编辑用户", "功能验证点", "P1", "编辑弹窗可见签名图片", 56,
[
"打开首行编辑",
"断言可见「签名图片」区域(有图则展示,无图则上传入口可见)",
"点「取 消」,不保存",
],
"签名图片控件可见;本条不上传、不保存。",
pre="列表至少 1 条", auto="USER-USR-020",
),
case(
22, "授权", "功能验证点", "P0", "打开授权后取消不生效", 68,
[
"点击首行「授权」",
"断言打开授权层(弹窗或权限树),可见可勾选菜单/功能",
"如可勾选则改一处勾选后点取消/关闭",
"再次打开同一用户授权,断言勾选与操作前一致",
],
"授权层可打开;取消后勾选不落库。",
pre="列表至少 1 条", auto="USER-USR-021",
),
case(
23, "重置密码", "功能验证点", "P0", "行内重置密码确认后取消", 75,
[
"点击首行「重置密码」",
"断言出现确认框或密码表单,含取消/关闭",
"点取消/关闭",
"断言仍停留本页且该行仍在",
],
"重置需确认;取消后不改密。本条不点确定,避免污染账号。",
pre="列表至少 1 条", auto="USER-USR-022",
),
],
),
page(
code="USER-UNI",
prefix="UNI",
name="医共体用户管理",
path="/spd/basic/user/platform/personCom",
scan="basic-user-union",
count=221,
spec="UI-SPEC-SPD-USER-002",
spec_file="basic-user-union.spec.ts",
tp_total=58,
write_note=(
"新增/编辑只打开后取消,不提交保存;授权与改密规则同用户管理,本页不重复真实写操作。"
"禁止写死医院名称。"
),
nav_note="菜单「基础设置 → 用户管理 → 医共体用户管理」;可用 `openPath('/spd/basic/user/platform/personCom')`。",
samples=(
"查询后从首行读取 `${sampleLoginName}`、`${sampleUserName}`、`${sampleHospital}`、`${samplePhone}`;"
"禁止写死扫描到的医院名;mock:`__NOT_EXIST_USER__99999`。"
),
skip="授权/改密持久化(引用 TP_USER_USR)、真实跨院保存、Excel 导入文件、接口失败、会话过期",
cases=[
case(
1, "页面进入", "功能验证点", "P0", "菜单进入医共体用户管理页", 1,
[
"按菜单「基础设置 → 用户管理 → 医共体用户管理」进入,或 `openPath` 打开 `/spd/basic/user/platform/personCom`",
"断言 URL 含 `/spd/basic/user/platform/personCom`",
"断言所属医院筛选、工具栏、列表列头与分页可见",
],
"进入医共体用户管理页;不误入用户管理或角色管理。",
auto="USER-UNI-001",
),
case(
2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
[
"清除登录态后直接打开 `/spd/basic/user/platform/personCom`",
"断言跳转登录或不展示用户业务列表",
],
"未登录不可见医共体用户数据。",
),
case(
3, "筛选与查询", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
[
"点击「查 询」并等待列表稳定",
"断言出现数据行,或空态且分页总数为 0",
],
"查询无报错;有数据时「共 N 条」N≥1。",
auto="USER-UNI-002",
),
case(
4, "筛选与查询", "功能验证点", "P0", "姓名或登录账号关键字查询", 10,
[
"从首行读取 `${sampleUserName}` 或 `${sampleLoginName}`",
"在「姓名」框填入关键字,点击「查 询」",
"断言结果行姓名或登录账号匹配",
],
"查询结果与关键字匹配。",
pre="默认查询至少 1 条", auto="USER-UNI-003",
),
case(
5, "筛选与查询", "功能验证点", "P0", "所属医院筛选", 11,
[
"记录当前默认 `${sampleHospital}` 与分页总数",
"切换「所属医院」为另一可选项(仅 1 家则 skip)",
"点击「查 询」",
"断言结果行「所属医院」均为所选值,「共 N 条」可与默认不同",
],
"列表仅该院用户;总数随医院变化。",
auto="USER-UNI-004",
),
case(
6, "筛选与查询", "功能验证点", "P0", "姓名+医院+联系方式组合查询", 13,
[
"读取 `${sampleUserName}`、`${sampleHospital}`、`${samplePhone}`(电话非法占位如 `1` 时改用姓名+医院)",
"填写后点击「查 询」",
"断言结果同时满足已填条件",
],
"组合条件同时生效。",
pre="默认查询至少 1 条", auto="USER-UNI-005",
),
case(
7, "筛选与查询", "功能验证点", "P1", "重置恢复默认医院", 14,
[
"更改所属医院或姓名后点击「重 置」",
"断言所属医院恢复进入页时的默认值,列表刷新",
],
"医院等条件恢复默认。",
auto="USER-UNI-006",
),
case(
8, "筛选与查询", "功能验证点", "P1", "展开高级筛选", 15,
[
"点击「高级筛选」",
"断言可见所属医院、联系方式、科室、角色及「筛 选」",
],
"高级筛选含医院维度字段。",
auto="USER-UNI-007",
),
case(
9, "筛选与查询", "异常验证点", "P1", "无匹配空态(mock反例)", 21,
[
"姓名框填入 `__NOT_EXIST_USER__99999`,点击「查 询」",
"断言空态且分页总数为 0",
],
"无匹配不报错;总数为 0。",
auto="USER-UNI-008",
),
case(
10, "列表与分页", "功能验证点", "P0", "列表列顺序含所属医院", 24,
[
"断言列顺序为登录账号、姓名、联系方式、所属医院、所属科室、医院员工编号、角色、科室任职数、操作",
"勿用用户管理页列断言(用户管理无所属医院且姓名在登录账号前)",
],
"列顺序与用户管理页不同;含所属医院。",
auto="USER-UNI-009",
),
case(
11, "列表与分页", "功能验证点", "P1", "分页翻至第 2 页", 26,
[
"记录第 1 页首行登录账号",
"翻到第 2 页",
"断言数据更换且所属医院筛选仍保持",
],
"翻页成功;医院条件不丢失。",
pre="总条数大于一页,否则 skip", auto="USER-UNI-010",
),
case(
12, "列表与分页", "集成验证点", "P1", "与用户管理页同一用户字段一致", 33,
[
"记录本页首行 `${sampleLoginName}`、姓名、所属科室、角色",
"打开用户管理页,按登录账号查询同一用户",
"断言姓名、角色、所属科室一致;本页仅多「所属医院」",
],
"同一登录账号跨页字段一致。",
pre="两页都能查到同一账号,否则 skip", auto="USER-UNI-011",
),
case(
13, "新增与编辑", "功能验证点", "P0", "打开新增弹窗", 36,
[
"点击「新 增」",
"断言弹出用户表单,含账号、姓名、联系方式及取消/确定",
],
"新增弹窗可见。",
auto="USER-UNI-012",
),
case(
14, "新增与编辑", "功能验证点", "P1", "新增取消不落库", 38,
[
"打开新增,账号填 `${uniqLogin}` 后点「取 消」",
"当前医院下列表查询该账号,断言不存在",
],
"取消后当前医院列表不出现该账号。",
auto="USER-UNI-013",
),
case(
15, "新增与编辑", "异常验证点", "P0", "必填为空拦截", 46,
[
"打开新增,账号或姓名留空后点「确 定」",
"断言被拦截,弹窗仍打开,不落库",
],
"必填为空不可保存。",
auto="USER-UNI-014",
),
case(
16, "新增与编辑", "功能验证点", "P0", "打开编辑并回填", 39,
[
"点击首行「编辑」",
"断言弹窗回填与该行登录账号、姓名、所属医院一致",
"点「取 消」",
],
"编辑回填正确;本条不保存。",
pre="列表至少 1 条", auto="USER-UNI-015",
),
],
),
page(
code="USER-ROL",
prefix="ROL",
name="角色管理",
path="/spd/basic/user/platform/role",
scan="basic-role",
count=1170,
spec="UI-SPEC-SPD-USER-003",
spec_file="basic-role.spec.ts",
tp_total=45,
write_note=(
"权限树勾选后点「刷 新」恢复,不点「保 存」;"
"删除只验证确认框后取消;新增只打开后取消。"
"禁止删除预置角色或真实改名。"
),
nav_note="菜单「基础设置 → 用户管理 → 角色管理」;本页左右分栏、无独立分页。",
samples=(
"左栏读取 `${sampleRoleName}`(优先非预置自定义角色;预置用「医院管理员」若存在);"
"权限树按「模块名 + 菜单名」定位,根为「SPD内网系统PC端」;"
"mock:`__NOT_EXIST_ROLE__99999`。"
),
skip="真实保存权限树、真实新增/改名/删除角色、未保存切角色(待评审)、角色授权后换账号登录、接口失败",
cases=[
case(
1, "页面进入", "功能验证点", "P0", "菜单进入角色管理页", 1,
[
"按菜单「基础设置 → 用户管理 → 角色管理」进入,或 `openPath` 打开 `/spd/basic/user/platform/role`",
"断言 URL 含 `/spd/basic/user/platform/role`",
"断言左侧角色列表、右侧菜单权限树、「保 存」「刷 新」可见,无独立分页",
],
"进入角色管理;左右分栏渲染完成;不误入用户管理页。",
auto="USER-ROL-001",
),
case(
2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
[
"清除登录态后直接打开 `/spd/basic/user/platform/role`",
"断言跳转登录或不展示角色/权限树业务数据",
],
"未登录不可见角色配置。",
),
case(
3, "左侧角色列表", "功能验证点", "P0", "角色列表加载并可点选", 9,
[
"断言左栏列出角色名称(如医院管理员、护士等)",
"点击首个 `${sampleRoleName}`",
"断言该行处于选中态",
],
"左栏有角色且可点选。",
auto="USER-ROL-002",
),
case(
4, "左侧角色列表", "功能验证点", "P0", "角色名称搜索过滤", 10,
[
"在角色名称框输入 `${sampleRoleName}` 的部分关键字",
"断言左栏收敛为匹配项(即时或回车,以现网为准)",
],
"搜索结果角色名包含关键字。",
pre="左栏至少 1 个角色", auto="USER-ROL-003",
),
case(
5, "左侧角色列表", "功能验证点", "P0", "选中角色刷新右侧权限树", 11,
[
"依次点选两个不同角色",
"断言右侧勾选随当前角色变化,无上一角色残留",
],
"权限树与当前角色配置一致。",
pre="左栏至少 2 个角色", auto="USER-ROL-004",
),
case(
6, "左侧角色列表", "功能验证点", "P1", "清空搜索恢复全量", 12,
[
"先按关键字过滤,再清空搜索",
"断言左栏恢复未过滤的完整角色列表",
],
"清空后角色列表恢复全量。",
auto="USER-ROL-005",
),
case(
7, "左侧角色列表", "异常验证点", "P1", "搜索无匹配空态(mock反例)", 16,
[
"角色名称框填入 `__NOT_EXIST_ROLE__99999`",
"断言左栏空态明确",
],
"无匹配不报错;空态明确。",
auto="USER-ROL-006",
),
case(
8, "右侧菜单权限树", "功能验证点", "P0", "权限树按应用与模块展开", 18,
[
"选中任一角色",
"断言右侧树根为「SPD内网系统PC端」,可见基础设置/运维管理/厂商管理/产品管理等模块",
"展开「基础设置」,断言可见子菜单(如医院资料)",
],
"权限树层级可见;节点可展开。",
auto="USER-ROL-007",
),
case(
9, "右侧菜单权限树", "功能验证点", "P0", "勾选父节点联动子节点", 19,
[
"选中一自定义或可编辑角色(预置被锁则 skip)",
"记录「厂商管理」父节点原勾选",
"勾选「厂商管理」父节点",
"断言其子菜单/功能全部勾选",
"点击「刷 新」,不点「保 存」",
],
"父节点勾选联动子节点;本条不保存。",
auto="USER-ROL-008",
),
case(
10, "右侧菜单权限树", "功能验证点", "P1", "刷新恢复未保存勾选", 23,
[
"改一处勾选后点「刷 新」且未保存",
"断言树恢复为进入该角色时的已保存状态",
],
"刷新丢弃未保存勾选。",
auto="USER-ROL-009",
),
case(
11, "角色增删改", "功能验证点", "P0", "删除需确认且取消保留", 34,
[
"点一自定义角色的删除入口(预置角色无删除则改点自定义;都没有则 skip)",
"断言出现确认框,含取消/确定",
"点「取 消」",
"断言该角色仍在左栏",
],
"删除需二次确认;取消后角色仍在。本条不点确定。",
auto="USER-ROL-010",
),
case(
12, "角色增删改", "功能验证点", "P1", "新增角色入口打开后取消", 32,
[
"通过新增或「角色」工具按钮打开新增表单(扫描未打开则 skip)",
"不填或填写后点取消/关闭",
"断言左栏角色数量不增加",
],
"新增入口可达;取消不落库。",
auto="USER-ROL-011",
),
case(
13, "角色增删改", "异常验证点", "P1", "预置角色不可删除", 41,
[
"在左栏定位「医院管理员」(或其它预置角色,以现网为准)",
"断言删除入口不可见;若可见则确定后被拒绝,角色仍在左栏",
"若弹出确认则点取消,避免误删",
],
"预置角色不可删除。",
pre="左栏存在可识别的预置角色,否则 skip", auto="USER-ROL-012",
),
case(
14, "左侧角色列表", "集成验证点", "P1", "角色名与用户管理角色下拉同源", 17,
[
"记录本页左栏角色名称集合",
"打开用户管理,展开「角色」筛选项",
"断言两边名称集合一致(允许按权限裁剪,本页有的名称用户页应能对应到)",
],
"角色主数据跨页一致,不串名。",
auto="USER-ROL-013",
),
],
),
]
def tp_id(p: dict, c: dict) -> str:
return f"TP_USER_{p['prefix']}_{c['tp']:03d}"
def prio_csv(prio: str) -> str:
return {"P0": "高", "P1": "中", "P2": "低"}[prio]
def is_unlogin(c: dict) -> bool:
return "未登录" in c["title"]
def render_csv_rows(p: dict) -> list[dict]:
logged = (
f"测试环境可访问;正例账号 {POSITIVE_USER} 已通过 auth.setup 写入 storageState 登录"
f"(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → 用户管理 → {p['name']}」权限"
)
unlogin = (
f"测试环境可访问;使用独立 context,storageState 为空(不加载 {POSITIVE_USER} 登录态)"
)
rows: list[dict] = []
for c in p["cases"]:
case_id = f"{p['code']}-{c['seq']:03d}"
if is_unlogin(c):
pre_parts = [unlogin]
if c["pre"]:
pre_parts.append(c["pre"])
steps = "\n".join(
["1. 新建独立 BrowserContext,不加载 auth/user.json"]
+ [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)]
)
else:
pre_parts = [logged]
if c["seq"] > 1:
pre_parts.append(f"已进入{p['name']}页({p['path']},无 iframe)")
if c["pre"]:
pre_parts.append(c["pre"])
steps = "\n".join(
[
f"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 {POSITIVE_USER}),无需在用例内重复登录"
]
+ [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)]
)
rows.append(
{
"用例编号": case_id,
"功能模块": "基础设置 · 用户管理",
"功能测试点": f"{p['name']} · {c['h3']}",
"用例标题": c["title"],
"优先级": prio_csv(c["prio"]),
"前置条件": ";".join(pre_parts),
"测试步骤": steps,
"预期结果": c["expect"],
"实际结果": "",
}
)
return rows
def render_md(p: dict) -> str:
n = len(p["cases"])
cnt = Counter(c["prio"] for c in p["cases"])
prio_txt = " / ".join(f"{k}={cnt[k]}" for k in ("P0", "P1", "P2") if cnt[k])
lines = [
f"# 基础管理-用户管理-{p['name']} UI 自动化测试用例",
"",
f"**来源测试点**:`testpoint/基础管理-用户管理-{p['name']}-测试点分析-01.md` ",
f"**执行 CSV**:`testcase/基础管理-用户管理-{p['name']}-UI自动化测试用例-01.csv` ",
f"**页面 URL**:`{p['path']}` ",
f"**定位器 YAML**:`locators/spd/{p['scan']}.locator.yaml` ",
f"**元素扫描**:`data/scan/{p['scan']}-elements.json`({p['count']} 项) ",
f"**关联规范**:`knowledge-base/08-UI专项规范库/{p['spec']}.md` ",
f"**版本**:{TODAY} ",
f"**自动化框架**:Playwright + TypeScript(规划 `tests/spd/{p['spec_file']}`,依赖 `auth.setup` 登录态)",
"",
f"**通用前置**:正例账号 `{POSITIVE_USER}` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → 用户管理 → {p['name']}」权限。反例筛选用 mock(`__NOT_EXIST_USER__99999` / `__NOT_EXIST_ROLE__99999`)。 ",
"**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 ",
f"**菜单导航**:{p['nav_note']} ",
f"**写操作**:{p['write_note']} ",
f"**动态取数**:{p['samples']}",
"",
"---",
"",
"## 基础设置 · 用户管理",
"",
]
current_h3 = ""
current_h4 = ""
for c in p["cases"]:
h3 = f"{p['name']} · {c['h3']}"
if h3 != current_h3:
current_h3 = h3
current_h4 = ""
lines += [f"### {h3}", ""]
if c["h4"] != current_h4:
current_h4 = c["h4"]
lines += [f"#### {current_h4}", ""]
case_id = f"{p['code']}-{c['seq']:03d}"
tc = f"TC-UI-{case_id}"
lines += [
f"##### 【{c['prio']}】【{tc}】【{tp_id(p, c)}】{c['title']}",
"",
f"- 前置条件:{c['pre'] or ('已进入' + p['name'] + '页')}",
"- 操作步骤:",
]
for i, step in enumerate(c["steps"], 1):
lines.append(f" {i}. {step}")
lines.append(f"- 自动化断言:规划与 CSV `{case_id}` 及 `tests/spd/{p['spec_file']}` 一致")
lines += ["", "###### 预期结果", "", c["expect"], "", "---", ""]
while lines and lines[-1] in ("", "---"):
lines.pop()
lines += [
"",
"",
"## 质量与覆盖说明",
"",
"| 项 | 说明 |",
"|----|------|",
f"| 用例数 | **{n}** 条({prio_txt}) |",
f"| 来源 | 从 {p['tp_total']} 条测试点选取可稳定自动化的主路径与关键反向 |",
f"| 未纳入自动化 | {p['skip']} |",
"| 单一职责 | 每条只断言一个核心业务结果 |",
"",
]
return "\n".join(lines)
def main() -> None:
MD_DIR.mkdir(parents=True, exist_ok=True)
CSV_DIR.mkdir(parents=True, exist_ok=True)
total = 0
for p in PAGES:
seqs = [c["seq"] for c in p["cases"]]
if seqs != list(range(1, len(seqs) + 1)):
raise SystemExit(f"{p['code']} seq not consecutive: {seqs}")
md_path = MD_DIR / f"基础管理-用户管理-{p['name']}-UI自动化测试用例-01.md"
csv_path = CSV_DIR / f"基础管理-用户管理-{p['name']}-UI自动化测试用例-01.csv"
md_path.write_text(render_md(p), encoding="utf-8")
rows = render_csv_rows(p)
with csv_path.open("w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=CSV_FIELDS)
w.writeheader()
w.writerows(rows)
total += len(rows)
print(f"OK {p['code']}\t{len(rows):2d} cases\t{md_path.name}\t{csv_path.name}")
print(f"done pages={len(PAGES)} cases={total}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Generate 用户管理 testpoint MD and patch UI-SPEC scan paths from data/scan JSON."""
from __future__ import annotations
import json
import re
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCAN_DIR = ROOT / "data" / "scan"
OUT_DIR = ROOT / "testpoint"
KB = ROOT / "knowledge-base" / "08-UI专项规范库"
INDEX = KB / "INDEX.md"
TODAY = date.today().isoformat()
PAGES = [
{
"pageKey": "basic-user",
"specId": "USER-001",
"name": "用户管理",
"prefix": "TP_USER_USR",
"file": "基础管理-用户管理-用户管理-测试点分析-01.md",
"kind": "user",
"path": "/spd/basic/user/platform/person",
},
{
"pageKey": "basic-user-union",
"specId": "USER-002",
"name": "医共体用户管理",
"prefix": "TP_USER_UNI",
"file": "基础管理-用户管理-医共体用户管理-测试点分析-01.md",
"kind": "union",
"path": "/spd/basic/user/platform/personCom",
},
{
"pageKey": "basic-role",
"specId": "USER-003",
"name": "角色管理",
"prefix": "TP_USER_ROL",
"file": "基础管理-用户管理-角色管理-测试点分析-01.md",
"kind": "role",
"path": "/spd/basic/user/platform/role",
},
]
def load_scan(page_key: str) -> dict:
path = SCAN_DIR / f"{page_key}-elements.json"
data = json.loads(path.read_text(encoding="utf-8"))
return {"url": data["url"].split("?")[0], "elementCount": data["elementCount"]}
def tp(prefix: str, n: int, prio: str, title: str, check: str, spec: str) -> str:
return f"- **{prefix}_{n:03d}**({prio})|{title}|验证要点:{check}|关联需求:{spec}"
def section(title: str, blocks: list[tuple[str, list[str]]]) -> str:
parts = [f"### {title}", ""]
for h4, lines in blocks:
parts.append(f"#### {h4}")
parts.append("")
parts.extend(lines or ["- 无(本功能点无独立该类场景,规则已归入其它功能点)"])
parts.append("")
return "\n".join(parts)
class Seq:
def __init__(self, prefix: str, spec: str):
self.prefix = prefix
self.spec = spec
self.n = 0
def add(self, prio: str, title: str, check: str) -> str:
self.n += 1
return tp(self.prefix, self.n, prio, title, check, self.spec)
def nav_section(p: dict, url: str, seq: Seq) -> str:
name = p["name"]
path = p["path"]
kind = p["kind"]
peers = "用户管理 / 医共体用户管理 / 角色管理"
nav_note = (
"一级菜单与叶子菜单均名「用户管理」,须点「基础设置 → 用户管理(子菜单)→ 用户管理(叶子)」进入 `/person`,"
"不误入医共体用户管理或角色管理"
if kind == "user"
else f"登录后通过「基础设置 → 用户管理 → {name}」进入目标页,左侧菜单与顶栏 Tab 高亮为{name},不串到同组其它页"
)
if kind == "role":
ready = "左右分栏渲染:左侧角色列表与右侧菜单权限树可见,「保存」「刷新」可见,无独立分页,无报错遮罩"
ctx = "当前登录人可维护角色与权限树,与顶栏登录身份不矛盾;本页无所属医院维度"
elif kind == "union":
ready = "整页单表渲染:所属医院等筛选、工具栏、列表列头与分页可见,无报错遮罩"
ctx = "顶栏院区/登录医院与本页可管理的「所属医院」范围一致"
else:
ready = "整页单表渲染:筛选区、工具栏(新增/Excel导入)、列表列头与分页可见,无报错遮罩"
ctx = "顶栏院区、登录用户与本页可管理用户范围一致"
return section(
"页面访问与导航",
[
(
"功能验证点",
[
seq.add("高", f"菜单路径可达{name}", nav_note),
seq.add("高", "页面主区加载完成", ready),
seq.add(
"中",
"同组用户管理菜单可切换",
f"在 {peers} 间切换后 URL 与页面内容对应,再返回{name}状态正确",
),
],
),
(
"边界验证点",
[
seq.add("低", "已登录直接打开 URL", f"携带有效登录态打开 `{path}`,页面正常渲染"),
],
),
(
"异常验证点",
[
seq.add("高", "未登录访问拦截", "无登录态访问 URL 跳转登录或不展示用户/角色业务数据"),
seq.add("中", "登录态过期", "会话失效后查询/写操作被拦截并提示重新登录,不误写"),
seq.add("中", "无菜单权限", f"无{name}权限的账号不可见菜单或 URL 被拒绝"),
],
),
(
"集成验证点",
[
seq.add("中", "顶栏身份与可管理范围一致", ctx),
],
),
],
)
def user_body(seq: Seq) -> str:
chunks = [
section(
"筛选与查询",
[
(
"功能验证点",
[
seq.add("高", "默认条件查询", "进入后点「查询」,列表有数据或空态明确,底部分页「共 N 条」与行数逻辑一致"),
seq.add("高", "姓名/登录账号关键字", "在「姓名」框(占位「姓名、登录账号」)输入关键字后,结果姓名或登录账号匹配"),
seq.add("高", "科室下拉筛选", "选择某科室后列表「所属科室」与所选一致(未分配显示 `--` 的行不应出现在指定科室结果中)"),
seq.add("高", "角色下拉筛选", "选择某角色后列表「角色」列包含该角色"),
seq.add("高", "姓名+科室+角色组合查询", "多个已填条件同时生效,结果同时满足全部条件"),
seq.add("中", "重置恢复默认", "填写筛选后点「重置」,条件恢复默认,列表按默认规则刷新"),
seq.add("中", "展开高级筛选", "点「高级筛选」展开面板,可见姓名/科室/角色/状态及底部「重置」「筛选」"),
seq.add("高", "高级筛选按状态过滤", "高级筛选选择启用或禁用后点「筛选」,列表仅保留对应状态用户"),
seq.add("中", "高级筛选内重置", "高级筛选填写后点面板「重置」,条件清空,列表恢复默认"),
],
),
(
"边界验证点",
[
seq.add("中", "超长姓名/账号关键字", "输入达到或超过允许长度的关键字,查询被接受并按规则截断或返回匹配,页面不崩溃"),
seq.add("低", "仅空格关键字", "按空查询处理或提示,不报错"),
seq.add("低", "科室/角色下拉可搜索", "下拉内输入关键字后选项收敛为匹配项"),
],
),
(
"异常验证点",
[
seq.add("中", "查询接口失败", "列表查询失败时有错误提示,不白屏、不把过期数据展示为成功态"),
seq.add("低", "无匹配结果空态", "条件无匹配时「暂无数据」明确,分页总数为 0"),
],
),
(
"集成验证点",
[
seq.add("中", "筛选结果与分页总数一致", "查询后可见行逻辑与「共 N 条」一致"),
seq.add("高", "角色下拉与角色管理同源", "本页角色筛选项集合与「角色管理」左侧角色名称一致(允许按权限裁剪)"),
],
),
],
),
section(
"列表与分页",
[
(
"功能验证点",
[
seq.add(
"高",
"列表核心列完整",
"主表展示序号、姓名、登录账号、联系方式、所属科室、医院员工编号、角色、科室任职数、操作(编辑/授权/重置密码)",
),
seq.add("中", "列表不含所属医院列", "本页列中无「所属医院」,与医共体用户管理页区分"),
seq.add("中", "多角色列表截断展示", "用户绑定多个角色时列表「角色」列截断(如带省略号)"),
seq.add("中", "空值展示为 --", "未分配科室、未填工号、无任职时对应列显示 `--`"),
seq.add("中", "表格密度切换", "密度切换后列表仍可读,列不丢失"),
seq.add("低", "无批量操作", "序号列可勾选,但工具栏无批量启用/删除/授权;按「无批量」处理,不测批量提交"),
seq.add("中", "分页翻页", "总条数大于一页时可翻至第 2 页,数据更换且页码高亮正确"),
seq.add("中", "每页条数切换", "切换「50 条/页」等后当前页行数不超过所选值,总数不变"),
seq.add("中", "跳至指定页", "分页「跳至」输入合法页码后跳到对应页"),
],
),
(
"边界验证点",
[
seq.add("中", "首页上一页不可用", "第 1 页时「上一页」禁用或点击无效"),
seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"),
seq.add("低", "跳至超出总页数", "跳至大于总页数的页码时被拒绝、纠正到末页或给出提示"),
],
),
(
"异常验证点",
[
seq.add("低", "跳至非数字页码", "跳至输入非法字符时不跳转异常页,输入被忽略或提示"),
],
),
(
"集成验证点",
[
seq.add("中", "翻页不丢失当前筛选", "带筛选条件翻页后条件保持,新页数据仍满足原筛选"),
seq.add("中", "科室任职数与任职记录一致", "「科室任职数」等于该用户在各科室任职条数,无任职为 `--`"),
seq.add("中", "任职数展示或可下钻", "任职数为只读数字;若可点开明细,则明细条数与数字一致"),
],
),
],
),
section(
"新增用户",
[
(
"功能验证点",
[
seq.add("高", "打开新增用户弹窗", "点「新增」弹出新增/编辑同类表单,含账号、姓名、联系方式、医院员工编号、启用/禁用、取消/确定"),
seq.add("高", "合法必填保存成功", "填写必填后确定,弹窗关闭,列表出现新记录且字段与提交一致"),
seq.add("中", "取消不落库", "填写后点「取消」或关闭,列表不新增对应账号"),
seq.add("中", "Excel 导入入口可达", "工具栏「Excel导入」可打开导入流程或选择文件"),
],
),
(
"边界验证点",
[
seq.add("中", "字段达到允许最大长度", "账号/姓名取最大允许长度可保存成功"),
seq.add("中", "联系方式合法号码", "填写合法手机号/电话可保存,列表联系方式与提交一致"),
],
),
(
"异常验证点",
[
seq.add("高", "必填为空拦截", "账号或姓名为空点确定被拦截并提示,不关闭弹窗、不落库"),
seq.add("高", "登录账号重复拦截", "使用已存在账号保存失败并提示,不覆盖原用户"),
seq.add("中", "联系方式非法拦截", "明显非法号码(过短数字)点确定被拦截并提示,不落库"),
seq.add("中", "保存接口失败", "失败有错误提示,弹窗仍在,列表不出现半成品成功态"),
seq.add("中", "重复点击确定", "连续快速点击只产生一条记录或第二次被防重"),
seq.add("中", "Excel 导入失败有提示", "导入文件失败或模板不符时有错误提示,不误导为成功"),
],
),
(
"集成验证点",
[
seq.add("中", "新用户可出现在医共体用户管理", "本页新增成功后,医共体用户管理按所属医院能查到同一登录账号(若业务归属医共体)"),
],
),
],
),
section(
"编辑用户",
[
(
"功能验证点",
[
seq.add("高", "打开编辑用户弹窗并回填", "行内「编辑」打开「编辑用户」,回填账号、姓名、联系方式、医院员工编号、启用/禁用"),
seq.add("中", "编辑页可查看全部角色", "多角色用户在编辑弹窗能看到完整角色列表,不被列表截断值替代"),
seq.add("中", "签名图片可上传或展示", "编辑弹窗含签名图片,已有签名可展示,可重新上传后随确定保存"),
seq.add("高", "修改后确定落库", "修改姓名或联系方式后确定,列表与再次编辑回看均为新值"),
seq.add("中", "编辑取消不保存", "修改后点「取消」,列表与再次打开仍为修改前值"),
seq.add("高", "启用/禁用切换", "在编辑弹窗将状态改为禁用或启用并确定后,高级筛选按新状态能查到该用户"),
],
),
(
"边界验证点",
[
seq.add("低", "不改字段直接确定", "打开编辑不改内容点确定,记录保持原值且不报错"),
seq.add("中", "编辑时账号只读", "编辑弹窗中登录账号不可改"),
seq.add("低", "不传签名可保存", "签名非必填时不上传仍可确定成功"),
],
),
(
"异常验证点",
[
seq.add("高", "编辑清空必填拦截", "清空姓名后确定被拦截,原记录不被写成空"),
seq.add("中", "编辑已删除用户", "记录已被删除后再确定,提示不存在,不产生幽灵更新"),
seq.add("中", "签名非图片格式拦截", "上传非图片或超限文件时拒绝保存并提示"),
],
),
(
"集成验证点",
[
seq.add("高", "禁用后登录受限", "用户禁用后使用该账号登录被拒绝或权限收回"),
seq.add("中", "再启用保持原授权", "禁用后再启用,菜单授权仍为禁用前已保存的勾选,不额外膨胀、不自动清空"),
],
),
],
),
section(
"授权",
[
(
"功能验证点",
[
seq.add("高", "打开授权", "行内「授权」打开授权层(弹窗或权限树),展示可勾选菜单/功能"),
seq.add("高", "保存后勾选持久化", "勾选后保存,关闭再打开同一用户,勾选与保存时一致"),
seq.add("中", "取消不生效", "勾选后点取消/关闭,再次打开仍为操作前勾选"),
],
),
(
"边界验证点",
[
seq.add("中", "未勾选任何权限可保存", "全部取消勾选后仍可保存成功,再次打开为全不勾选"),
],
),
(
"异常验证点",
[
seq.add("中", "授权保存失败", "接口失败有提示,再次打开不是半成功勾选"),
],
),
(
"集成验证点",
[
seq.add("高", "授权后仅可见已授权菜单", "保存后该用户登录仅能看到已授权菜单"),
seq.add("中", "授权节点与角色权限树语义一致", "用户授权层菜单节点名称与角色管理权限树对应节点一致"),
],
),
],
),
section(
"重置密码",
[
(
"功能验证点",
[
seq.add("高", "行内重置密码", "点「重置密码」出现确认或密码表单,确认后该用户密码被更新"),
seq.add("中", "编辑弹窗内重置密码", "「编辑用户」内「重置密码」与行内重置效果一致"),
seq.add("中", "取消不改密", "确认框点取消后原密码仍有效"),
],
),
(
"边界验证点",
[
seq.add("低", "对已禁用用户可重置", "禁用用户允许重置密码,重置后账号仍为禁用、不误启用"),
],
),
(
"异常验证点",
[
seq.add("中", "重置接口失败", "失败有提示,原密码仍可登录"),
seq.add("中", "无二次确认不得改密", "重置须经确认框;直接关闭或点取消不得改密"),
],
),
(
"集成验证点",
[
seq.add("高", "重置后原密码不可登录", "重置成功后原密码登录失败,新密码(或初始密码规则)可登录"),
],
),
],
),
]
return "\n---\n\n".join(chunks)
def union_body(seq: Seq) -> str:
chunks = [
section(
"筛选与查询",
[
(
"功能验证点",
[
seq.add("高", "默认条件查询", "进入后点「查询」,列表有数据或空态明确,「共 N 条」与行数逻辑一致"),
seq.add("高", "姓名/登录账号关键字", "「姓名」框(占位「姓名、登录账号」)输入后结果姓名或账号匹配"),
seq.add("高", "所属医院筛选", "切换「所属医院」后列表仅该院用户,「共 N 条」同步变化"),
seq.add("高", "联系方式筛选", "按「联系方式」关键字查询,结果联系方式匹配"),
seq.add("高", "姓名+医院+联系方式组合查询", "多条件同时生效,结果同时满足"),
seq.add("中", "重置恢复默认", "点「重置」后医院等条件恢复默认(含默认医院),列表刷新"),
seq.add("中", "展开高级筛选", "点「高级筛选」展开后可见所属医院、联系方式、科室、角色及「筛选」"),
seq.add("中", "高级筛选按科室过滤", "高级筛选选择科室后点「筛选」,列表「所属科室」与所选一致"),
seq.add("中", "高级筛选按角色过滤", "高级筛选选择角色后点「筛选」,列表「角色」列包含该角色"),
],
),
(
"边界验证点",
[
seq.add("中", "超长关键字", "超长姓名或联系方式关键字不崩溃,按规则截断或无匹配空态"),
seq.add("低", "仅空格联系方式", "空格按空查询处理或提示,不报错"),
],
),
(
"异常验证点",
[
seq.add("中", "查询接口失败", "失败有提示,不白屏"),
seq.add("低", "无匹配空态", "总数为 0,空态明确"),
],
),
(
"集成验证点",
[
seq.add("中", "筛选结果与分页总数一致", "当前医院+条件下可见行逻辑与「共 N 条」一致"),
seq.add("高", "默认医院与顶栏/登录医院一致", "进入页时所属医院默认值与当前登录医院或顶栏院区约定一致"),
],
),
],
),
section(
"列表与分页",
[
(
"功能验证点",
[
seq.add(
"高",
"列表核心列完整且顺序正确",
"列顺序为登录账号、姓名、联系方式、所属医院、所属科室、医院员工编号、角色、科室任职数、操作(编辑/授权/重置密码);勿用用户管理页列断言",
),
seq.add("中", "登录账号列截断", "长账号在列宽内截断;按账号搜索/断言用筛选框而非截断后的单元格文本"),
seq.add("中", "分页翻页", "总条数大于一页时可翻至第 2 页,数据更换且页码高亮正确"),
seq.add("中", "每页条数切换", "切换每页条数后当前页行数不超过所选值,总数不变"),
seq.add("中", "跳至指定页", "分页「跳至」输入合法页码后跳到对应页"),
],
),
(
"边界验证点",
[
seq.add("中", "首页上一页不可用", "第 1 页「上一页」禁用或无效"),
seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"),
seq.add("低", "某医院无用户", "切换到无用户医院时空态明确,总数为 0"),
],
),
(
"异常验证点",
[
seq.add("低", "跳至非数字页码", "非法页码不跳转异常页"),
],
),
(
"集成验证点",
[
seq.add(
"高",
"与用户管理页同一用户字段一致",
"同一登录账号在两页的姓名、角色、所属科室一致,本页仅多「所属医院」维度",
),
seq.add("中", "翻页不丢失医院条件", "带所属医院筛选翻页后条件保持"),
seq.add("中", "科室任职数规则同用户管理", "本页「科室任职数」判定同 TP_USER_USR 任职数与任职记录一致,无任职为 `--`"),
],
),
],
),
section(
"新增与编辑",
[
(
"功能验证点",
[
seq.add("高", "打开新增弹窗", "点「新增」弹出用户表单,含账号、姓名、联系方式等及取消/确定"),
seq.add("高", "合法保存落在当前医院", "保存成功后列表在当前「所属医院」下可见该账号"),
seq.add("中", "新增取消不落库", "填写后点取消,当前医院列表不出现该账号"),
seq.add("高", "打开编辑并回填", "行内「编辑」打开「编辑用户」,回填与列表行一致"),
seq.add("高", "修改后确定落库", "修改后确定,本页列表与再次编辑回看均为新值"),
seq.add("中", "编辑取消不保存", "修改后点取消,列表与再次打开仍为修改前值"),
seq.add("中", "启用禁用规则同用户管理", "编辑弹窗启用/禁用后,按状态筛选能命中;禁用登录受限同 TP_USER_USR"),
seq.add("中", "签名规则同用户管理", "编辑弹窗签名上传/展示规则同 TP_USER_USR,本页不重复展开"),
seq.add("中", "Excel 导入入口可达", "「Excel导入」可打开导入流程"),
],
),
(
"边界验证点",
[
seq.add("中", "字段最大长度", "账号/姓名最大长度可保存"),
],
),
(
"异常验证点",
[
seq.add("高", "必填为空拦截", "账号或姓名为空确定被拦截,不落库"),
seq.add("高", "跨院重复账号不静默覆盖", "使用他院已占用登录账号保存时拒绝并提示,不覆盖他院用户"),
seq.add("中", "Excel 导入失败有提示", "导入文件失败或模板不符时有错误提示,不误导为成功"),
],
),
(
"集成验证点",
[
seq.add("中", "本页编辑结果回写用户管理", "在医共体页修改姓名/联系方式后,用户管理页同一账号字段一致"),
seq.add("中", "Excel 导入归属当前医院", "导入成功的数据出现在当前「所属医院」列表,不写入其它院"),
],
),
],
),
section(
"授权与重置密码",
[
(
"功能验证点",
[
seq.add("高", "行内授权可打开", "点「授权」打开授权层;勾选持久化规则同 TP_USER_USR,本页只校验当前行"),
seq.add("高", "行内重置密码可执行", "点「重置密码」可完成改密;改密后登录规则同 TP_USER_USR"),
],
),
(
"边界验证点",
[
seq.add("低", "对他院用户写操作不可用", "筛选他院后,对非当前医院用户的编辑/授权/重置入口不可见或拒绝"),
],
),
(
"异常验证点",
[
seq.add("中", "授权保存失败", "接口失败有提示,再次打开不是半成功勾选"),
seq.add("中", "重置密码失败", "接口失败有提示,原密码仍可登录"),
],
),
(
"集成验证点",
[
seq.add("高", "编辑不串改他院同名用户", "保存编辑后仅当前所属医院下该账号变更,其它院区同名用户不变"),
seq.add("高", "授权不串改他院账号", "授权只作用于当前行登录账号+所属医院,其它院区同账号勾选不变"),
seq.add("高", "重置密码不串改他院账号", "重置密码只改当前行,其它院区同账号原密码仍有效"),
],
),
],
),
]
return "\n---\n\n".join(chunks)
def role_body(seq: Seq) -> str:
chunks = [
section(
"左侧角色列表",
[
(
"功能验证点",
[
seq.add("高", "角色列表加载", "左侧列出角色名称(如医院管理员、中心库库管、护士等),可点选"),
seq.add("高", "角色名称搜索过滤", "在角色名称框输入关键字后列表收敛为匹配项(即时或回车,以现网为准)"),
seq.add("高", "选中角色刷新右侧权限树", "点击某角色后右侧菜单权限勾选刷新为该角色配置,无上一角色残留"),
seq.add("中", "清空搜索恢复全量", "清空角色搜索后列表恢复未过滤的完整角色"),
],
),
(
"边界验证点",
[
seq.add("低", "搜索超长关键字", "超长不崩溃,无匹配时左栏空态明确"),
seq.add("低", "仅空格搜索", "按空处理或提示,不报错"),
],
),
(
"异常验证点",
[
seq.add("中", "角色列表加载失败", "失败有提示,不白屏"),
seq.add("低", "无匹配角色空态", "搜索无结果时空态明确"),
],
),
(
"集成验证点",
[
seq.add("高", "角色名与用户管理角色下拉同源", "左栏角色名称集合与用户管理/医共体用户管理「角色」筛选项一致(允许按权限裁剪)"),
],
),
],
),
section(
"右侧菜单权限树",
[
(
"功能验证点",
[
seq.add(
"高",
"权限树按应用与模块展开",
"右侧为「菜单权限」树,根为「SPD内网系统PC端」,下含基础设置/运维管理/厂商管理/产品管理等模块;节点可展开/折叠,展开后可见子菜单",
),
seq.add("高", "勾选父节点联动子节点", "勾选某模块父节点后其子菜单/功能全部勾选"),
seq.add("高", "取消父节点联动取消子节点", "取消父节点后子节点全部取消"),
seq.add("中", "部分勾选呈半选态", "只勾选部分子节点时父节点为半选"),
seq.add("高", "保存授权持久化", "点「保存」成功后刷新或重新进入,该角色勾选与保存时一致"),
seq.add("中", "刷新恢复未保存勾选", "改勾选后点「刷新」且未保存,树恢复为上次已保存状态"),
],
),
(
"边界验证点",
[
seq.add("中", "同名菜单按层级定位", "不同模块下同名菜单(若有)勾选互不影响,须按「模块名 + 菜单名」判定"),
seq.add("低", "树节点很多时可滚动勾选", "长树滚动后勾选仍对应正确节点,不点错行"),
],
),
(
"异常验证点",
[
seq.add("中", "未选角色直接保存", "未选中角色时保存被拦截或提示,不把勾选写到错误角色"),
seq.add(
"高",
"未保存勾选时切换角色",
"改勾选未保存即点另一角色:须提示未保存;确认后丢弃未保存勾选并加载新角色,不把未保存勾选静默写到原角色或新角色",
),
seq.add("中", "保存接口失败", "失败有提示,再次进入不是半成功勾选"),
seq.add("中", "权限树加载失败", "失败有提示,左栏角色仍可选"),
],
),
(
"集成验证点",
[
seq.add(
"高",
"角色授权后用户可见对应菜单",
"为角色勾选「厂商查询」等菜单并保存后,仅绑定该角色的用户登录后能看到对应菜单",
),
seq.add("高", "取消角色菜单后用户收回", "取消该角色已勾选菜单并保存后,仅绑定该角色的用户登录不再看到该菜单"),
],
),
],
),
section(
"角色增删改",
[
(
"功能验证点",
[
seq.add("中", "新增角色入口可达", "通过新增/「角色」工具按钮打开新增表单,可填写角色名称并保存,左栏出现新角色"),
seq.add("高", "编辑角色名称", "行内编辑可改角色名称,保存后左栏显示新名"),
seq.add("高", "删除需二次确认", "点删除出现确认框,含取消/确定"),
seq.add("高", "确认删除后左栏移除", "确认后该角色从左栏消失,再搜索名称无该角色"),
seq.add("中", "取消删除保留角色", "确认框点取消后角色仍在,权限树勾选不变"),
],
),
(
"边界验证点",
[
seq.add("中", "角色名最大长度", "达到允许最大长度可保存"),
seq.add("低", "删除列表中最后一个自定义角色", "删除后左栏不报错,权限树进入空态或不可保存"),
],
),
(
"异常验证点",
[
seq.add("高", "角色名称为空拦截", "空名称保存被拦截并提示"),
seq.add("高", "角色名称重复拦截", "与已有角色重名保存失败,不覆盖"),
seq.add("高", "预置角色不可删除", "医院管理员等预置角色删除入口不可见或确定后拒绝,角色仍在左栏"),
seq.add("高", "删除仍被用户引用的角色", "若有用户绑定该自定义角色则拒绝删除或提示先解绑,不出现孤儿授权"),
],
),
(
"集成验证点",
[
seq.add("高", "新增后用户页角色下拉出现", "新增角色保存后,用户管理/医共体用户管理「角色」筛选项出现该名称"),
seq.add("高", "改名后用户页角色文案同步", "改名保存后,用户页下拉与已绑用户行「角色」列为新名"),
seq.add("高", "删除后用户页无已删角色残留", "删除自定义角色后,用户页下拉与行「角色」列不再出现该名称"),
],
),
],
),
]
return "\n---\n\n".join(chunks)
def quality(p: dict, count: int, tp_count: int) -> str:
kind = p["kind"]
if kind == "user":
cover = "导航、筛选查询、列表分页、新增/编辑(含签名与启用禁用)、授权、重置密码、权限与登录异常、同模块衔接"
pending = (
"① 新增弹窗扫描未稳定打开,字段以编辑弹窗与 UI-SPEC 为准 "
"② 授权层扫描未采集到,勾选控件以现网为准 "
"③ 编辑时登录账号按只读写点,若现网可改需改判定 "
"④ 空权限按允许保存写点,联系方式非法/签名非图片按拦截写点,禁用用户重置按允许且不启用写点 "
"⑤ UI-SPEC 截图未见「查询」,现网扫描有「查询」,测试点以扫描为准 "
"⑥ 列表序号可勾选但无批量工具栏,按无批量处理"
)
elif kind == "union":
cover = "导航、所属医院筛选、列顺序差异、跨院不覆盖/不串改;启用禁用/签名/任职/授权持久化引用 TP_USER_USR,本页不重复展开"
pending = (
"① 新增/授权弹窗扫描未稳定打开,以现网为准 "
"② 跨院登录账号按拒绝且不覆盖写点,若产品允许共用需改判定 "
"③ 所属医院默认值与顶栏约定以现网为准 "
"④ 签名/启用禁用/任职明细/改密后登录同用户管理对应 TP,本页只保留医院维度"
)
else:
cover = "导航(左右分栏无分页)、左栏角色搜索、右栏权限树勾选联动/保存/未保存切角色、角色增删改与用户页下拉同步"
pending = (
"① 新增角色弹窗扫描未打开,入口以现网工具栏/行内图标为准,对应点优先级为中 "
"② 预置角色按不可删除写点,以现网预置名单为准 "
"③ 未保存切角色按「提示后丢弃、不静默写入」写点,若自动保存需改判定 "
"④ 本页无授权弹窗、无分页、无所属医院"
)
return f"""## 质量自检摘要
| 项 | 结论 |
|----|------|
| 覆盖 | 已覆盖:{cover};共 **{tp_count}** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) |
| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 |
| 去重 | 登录拦截、会话过期与同模块其它用户管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `{p["prefix"]}` |
| 待评审项 | {pending} |
| 下游 | 可转结构化 MD/CSV;扫描 `npm run discover:scan-user`;自动化建议 `pages/spd/{p["pageKey"]}.page.ts` |
"""
def render_testpoint(p: dict, scan: dict) -> tuple[str, int]:
spec = f"UI-SPEC-SPD-{p['specId']}"
seq = Seq(p["prefix"], spec)
nav = nav_section(p, scan["url"], seq)
if p["kind"] == "user":
body = user_body(seq)
elif p["kind"] == "union":
body = union_body(seq)
else:
body = role_body(seq)
md = f"""# 基础管理-用户管理-{p['name']} — 测试点分析
**关联需求**:`knowledge-base/08-UI专项规范库/{spec}.md`
**页面 URL**:`{scan["url"]}`
**版本 / 定稿日期**:{TODAY}
**元素扫描**:`data/scan/{p["pageKey"]}-elements.json`({scan["elementCount"]} 项)
**定位器**:`locators/spd/{p["pageKey"]}.locator.yaml`
---
## 基础设置 · 用户管理 · {p['name']}
{nav}
---
{body}
---
{quality(p, scan["elementCount"], seq.n)}
"""
return md, seq.n
def patch_ui_spec(p: dict, scan: dict, tp_file: str) -> None:
path = KB / f"UI-SPEC-SPD-{p['specId']}.md"
text = path.read_text(encoding="utf-8")
text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1)
text = re.sub(r'related_tc: "[^"]*"', f'related_tc: "testpoint/{tp_file}"', text, count=1)
url = scan["url"]
count = scan["elementCount"]
page_key = p["pageKey"]
extra = ""
if p["kind"] == "user":
extra = "| 浏览器页签标题 | `用户管理` |\n"
elif p["kind"] == "union":
extra = "| 浏览器页签标题 | `医共体用户管理` |\n"
table = f"""| 项 | 值 |
|----|-----|
| 主框架 URL | `{url}` |
{extra}| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
| 元素扫描 JSON | `data/scan/{page_key}-elements.json`({count} 项) |
| 定位器 YAML | `locators/spd/{page_key}.locator.yaml` |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |"""
text = re.sub(
r"\| 项 \| 值 \|\n\|----\|-----\|\n(?:\| .+\|\n)+",
table + "\n",
text,
count=1,
)
text = text.replace(
"1. 完成元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object(建议 `pages/spd/"
+ page_key
+ ".page.ts`)。",
f"1. 元素扫描已完成({count} 项):`data/scan/{page_key}-elements.json`;定位器 `locators/spd/{page_key}.locator.yaml`;下一步补充 Page Object 与用例。",
)
path.write_text(text, encoding="utf-8")
def patch_index(counts: dict[str, int]) -> None:
text = INDEX.read_text(encoding="utf-8")
text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1)
repl = {
"USER-001": (
"| 用户管理 | USER-001 | `/spd/basic/user/platform/person` | "
f"已扫描 · `basic-user` · **219** 项 · 测试点 **{counts['USER-001']}** 条 · `TP_USER_USR` |"
),
"USER-002": (
"| 医共体用户管理 | USER-002 | `/spd/basic/user/platform/personCom` | "
f"已扫描 · `basic-user-union` · **221** 项 · 测试点 **{counts['USER-002']}** 条 · `TP_USER_UNI` |"
),
"USER-003": (
"| 角色管理 | USER-003 | `/spd/basic/user/platform/role` | "
f"已扫描 · `basic-role` · **1170** 项(含权限树) · 测试点 **{counts['USER-003']}** 条 · `TP_USER_ROL` |"
),
}
text = re.sub(
r"\| 用户管理 \| USER-001 \| `/spd/basic/user/platform/person` \| .+ \|",
repl["USER-001"],
text,
count=1,
)
text = re.sub(
r"\| 医共体用户管理 \| USER-002 \| `/spd/basic/user/platform/personCom` \| .+ \|",
repl["USER-002"],
text,
count=1,
)
text = re.sub(
r"\| 角色管理 \| USER-003 \| `/spd/basic/user/platform/role` \| .+ \|",
repl["USER-003"],
text,
count=1,
)
if "generate:testpoints-user" not in text:
text = text.replace(
"npm run discover:scan-user\n",
"npm run discover:scan-user\nnpm run generate:testpoints-user\n",
)
INDEX.write_text(text, encoding="utf-8")
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
counts: dict[str, int] = {}
for p in PAGES:
scan = load_scan(p["pageKey"])
md, n = render_testpoint(p, scan)
out = OUT_DIR / p["file"]
out.write_text(md, encoding="utf-8")
patch_ui_spec(p, scan, p["file"])
counts[p["specId"]] = n
print(f"OK {p['specId']}\t{p['name']}\t{scan['elementCount']} el\t{n} TP\t{out.name}")
patch_index(counts)
print("done", counts)
if __name__ == "__main__":
main()
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