Commit 1a4c1b8b authored by liguangyu06's avatar liguangyu06
Browse files

从 temp 选择性同步框架与用例到 main

合入自动化代码、用例 CSV/MD/spec/PO 及登录态修复;不含 knowledge-base、.cursor、data/scan 等 temp 全量备份内容。
parent f6eec849
#!/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",
spec_ready=True,
tp_total=84,
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", "姓名+科室+角色+状态组合查询", 13,
[
"点击「查 询」后读取首行 `${sampleUserName}`、`${sampleLoginName}`、`${sampleDept}`、`${sampleRole}`、`${sampleStatus}`(角色取截断前列名;状态优先读列表「状态」列,否则按行内启用/禁用文案推断)",
"填写姓名、科室、角色,展开高级筛选选择 `${sampleStatus}` 后点「筛 选」(无「筛 选」则点「查 询」)",
"断言结果非空;首行登录账号与采样行一致;可见行姓名/科室/角色/状态与已填条件一致",
],
"组合查询无报错;查询结果与首行采样条件一致且同时满足全部已填条件。",
pre="默认查询至少 1 条", auto="USER-USR-001",
),
case(
2, "筛选与查询", "功能验证点", "P1", "重置恢复默认", 14,
[
"填写姓名等筛选条件后点击「重 置」",
"断言筛选字段恢复默认,列表按默认规则刷新",
],
"条件恢复默认;列表刷新。",
auto="USER-USR-002",
),
case(
3, "筛选与查询", "功能验证点", "P1", "展开高级筛选", 15,
[
"点击「高级筛选」",
"断言展开后面板可见姓名/科室/角色/状态及「重 置」「筛 选」",
],
"高级筛选面板展开;关键字段与筛选按钮可见。",
auto="USER-USR-003",
),
case(
4, "筛选与查询", "功能验证点", "P0", "高级筛选按状态过滤", 16,
[
"展开高级筛选,状态下拉选择「启用」或「禁用」",
"点击「筛 选」",
"断言列表仅保留对应状态用户(无匹配则空态且总数为 0)",
],
"按状态过滤成功;无报错。",
auto="USER-USR-004",
),
case(
5, "筛选与查询", "异常验证点", "P1", "无匹配结果空态(mock反例)", 22,
[
"姓名框填入 `__NOT_EXIST_USER__99999`",
"点击「查 询」",
"断言空态或「暂无数据」,分页总数为 0",
],
"无匹配时不报错;空态明确;总数为 0。",
auto="USER-USR-005",
),
case(
6, "列表与分页", "功能验证点", "P1", "分页翻至第 2 页", 31,
[
"记录第 1 页首行登录账号",
"点击页码「2」或「下一页」",
"断言页码高亮为 2,首行账号与第 1 页不同",
],
"翻页成功;数据更换。",
pre="默认查询总条数大于一页,否则 skip", auto="USER-USR-006",
),
case(
7, "新增用户", "功能验证点", "P0", "打开新增用户弹窗", 41,
[
"点击「新 增」",
"断言弹出用户表单,可见账号、姓名、联系方式、医院员工编号、启用/禁用、「取 消」「确 定」",
],
"新增弹窗可见;关键字段与按钮齐全。",
auto="USER-USR-007",
),
case(
8, "新增用户", "功能验证点", "P1", "新增取消不落库", 43,
[
"打开新增,账号填 `${uniqLogin}`,姓名填任意合法值",
"点击「取 消」",
"按账号查询,断言无该记录",
],
"弹窗关闭;列表不出现 `${uniqLogin}`。",
auto="USER-USR-008",
),
case(
9, "新增用户", "异常验证点", "P0", "必填为空拦截", 47,
[
"打开新增,账号或姓名留空后点击「确 定」",
"断言出现必填提示,弹窗仍打开",
],
"确定被拦截;不落库;弹窗不关闭。",
auto="USER-USR-009",
),
case(
10, "新增用户", "异常验证点", "P0", "登录账号重复拦截", 48,
[
"打开新增,账号填入已存在的 `${sampleLoginName}`,补齐其它必填后点「确 定」",
"断言保存失败并提示,弹窗仍打开",
"查询该账号,断言原用户未被覆盖",
],
"重复账号被拒绝;不覆盖原用户。",
pre="默认查询至少 1 条且登录账号非空", auto="USER-USR-010",
),
case(
11, "编辑用户", "功能验证点", "P0", "打开编辑用户弹窗并回填", 54,
[
"点击首行「编辑」",
"断言弹窗标题含「编辑用户」",
"断言账号、姓名与该行 `${sampleLoginName}`、`${sampleUserName}` 一致(允许规范化空白)",
],
"编辑弹窗可见;回填与列表行一致。",
pre="列表至少 1 条", auto="USER-USR-011",
),
case(
12, "编辑用户", "功能验证点", "P1", "编辑取消不保存", 58,
[
"打开首行编辑,记录原姓名",
"将姓名改为临时值后点「取 消」",
"再次打开编辑,断言姓名仍为原值",
],
"取消后列表与回看均为修改前姓名。",
pre="列表至少 1 条", auto="USER-USR-012",
),
case(
13, "编辑用户", "边界验证点", "P1", "编辑时账号只读", 61,
[
"打开首行编辑",
"断言登录账号不可改(disabled/readonly 或无法输入)",
],
"编辑态登录账号只读。",
pre="列表至少 1 条", auto="USER-USR-013",
),
case(
14, "编辑用户", "功能验证点", "P1", "编辑弹窗可见签名图片", 56,
[
"打开首行编辑",
"断言可见「签名图片」区域(有图则展示,无图则上传入口可见)",
"点「取 消」,不保存",
],
"签名图片控件可见;本条不上传、不保存。",
pre="列表至少 1 条", auto="USER-USR-014",
),
case(
15, "授权", "功能验证点", "P0", "打开授权后取消不生效", 68,
[
"点击首行「授权」",
"断言打开授权层(弹窗或权限树),可见可勾选菜单/功能",
"如可勾选则改一处勾选后点取消/关闭",
"再次打开同一用户授权,断言勾选与操作前一致",
],
"授权层可打开;取消后勾选不落库。",
pre="列表至少 1 条", auto="USER-USR-015",
),
case(
16, "列表与分页", "功能验证点", "P1", "切换20条每页页面正常且列表同步", 32,
[
"点击「查 询」,记录分页「共 N 条」与当前页可见行数",
"分页区切换每页条数为「20 条/页」",
"断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;筛选区、表头可见无报错",
],
"切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。",
pre="默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip",
auto="USER-USR-016",
),
case(
17, "列表与分页", "功能验证点", "P2", "打开列设置面板", 83,
[
"点击「查 询」等待列表稳定",
"点击表格工具栏列设置(齿轮)图标",
"断言弹出面板含「列展示」「字段设置」「重置」,关闭面板",
],
"列设置面板可打开;关键项可见;关闭后列表正常。",
pre="默认查询至少 1 条", auto="USER-USR-017",
),
case(
18, "列表与分页", "功能验证点", "P2", "列设置取消勾选后列表列恢复", 84,
[
"点击「查 询」后打开列设置,取消勾选一列非核心字段并关闭",
"断言该列表头隐藏;再次打开列设置勾选恢复并关闭",
"断言该列表头重新可见",
],
"取消勾选后表头隐藏;再勾选后表头恢复;无报错遮罩。",
pre="默认查询至少 1 条;存在可 toggle 的非核心列,否则 skip", auto="USER-USR-018",
),
case(
19, "授权", "功能验证点", "P0", "授权弹框账号与所选行一致", 68,
[
"点击「查 询」,任选列表一条登录账号非空的行,记录 `${sampleLoginName}`、`${sampleUserName}`",
"点击该行「授权」",
"断言弹出用户角色授权弹框,弹框展示的账号信息与所选行登录账号、姓名一致",
"关闭弹框",
],
"用户角色授权弹框打开;弹框账号与所选列表行一致。",
pre="列表至少 1 条且登录账号非空", auto="USER-USR-019",
),
case(
20, "授权", "功能验证点", "P1", "授权弹框添加移除角色行", 82,
[
"点击「查 询」,任选列表一条数据行,点击该行「授权」",
"断言弹出用户角色授权弹框",
"记录弹框内可「移除」的角色行数,点击「添加」",
"断言可移除行数 +1;点击新增行的「移除」",
"断言可移除行数恢复为添加前;关闭弹框且不保存",
],
"添加后列表下方新增一行;移除后新增行被删除;未保存不落库。",
pre="列表至少 1 条;授权弹框含「添加/移除」(非仅权限树模式),否则 skip",
auto="USER-USR-020",
),
],
),
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",
spec_ready=True,
tp_total=60,
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", "默认查询列表有数据或空态明确", 9,
[
"点击「查 询」并等待列表稳定",
"断言出现数据行,或空态且分页总数为 0",
],
"查询无报错;有数据时「共 N 条」N≥1。",
),
case(
2, "筛选与查询", "功能验证点", "P0", "姓名、所属医院、联系方式、科室、角色等条件组合查询", 13,
[
"点击「查 询」后读取首行 `${sampleUserName}`、`${sampleLoginName}`、`${sampleHospital}`、`${samplePhone}`、`${sampleDept}`、`${sampleRole}`(科室或联系方式为 `--`/空时换一行)",
"填写姓名、所属医院,展开高级筛选填写「联系方式」、选择科室与角色后点「筛 选」(无则点「查 询」)",
"断言结果非空;首行登录账号与采样行一致;可见行姓名/所属医院/联系方式/科室/角色与已填条件一致",
],
"组合查询无报错;结果与采样条件一致且同时满足全部已填条件。",
pre="默认查询至少 1 条",
),
case(
3, "筛选与查询", "功能验证点", "P1", "重置恢复默认医院", 14,
[
"更改所属医院或姓名后点击「重 置」",
"断言所属医院恢复进入页时的默认值,列表刷新",
],
"医院等条件恢复默认。",
),
case(
4, "筛选与查询", "功能验证点", "P1", "展开高级筛选", 15,
[
"点击「高级筛选」",
"断言可见所属医院、联系方式、科室、角色及「筛 选」",
],
"高级筛选含医院维度字段。",
),
case(
5, "筛选与查询", "异常验证点", "P1", "无匹配空态(mock反例)", 21,
[
"姓名框填入 `__NOT_EXIST_USER__99999`,点击「查 询」",
"断言空态且分页总数为 0",
],
"无匹配不报错;总数为 0。",
),
case(
6, "列表与分页", "功能验证点", "P0", "列表列顺序含所属医院", 24,
[
"断言列顺序为登录账号、姓名、联系方式、所属医院、所属科室、医院员工编号、角色、科室任职数、操作",
"勿用用户管理页列断言(用户管理无所属医院且姓名在登录账号前)",
],
"列顺序与用户管理页不同;含所属医院。",
),
case(
7, "列表与分页", "功能验证点", "P1", "分页翻至第 2 页", 26,
[
"记录第 1 页首行登录账号",
"翻到第 2 页",
"断言数据更换且所属医院筛选仍保持",
],
"翻页成功;医院条件不丢失。",
pre="总条数大于一页,否则 skip",
),
case(
8, "列表与分页", "集成验证点", "P1", "与用户管理页同一用户字段一致", 33,
[
"记录本页首行 `${sampleLoginName}`、姓名、所属科室、角色",
"打开用户管理页,按登录账号查询同一用户",
"断言姓名、角色、所属科室一致;本页仅多「所属医院」",
],
"同一登录账号跨页字段一致。",
pre="两页都能查到同一账号,否则 skip",
),
case(
9, "新增与编辑", "功能验证点", "P0", "打开新增弹窗", 36,
[
"点击「新 增」",
"断言弹出用户表单,含账号、姓名、联系方式及取消/确定",
],
"新增弹窗可见。",
),
case(
10, "新增与编辑", "功能验证点", "P1", "新增取消不落库", 38,
[
"打开新增,账号填 `${uniqLogin}` 后点「取 消」",
"当前医院下列表查询该账号,断言不存在",
],
"取消后当前医院列表不出现该账号。",
),
case(
11, "新增与编辑", "异常验证点", "P0", "必填为空拦截", 46,
[
"打开新增,账号或姓名留空后点「确 定」",
"断言被拦截,弹窗仍打开,不落库",
],
"必填为空不可保存。",
),
case(
12, "新增与编辑", "功能验证点", "P0", "打开编辑并回填", 39,
[
"点击首行「编辑」",
"断言弹窗回填与该行登录账号、姓名、所属医院一致",
"点「取 消」",
],
"编辑回填正确;本条不保存。",
pre="列表至少 1 条",
),
case(
13, "筛选与查询", "功能验证点", "P0", "高级筛选按科室过滤", 16,
[
"点击「查 询」后读取首行 `${sampleDept}`(空或 `--` 时换一行)",
"展开高级筛选,选择该科室后点「筛 选」",
"断言结果非空或空态明确;有数据时可见行「所属科室」与所选科室一致",
],
"按科室过滤成功;无报错;结果所属科室与条件一致。",
pre="默认查询至少 1 条且所属科室非空/--,否则 skip",
),
case(
14, "列表与分页", "功能验证点", "P1", "切换20条每页页面正常且列表同步", 27,
[
"点击「查 询」,记录分页「共 N 条」与当前页可见行数",
"分页区切换每页条数为「20 条/页」",
"断言分页显示 20 条/页;总条数 N 不变;当前页行数为 min(N,20) 且与切换前不同;所属医院筛选、表头可见无报错",
],
"切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。",
pre="默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip",
),
case(
15, "列表与分页", "功能验证点", "P2", "打开列设置面板", 59,
[
"点击「查 询」等待列表稳定",
"点击表格工具栏列设置(齿轮)图标",
"断言弹出面板含「列展示」「字段设置」「重置」,关闭面板",
],
"列设置面板可打开;关键项可见;关闭后列表正常。",
pre="默认查询至少 1 条",
),
case(
16, "列表与分页", "功能验证点", "P2", "列设置取消勾选后列表列恢复", 60,
[
"点击「查 询」后打开列设置,取消勾选一列非核心字段并关闭",
"断言该列表头隐藏;再次打开列设置勾选恢复并关闭",
"断言该列表头重新可见",
],
"取消勾选后表头隐藏;再勾选后表头恢复;无报错遮罩。",
pre="默认查询至少 1 条;存在可 toggle 的非核心列,否则 skip",
),
case(
17, "授权", "功能验证点", "P0", "打开授权后取消不生效", 51,
[
"点击首行「授权」",
"断言打开用户角色授权层,可见可勾选菜单/功能",
"如可勾选则改一处勾选后点取消/关闭",
"再次打开同一用户授权,断言勾选与操作前一致",
],
"授权层可打开;取消后勾选不落库。",
pre="列表至少 1 条",
),
case(
18, "授权", "功能验证点", "P0", "授权弹框账号与所选行一致", 51,
[
"点击「查 询」,任选列表一条登录账号非空的行,记录 `${sampleLoginName}`、`${sampleUserName}`",
"点击该行「授权」",
"断言弹出用户角色授权弹框,弹框展示的账号信息与所选行登录账号、姓名一致",
"关闭弹框",
],
"用户角色授权弹框打开;弹框账号与所选列表行一致。",
pre="列表至少 1 条且登录账号非空",
),
case(
19, "授权", "功能验证点", "P1", "授权弹框添加移除角色行", 51,
[
"点击「查 询」,任选列表一条数据行,点击该行「授权」",
"断言弹出用户角色授权弹框",
"记录弹框内可「移除」的角色行数,点击「添加」",
"断言可移除行数 +1;点击新增行的「移除」",
"断言可移除行数恢复为添加前;关闭弹框且不保存",
],
"添加后列表下方新增一行;移除后新增行被删除;未保存不落库。",
pre="列表至少 1 条;授权弹框含「添加/移除」(非仅权限树模式),否则 skip",
),
],
),
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=(
"权限树 001/002 只测展开收起,不保存授权;003 切换同步。"
"编辑 004 取消;005 不改名保存(受 SKIP_BASIC_WRITE)。"
"新增 006–010 不提交落库。"
),
nav_note="菜单「基础设置 → 用户管理 → 角色管理」;本页左右分栏、无独立分页。",
samples=(
"左栏读取 `${sampleRoleName}`(优先非预置自定义角色;预置用「医院管理员」若存在);"
"权限树按「模块名 + 菜单名」定位,根为「SPD内网系统PC端」。"
),
skip=(
"未登录与菜单进入、角色搜索与空态、权限树勾选联动/刷新/保存、"
"删除与预置角色、与用户页角色下拉同源、真实新增角色落库、未保存切角色提示等"
),
include_mock=False,
spec_ready=True,
cases=[
case(
1, "右侧菜单权限树", "功能验证点", "P0", "权限树按应用与模块展开", 18,
[
"选中任一角色",
"断言右侧树根为「SPD内网系统PC端」,可见基础设置/运维管理/厂商管理/产品管理等模块",
"展开「基础设置」,断言可见子菜单(如医院资料)",
],
"权限树层级可见;节点可展开。",
),
case(
2, "右侧菜单权限树", "功能验证点", "P0", "权限树模块节点收起正常", 18,
[
"选中任一角色",
"展开「基础设置」,断言可见子菜单(如医院资料)",
"点击「基础设置」节点展开图标收起该模块",
"断言子菜单(如医院资料)不可见,且「基础设置」节点处于收起态",
],
"权限树节点可收起;子级随父节点隐藏。",
),
case(
3, "左侧角色列表", "功能验证点", "P0", "切换角色右侧权限树同步更新", 11,
[
"点选左栏第一个角色,记录右侧权限树勾选快照",
"点选左栏第二个不同角色,断言该行选中且右侧权限树仍可见",
"再次点选第一个角色,断言右侧权限树勾选快照与步骤 2 一致",
],
"切换后右侧与当前角色一致;切回后恢复该角色已保存勾选,无错乱残留。",
pre="左栏至少 2 个角色",
),
case(
4, "角色增删改", "功能验证点", "P0", "编辑角色弹框回填名称一致", 33,
[
"点选左栏任一角色,记录其角色名称",
"点击左栏右下角(或行内)编辑图标",
"断言弹出编辑角色弹框,标题或内容含「编辑」",
"断言弹框内「角色名称」输入框回填值与所选角色名称一致",
"点「取 消」或「关 闭」关闭弹框,不保存",
],
"编辑弹框正常打开;角色名称与当前选中角色一致;本条不保存。",
pre="左栏至少 1 个角色",
),
case(
5, "角色增删改", "功能验证点", "P0", "编辑角色保存成功", 33,
[
"点选左栏任一角色",
"点击左栏右下角(或行内)编辑图标,断言弹出编辑角色弹框",
"不修改角色名称,点击弹框内「保 存」",
"断言页面无报错提示,且出现「编辑成功」类成功提示",
"断言编辑弹框关闭",
],
"保存成功;提示编辑成功;角色数据无异常报错。",
pre="左栏至少 1 个可编辑角色;环境允许写操作(非 SKIP_BASIC_WRITE=1)",
),
case(
6, "角色增删改", "功能验证点", "P0", "点击角色按钮打开新增弹框", 32,
[
"点击左栏工具区「角 色」按钮",
"断言弹出新增角色弹框,标题或内容含「新增」或「角色」",
"断言页面无报错提示(无错误 toast/遮罩)",
"点「取 消」或「关 闭」关闭弹框,不保存",
],
"新增角色弹框正常打开;页面无报错;本条不提交保存。",
),
case(
7, "角色增删改", "异常验证点", "P0", "新增角色名称为空保存拦截", 39,
[
"点击左栏工具区「角 色」按钮,断言弹出新增角色弹框",
"不填写角色名称,直接点击弹框内「保 存」",
"断言「角色名称」输入框呈校验失败态(变红)",
"断言同字段下方提示:请输入角色名称!",
"点「取 消」关闭弹框,不保存",
],
"空名称保存被拦截;角色名称框变红并提示请输入角色名称!;弹框不提交。",
),
case(
8, "角色增删改", "功能验证点", "P0", "新增角色弹框取消关闭", 32,
[
"点击左栏工具区「角 色」按钮,断言弹出新增角色弹框",
"不填写任何内容,直接点击弹框内「取 消」",
"断言新增角色弹框关闭",
"断言页面无报错提示,左栏角色数量不增加",
],
"取消后弹框关闭;页面无报错;未新增角色。",
),
case(
9, "角色增删改", "功能验证点", "P0", "新增弹框查询配置化复选框可勾选", 32,
[
"点击左栏工具区「角 色」按钮,断言弹出新增角色弹框",
"定位「查询配置化」复选框,点击勾选,断言为选中态",
"再次点击取消勾选,断言为未选中态",
"断言页面无报错提示",
"点「取 消」关闭弹框,不保存",
],
"查询配置化可正常勾选与取消勾选;页面无报错;本条不提交保存。",
pre="新增弹框含「查询配置化」复选框",
),
case(
10, "角色增删改", "功能验证点", "P0", "新增角色弹框X关闭", 32,
[
"点击左栏工具区「角 色」按钮,断言弹出新增角色弹框",
"点击弹框右上角「X」关闭图标",
"断言新增角色弹框关闭",
"断言页面无报错提示,左栏角色数量不增加",
],
"X 关闭后弹框正常关闭;页面无报错;未新增角色。",
),
case(
11, "左侧角色列表", "功能验证点", "P1", "左栏角色列表可上下滑动", 9,
[
"定位左栏角色列表滚动区域",
"向下滚动列表,断言 scrollTop 增加且列表可继续交互",
"向上滚回(滚轮或滑至顶部),断言 scrollTop 减小",
"断言页面无报错遮罩",
],
"左栏可正常上下滑动;滚动后页面无异常。",
pre="左栏列表高度溢出可滚动(不足则 skip)",
),
case(
12, "右侧菜单权限树", "功能验证点", "P1", "右侧权限树子菜单可上下滑动", 25,
[
"点选左栏任一角色,断言右侧菜单权限树可见",
"定位右侧权限树滚动区域(含子菜单列表)",
"向下滚动,断言 scrollTop 增加",
"向上滚回,断言 scrollTop 减小",
"断言页面无报错遮罩",
],
"右侧子菜单列表可正常上下滑动;滚动后页面无异常。",
pre="已选中任一角色且右侧权限树高度溢出可滚动(不足则 skip)",
),
],
),
]
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("
f"{'`tests/spd/' + p['spec_file'] + '`' if p.get('spec_ready') else '规划 `tests/spd/' + p['spec_file'] + '`'}"
f",依赖 `auth.setup` 登录态)",
"",
f"**通用前置**:正例账号 `{POSITIVE_USER}` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → 用户管理 → {p['name']}」权限。"
+ (
"反例筛选用 mock(`__NOT_EXIST_USER__99999` / `__NOT_EXIST_ROLE__99999`)。 "
if p.get("include_mock", True)
else " "
),
"**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 ",
f"**菜单导航**:{p['nav_note']} ",
]
if p.get("write_note"):
lines.append(f"**写操作**:{p['write_note']} ")
lines.append(f"**动态取数**:{p['samples']}")
lines.extend(
[
"",
"---",
"",
"## 基础设置 · 用户管理",
"",
]
)
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}")
auto_verb = "与" if p.get("spec_ready") else "规划与"
auto_line = f"- 自动化断言:{auto_verb} CSV `{case_id}` 及 `tests/spd/{p['spec_file']}` 一致"
if p.get("spec_ready") and c["title"] == "编辑角色保存成功":
auto_line += "(`SKIP_BASIC_WRITE=1` 时 skip)"
lines.append(auto_line)
lines += ["", "###### 预期结果", "", c["expect"], "", "---", ""]
while lines and lines[-1] in ("", "---"):
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(
"中",
"每页条数切换",
"切换「20 条/页」「50 条/页」等后当前页行数为 min(N,所选值),总数不变(用户管理自动化 USER-USR-016 覆盖 20 条/页)",
),
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(
"中",
"授权角色行添加与移除",
"用户角色授权弹框内点「添加」增加一行,点「移除」删除新增行;不保存不落库(USER-USR-020)",
),
],
),
(
"边界验证点",
[
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(
"高",
"姓名、所属医院、联系方式、科室、角色等条件组合查询",
"多条件同时生效,结果同时满足(USER-UNI-002)",
),
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"]
domains = {
"user": "页面访问与导航、筛选与查询、列表与分页、新增用户、编辑用户、授权、重置密码",
"union": "页面访问与导航、筛选与查询、列表与分页、新增与编辑、授权与重置密码",
"role": "页面访问与导航、左侧角色列表、右侧菜单权限树、角色增删改",
}[kind]
if kind == "user":
cover = "导航、筛选查询、列表分页、列设置、新增/编辑(含签名与启用禁用)、授权、重置密码、权限与登录异常、同模块衔接"
pending = (
"① 新增弹窗扫描未稳定打开,字段以编辑弹窗与 UI-SPEC 为准 "
"② 授权层扫描未采集到,勾选控件以现网为准 "
"③ 编辑时登录账号按只读写点,若现网可改需改判定 "
"④ 空权限按允许保存写点,联系方式非法/签名非图片按拦截写点,禁用用户重置按允许且不启用写点 "
"⑤ UI-SPEC 截图未见「查询」,现网扫描有「查询」,测试点以扫描为准 "
"⑥ 列表序号可勾选但无批量工具栏,按无批量处理"
)
downstream = (
"可转结构化 MD/CSV(`npm run generate:md-user`);扫描 `npm run discover:scan-user`;"
"Playwright `tests/spd/basic-user.spec.ts` + `pages/spd/basic-user.page.ts`"
)
elif kind == "union":
cover = "导航、所属医院筛选、列顺序差异、跨院不覆盖/不串改、列设置;启用禁用/签名/任职/授权持久化引用 TP_USER_USR,本页不重复展开"
pending = (
"① 新增/授权弹窗扫描未稳定打开,以现网为准 "
"② 跨院登录账号按拒绝且不覆盖写点,若产品允许共用需改判定 "
"③ 所属医院默认值与顶栏约定以现网为准 "
"④ 签名/启用禁用/任职明细/改密后登录同用户管理对应 TP,本页只保留医院维度 "
"⑤ UI-SPEC 测试关注点写了「状态」筛选,现网扫描仅在编辑弹窗有启用/禁用、无高级筛选「状态」;按扫描写点,自动化 `USER-UNI-013` 改为按科室过滤(TP_USER_UNI_016)"
)
downstream = (
"可转结构化 MD/CSV(`npm run generate:md-user`);扫描 `npm run discover:scan-user`;"
"Playwright `tests/spd/basic-user-union.spec.ts` + `pages/spd/basic-user-union.page.ts`"
)
else:
cover = "导航(左右分栏无分页)、左栏角色搜索、右栏权限树勾选联动/保存/未保存切角色、角色增删改与用户页下拉同步"
pending = (
"① 新增角色弹窗扫描未打开,入口以现网工具栏/行内图标为准,对应点优先级为中 "
"② 预置角色按不可删除写点,以现网预置名单为准 "
"③ 未保存切角色按「提示后丢弃、不静默写入」写点,若自动保存需改判定 "
"④ 本页无授权弹窗、无分页、无所属医院"
)
downstream = (
"可转结构化 MD/CSV(`npm run generate:md-user`);扫描 `npm run discover:scan-user`;"
"Playwright `tests/spd/basic-role.spec.ts` + `pages/spd/basic-role.page.ts`"
)
return f"""## 质量自检摘要
| 项 | 结论 |
|----|------|
| 范围 | 在范围内:{domains}。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 |
| 覆盖 | 已覆盖:{cover};共 **{tp_count}** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) |
| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 |
| 去重 | 登录拦截、会话过期与同模块其它用户管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `{p["prefix"]}` |
| 待评审项 | {pending} |
| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe |
| 下游 | {downstream} |
"""
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)
extra = [
seq.add("低", "打开列设置面板", "列设置齿轮可打开,含列展示/字段设置/重置;关闭后面板消失、列表仍在(自动化 `USER-USR-017`)"),
seq.add("低", "列设置隐藏后再恢复", "取消勾选非核心列后表头隐藏,再勾选恢复;勾选状态与表头一致(自动化 `USER-USR-018`)"),
]
needle = "(中)|跳至指定页|验证要点:分页「跳至」输入合法页码后跳到对应页|关联需求:UI-SPEC-SPD-USER-001"
body = body.replace(needle, needle + "\n" + "\n".join(extra), 1)
elif p["kind"] == "union":
body = union_body(seq)
extra = [
seq.add("低", "打开列设置面板", "列设置齿轮可打开,含列展示/字段设置/重置;关闭后面板消失、列表仍在"),
seq.add("低", "列设置隐藏后再恢复", "取消勾选非核心列后表头隐藏,再勾选恢复;勾选状态与表头一致(一对多:CSV `USER-UNI-015`/`016`)"),
]
needle = "(中)|跳至指定页|验证要点:分页「跳至」输入合法页码后跳到对应页|关联需求:UI-SPEC-SPD-USER-002"
body = body.replace(needle, needle + "\n" + "\n".join(extra), 1)
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`
**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围
---
## 基础设置 · 用户管理 · {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']}** 条 · UI 自动化 **12** 条 · `TP_USER_ROL` / `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()
#!/usr/bin/env python3
"""生成基础管理-厂商管理 10 页结构化 MD(testcases/)与同源 CSV(testcase/)。"""
from __future__ import annotations
import csv
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MD_DIR = ROOT / "testcases"
CSV_DIR = ROOT / "testcase"
# case: (seq, h3, h4, prio P0/P1/P2, title, tp_suffix or full tp, pre, steps_list, expect, auto_id optional)
# tp_suffix: used as TP_VENDOR_{PREFIX}_{suffix:03d} if int, else literal TP string
PAGES: list[dict] = []
def page(**kwargs) -> dict:
return kwargs
def case(
seq: int,
h3: str,
h4: str,
prio: str,
title: str,
tp: str | int,
steps: list[str],
expect: str,
pre: str = "",
auto: str | None = None,
) -> dict:
return {
"seq": seq,
"h3": h3,
"h4": h4,
"prio": prio,
"title": title,
"tp": tp,
"steps": steps,
"expect": expect,
"pre": pre,
"auto": auto,
}
PAGES = [
page(
code="VENDOR-QRY",
prefix="QRY",
name="厂商查询",
path="/spd/basic/mfrs/paltform/mfrsName/factory",
scan="vendor-query",
count=47,
spec="UI-SPEC-SPD-VENDOR-001",
spec_file="vendor-query.spec.ts",
iframe=False,
write_note="本页只读查询,无新增/编辑提交。",
samples="从器械注册人 Tab 首行读取 `${sampleMfrsName}`;禁止写死厂商名称;mock:`__NOT_EXIST_MFRS__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "菜单进入厂商查询页", 1,
["按菜单「基础设置 → 厂商管理 → 厂商查询」进入",
"断言 URL 含 `/spd/basic/mfrs/paltform/mfrsName/factory`",
"断言可见「器械注册人」「经销商」Tab、厂商名称搜索框及列表表头"],
"进入厂商查询页;不误入供应商资质管理。",
pre="已登录且具备厂商管理权限", auto="VENDOR-QRY-001"),
case(2, "Tab 与搜索", "功能验证点", "P0", "默认器械注册人列表有数据或空态明确", 9,
["切换或停留「器械注册人」", "断言出现数据行,或「暂无数据」且分页总数为 0"],
"器械注册人列表稳定;有数据时分页「共 N 条」N≥1。", auto="VENDOR-QRY-002"),
case(3, "Tab 与搜索", "功能验证点", "P0", "切换经销商 Tab", 10,
["点击「经销商」", "断言 Tab 激活,列表有数据或空态明确"],
"经销商列表加载成功;总数可与器械注册人不同。", auto="VENDOR-QRY-003"),
case(4, "Tab 与搜索", "功能验证点", "P0", "按厂商名称搜索", 11,
["从首行读取 `${sampleMfrsName}` 取关键字", "在厂商名称搜索框填入关键字并回车",
"断言结果行「厂商名称」包含关键字"],
"厂商名称列与关键字匹配。",
pre="器械注册人至少 1 条且厂商名称非空", auto="VENDOR-QRY-004"),
case(5, "Tab 与搜索", "功能验证点", "P1", "清空搜索恢复", 19,
["在已过滤状态下清空搜索框并回车", "断言分页总数不少于过滤后"],
"恢复更宽结果集。", pre="已按厂商名称过滤", auto="VENDOR-QRY-005"),
case(6, "Tab 与搜索", "异常验证点", "P1", "无匹配空态(mock反例)", 17,
["厂商名称填入 `__NOT_EXIST_MFRS__99999` 并回车", "断言空态且分页总数为 0"],
"无匹配时不报错;总数为 0。", auto="VENDOR-QRY-006"),
case(7, "Tab 与搜索", "功能验证点", "P0", "列表核心列完整", 12,
["断言表头含序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号"],
"核心列可见。", auto="VENDOR-QRY-007"),
case(8, "Tab 与搜索", "功能验证点", "P1", "分页翻至第 2 页", 13,
["总数足够时翻到第 2 页", "断言列表可加载;不足两页则 skip"],
"第 2 页可加载。", pre="器械注册人总数足够翻页", auto="VENDOR-QRY-008"),
case(9, "Tab 与搜索", "边界验证点", "P2", "仅空格关键字搜索", 15,
["厂商名称仅填空格并回车", "断言按空查询处理或提示,不白屏"],
"不报错;行为明确。"),
case(10, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
["新建独立 BrowserContext,不加载 auth/user.json(test.use 空 storageState)",
"直接打开厂商查询 URL(/spd/basic/mfrs/paltform/mfrsName/factory)",
"断言跳转登录或不展示业务列表"],
"未登录不可见厂商数据。",
pre="使用独立 context,storageState 为空(不加载 test_1 登录态)",
auto="VENDOR-QRY-010"),
case(11, "Tab 与搜索", "集成验证点", "P1", "厂商名称与资质管理进院供应商可对照", 18,
["记录厂商查询中某厂商名称(若有)", "打开供应商资质管理,用同源关键字检索或目视对照",
"断言不出现明显主数据串名(允许一方无记录)"],
"厂商主数据跨菜单可对照,不串名。"),
],
),
page(
code="VENDOR-QUL",
prefix="QUL",
name="供应商资质管理",
path="/spd/basic/mfrs/basic/qcManage",
scan="vendor-qual",
count=99,
spec="UI-SPEC-SPD-VENDOR-002",
spec_file="vendor-qual.spec.ts",
iframe=False,
write_note="本批只读:查看打开后关闭;不确认导出下载、不改证件。",
samples="证件效期提醒 Tab 首行读取 `${sampleHospitalSupplier}`;mock:`__NOT_EXIST_SUPPLIER__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "菜单进入供应商资质管理页", 1,
["按菜单「基础设置 → 厂商管理 → 供应商资质管理」进入",
"断言 URL 含 `/spd/basic/mfrs/basic/qcManage`",
"断言可见「查 询」「重 置」及证件效期提醒相关入口"],
"进入供应商资质管理;不误入厂商查询。",
pre="已登录且具备厂商管理权限", auto="VENDOR-QUL-001"),
case(2, "筛选与 Tab", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
["点击「查 询」并等待列表稳定", "断言出现数据行或空态明确"],
"查询无报错;有数据时分页 N≥1。", auto="VENDOR-QUL-002"),
case(3, "筛选与 Tab", "功能验证点", "P0", "切换授权链路效期预警 Tab", 9,
["点击「授权链路效期预警」", "断言列表有数据或空态明确"],
"Tab 切换成功,列表字段可随 Tab 变化。", auto="VENDOR-QUL-003"),
case(4, "筛选与 Tab", "功能验证点", "P0", "近三个月到期计数一致", 10,
["切换「近三个月到期」(或等价文案)",
"若可读到括号/冒号计数,断言等于分页「共 N 条」"],
"计数与分页一致(可读时)。", auto="VENDOR-QUL-004"),
case(5, "筛选与 Tab", "功能验证点", "P0", "按进院供应商筛选", 11,
["从首行读取 `${sampleHospitalSupplier}` 取关键字",
"在进院供应商名称填入关键字并「查 询」",
"断言结果行「进院供应商」包含关键字"],
"进院供应商列与条件一致。",
pre="证件效期提醒至少 1 条且进院供应商非空", auto="VENDOR-QUL-005"),
case(6, "筛选与 Tab", "功能验证点", "P1", "重置恢复默认", 11,
["填写筛选条件后点击「重 置」", "断言列表按默认规则刷新"],
"筛选恢复默认。", auto="VENDOR-QUL-006"),
case(7, "筛选与 Tab", "异常验证点", "P1", "无匹配空态(mock反例)", 16,
["进院供应商名称填入 `__NOT_EXIST_SUPPLIER__99999`", "点击「查 询」",
"断言空态且分页总数为 0"],
"无匹配时不报错;总数为 0。", auto="VENDOR-QUL-007"),
case(8, "筛选与 Tab", "功能验证点", "P0", "证件效期提醒核心列完整", 12,
["处于证件效期提醒 Tab",
"断言表头含序号、数据来源、进院供应商、有效期至、证件名称、证件类型、效期提醒、操作"],
"核心列可见。", auto="VENDOR-QUL-008"),
case(9, "查看与导出", "功能验证点", "P0", "打开查看后关闭", 14,
["点击首行「查 看」", "断言详情层可见", "关闭/返回,断言回到列表"],
"详情可打开;关闭后回到列表。",
pre="列表至少 1 行含「查看」", auto="VENDOR-QUL-009"),
case(10, "查看与导出", "功能验证点", "P1", "导出按钮可见", 15,
["断言「导出」或「导出明细」按钮可见(本批不确认下载)"],
"导出入口可见。", auto="VENDOR-QUL-010"),
case(11, "筛选与 Tab", "边界验证点", "P2", "制单/效期日期起止同一天", 15,
["若存在日期区间筛选,起止选同一天并查询", "断言仅返回该日数据或空态明确,不报错"],
"同日区间行为明确。"),
case(12, "筛选与 Tab", "集成验证点", "P1", "效期提醒与有效期至抽样一致", 18,
["在近三个月到期列表抽样一行", "核对「效期提醒」文案与「有效期至」日期逻辑一致"],
"提醒文案与有效期至不矛盾。"),
],
),
]
def audit_page(code: str, prefix: str, name: str, path: str, scan: str, count: int, spec: str, kind: str) -> dict:
"""kind: auth | cert"""
peer = "终审" if "初审" in name else "初审"
filter_label = "进院供应商" if kind == "auth" else "注册证号(新)"
cols = (
"进院供应商名称、单号、状态、制单时间、制单人、变更类型、原供应商、预转供应商、操作"
if kind == "auth"
else "进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新)、操作"
)
return page(
code=code,
prefix=prefix,
name=name,
path=path,
scan=scan,
count=count,
spec=spec,
spec_file="vendor-audit.spec.ts",
iframe=False,
write_note="审批写操作只打开详情后返回,不确认通过/驳回/提交。",
samples=f"默认查询后从首行读取 `${{sampleOrderNo}}`;禁止写死单号;mock:`__NOT_EXIST_ORDER__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", f"菜单进入{name}页", 1,
[f"按菜单「基础设置 → 厂商管理 → {name}」进入",
f"断言 URL 含 `{path}`",
"断言可见「查 询」「重 置」及制单日期筛选"],
f"进入{name}页;不误入其它变更审批菜单。",
pre="已登录且具备厂商管理权限", auto=f"{code}-001"),
case(2, "筛选与列表", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
["点击「查 询」并等待列表稳定", "断言出现数据行或空态明确"],
"查询无报错;有数据时分页 N≥1。", auto=f"{code}-002"),
case(3, "筛选与列表", "功能验证点", "P0", "按单号筛选", 10,
["从首行读取 `${sampleOrderNo}` 取关键字", "单号填入关键字并「查 询」",
"断言结果行「单号」包含关键字"],
"单号列与条件一致。",
pre="列表至少 1 条且单号非空", auto=f"{code}-003"),
case(4, "筛选与列表", "功能验证点", "P0", f"按{filter_label}筛选", 11,
[f"按样本填写或选择「{filter_label}」并查询", "断言结果列与条件匹配"],
"筛选条件生效。",
pre="列表至少 1 条", auto=f"{code}-004"),
case(5, "筛选与列表", "功能验证点", "P1", "重置恢复默认", 12,
["填写筛选项后点击「重 置」", "断言文本条件清空、列表按默认规则刷新"],
"重置成功。", auto=f"{code}-005"),
case(6, "筛选与列表", "异常验证点", "P1", "无匹配空态(mock反例)", 16,
["单号填入 `__NOT_EXIST_ORDER__99999` 并查询", "断言空态且分页总数为 0"],
"无匹配时不报错;总数为 0。", auto=f"{code}-006"),
case(7, "筛选与列表", "功能验证点", "P0", "列表核心列完整", 13,
[f"断言表头含 {cols}", "断言存在「详情」操作"],
"核心列与详情入口可见。", auto=f"{code}-007"),
case(8, "详情", "功能验证点", "P0", "打开详情后返回", 14,
["点击行内「详 情」", "断言详情层可见且含单号片段", "返回/关闭,不提交审批"],
"详情只读可返回;列表状态不变。",
pre="存在含「详情」的行", auto=f"{code}-008"),
case(9, "筛选与列表", "边界验证点", "P2", "制单日期起止同一天", 15,
["制单日期起止选同一天并查询", "断言仅返回该日单据或空态明确"],
"同日区间行为明确。"),
case(10, "详情", "集成验证点", "P0", f"与{peer}单据衔接不串单", 18,
[f"记录本页某单号后切换到对应{peer}菜单", "断言同业务单据状态流转合理、不串到非本业务菜单"],
f"初审/终审衔接正确。"),
],
)
PAGES.extend(
[
audit_page("VENDOR-AF1", "AF1", "授权变更初审", "/spd/basic/mfrs/paltform/mfrsName/authAudit", "vendor-auth-first", 40, "UI-SPEC-SPD-VENDOR-003", "auth"),
audit_page("VENDOR-AF2", "AF2", "授权变更终审", "/spd/basic/mfrs/paltform/mfrsName/authAuditFinal", "vendor-auth-final", 40, "UI-SPEC-SPD-VENDOR-004", "auth"),
audit_page("VENDOR-CF1", "CF1", "注册证变更初审", "/spd/basic/mfrs/paltform/mfrsName/regCertAudit", "vendor-cert-first", 40, "UI-SPEC-SPD-VENDOR-005", "cert"),
audit_page("VENDOR-CF2", "CF2", "注册证变更终审", "/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal", "vendor-cert-final", 40, "UI-SPEC-SPD-VENDOR-006", "cert"),
]
)
PAGES.append(
page(
code="VENDOR-CDF",
prefix="CDF",
name="注册证差异对比",
path="/spd/basic/mfrs/scjs/yc/certCompare",
scan="vendor-cert-diff",
count=54,
spec="UI-SPEC-SPD-VENDOR-007",
spec_file="vendor-cert-diff.spec.ts",
iframe=True,
write_note="主体在 scjs iframe;导出本批只断言按钮可见,不确认下载。",
samples="iframe 内首行读取 `${sampleMaterialName}`;mock:`__NOT_EXIST_MFRS__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "菜单进入注册证差异对比页", 1,
["按菜单「基础设置 → 厂商管理 → 注册证差异对比」进入",
"断言 URL 含 `certCompare`",
"断言外壳或 iframe 内可见物料名称/全部/一致/不一致等主区"],
"进入差异对比页;iframe 或主区加载成功。",
pre="已登录且具备厂商管理权限", auto="VENDOR-CDF-001"),
case(2, "筛选与对比", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
["在对比页执行查询(若有查询按钮)", "断言列表有数据或空态明确"],
"查询无报错。", auto="VENDOR-CDF-002"),
case(3, "筛选与对比", "功能验证点", "P0", "切换不一致 Tab", 13,
["点击「不一致」", "断言列表稳定"],
"不一致 Tab 切换成功。", auto="VENDOR-CDF-003"),
case(4, "筛选与对比", "功能验证点", "P1", "切换一致 Tab", 13,
["点击「一致」", "断言列表稳定"],
"一致 Tab 切换成功。", auto="VENDOR-CDF-004"),
case(5, "筛选与对比", "功能验证点", "P0", "按物料名称筛选", 10,
["从首行读取 `${sampleMaterialName}` 取关键字", "物料名称填入并查询",
"断言结果行物料名称包含关键字"],
"物料名称匹配。",
pre="对比列表至少 1 条", auto="VENDOR-CDF-005"),
case(6, "筛选与对比", "异常验证点", "P1", "无匹配空态(mock反例)", 16,
["物料名称填 mock 关键字并查询", "断言空态或总数为 0"],
"无匹配时不报错。", auto="VENDOR-CDF-006"),
case(7, "筛选与对比", "功能验证点", "P0", "列表核心列完整", 14,
["断言表头含物料编号/物料名称/规格型号/生产厂家等关键列"],
"核心对比列可见。", auto="VENDOR-CDF-007"),
case(8, "导出", "功能验证点", "P1", "导出按钮可见", 15,
["断言「导出」按钮可见(不确认下载)"],
"导出入口可见。", auto="VENDOR-CDF-008"),
case(9, "筛选与对比", "边界验证点", "P2", "仅空格物料名称搜索", 15,
["物料名称仅填空格并查询", "断言按空查询处理或提示,不白屏"],
"空格关键字不导致页面异常。"),
case(10, "筛选与对比", "集成验证点", "P0", "一致行两侧字段抽样一致", 17,
["在「一致」Tab 抽样一行", "核对望海与 FLI+ 注册证号/效期关键字段一致"],
"标记一致的行两侧关键字段抽样相同。"),
case(11, "筛选与对比", "集成验证点", "P0", "不一致行至少一侧差异", 18,
["在「不一致」Tab 抽样一行", "断言望海与 FLI+ 至少一处字段不同"],
"不一致结论与字段差异相符。"),
],
)
)
PAGES.append(
page(
code="VENDOR-EVS",
prefix="EVS",
name="评价方案维护",
path="/spd/basic/mfrs/paltform/mfrsName/evaluationPlan",
scan="vendor-eval-scheme",
count=57,
spec="UI-SPEC-SPD-VENDOR-008",
spec_file="vendor-eval-scheme.spec.ts",
iframe=False,
write_note="新增只打开后取消,不提交保存。",
samples="有数据时从首行读取 `${sampleSchemeCode}`;mock:`__NOT_EXIST_ORDER__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "菜单进入评价方案维护页", 1,
["按菜单进入评价方案维护", "断言 URL 含 `evaluationPlan`", "断言可见查询与新增"],
"进入评价方案维护页。",
pre="已登录且具备厂商管理权限", auto="VENDOR-EVS-001"),
case(2, "筛选与维护", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
["点击查询", "断言列表有数据或「暂无数据」空态"],
"查询无报错。", auto="VENDOR-EVS-002"),
case(3, "筛选与维护", "功能验证点", "P0", "按方案编码筛选", 10,
["取样本方案编码筛选并查询", "断言方案编码列匹配"],
"方案编码匹配。",
pre="列表有数据", auto="VENDOR-EVS-003"),
case(4, "筛选与维护", "功能验证点", "P1", "重置恢复默认", 11,
["填写筛选后重置", "断言条件清空、列表刷新"],
"重置成功。", auto="VENDOR-EVS-004"),
case(5, "筛选与维护", "异常验证点", "P1", "无匹配空态(mock反例)", 15,
["方案编码填 mock 后查询", "断言空态总数 0"],
"无匹配不报错。", auto="VENDOR-EVS-005"),
case(6, "筛选与维护", "功能验证点", "P0", "列表核心列完整", 12,
["断言表头含方案编码、方案名称、创建人、创建时间、状态、操作"],
"核心列可见。", auto="VENDOR-EVS-006"),
case(7, "新增", "功能验证点", "P0", "打开新增后取消", 13,
["记录总数", "点击新增打开编辑层", "取消/返回不提交", "断言总数不变"],
"取消不落库。", auto="VENDOR-EVS-007"),
case(8, "新增", "异常验证点", "P0", "必填为空提交拦截", 16,
["打开新增不填必填项尝试提交(若可点)", "断言拦截提示,页面不关闭或总数不变"],
"空提交被拦截。"),
case(9, "筛选与维护", "边界验证点", "P2", "方案名称超长输入", 15,
["打开新增,方案名称输入超长字符串(如 200+ 字符)", "断言前端截断/校验提示或后端拒绝,不白屏"],
"超长输入被约束且页面可用。"),
case(10, "筛选与维护", "集成验证点", "P1", "方案与评价指标可关联", 18,
["在方案配置中查看指标选择(只读打开)", "断言可选指标来源于评价指标维护启用项或规则允许集合"],
"方案与指标主数据衔接正确。"),
],
)
)
PAGES.append(
page(
code="VENDOR-EVI",
prefix="EVI",
name="评价指标维护",
path="/spd/basic/mfrs/paltform/mfrsName/indicators",
scan="vendor-eval-indicator",
count=151,
spec="UI-SPEC-SPD-VENDOR-009",
spec_file="vendor-eval-indicator.spec.ts",
iframe=False,
write_note="详情只读返回;不落库新增/编辑提交。",
samples="右表首行读取 `${sampleIndicatorCode}`;列名可能为指标编码或指标编号;mock:`__NOT_EXIST_ORDER__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "菜单进入评价指标维护页", 1,
["按菜单进入评价指标维护", "断言 URL 含 `indicators`", "断言可见「指标分类」与查询"],
"进入评价指标维护页。",
pre="已登录且具备厂商管理权限", auto="VENDOR-EVI-001"),
case(2, "树与列表", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
["点击查询", "断言右表有数据或空态"],
"查询无报错。", auto="VENDOR-EVI-002"),
case(3, "树与列表", "功能验证点", "P0", "点击左侧分类树", 10,
["点击左侧首个分类节点", "断言右侧列表刷新"],
"分类切换成功。", auto="VENDOR-EVI-003"),
case(4, "树与列表", "功能验证点", "P0", "按指标编码筛选", 11,
["取样本指标编码/编号筛选并查询", "断言编码列匹配"],
"指标编码匹配。",
pre="右表有数据", auto="VENDOR-EVI-004"),
case(5, "树与列表", "异常验证点", "P1", "无匹配空态(mock反例)", 15,
["指标编码填 mock 后查询", "断言空态"],
"无匹配不报错。", auto="VENDOR-EVI-005"),
case(6, "树与列表", "功能验证点", "P0", "列表核心列完整", 12,
["断言表头含评价类型、指标编码或指标编号、指标名称、状态、操作"],
"核心列可见。", auto="VENDOR-EVI-006"),
case(7, "详情", "功能验证点", "P0", "打开详情后返回", 14,
["点击行内详情", "断言详情层可见", "返回不保存"],
"详情只读可返回。",
pre="存在详情操作行", auto="VENDOR-EVI-007"),
case(8, "树与列表", "边界验证点", "P2", "分类下无指标空态", 15,
["选择无指标的分类(若有)", "断言右表空态明确"],
"空分类下列表空态正确。"),
case(9, "树与列表", "集成验证点", "P1", "启用指标可供方案使用", 18,
["确认状态为启用的指标", "在评价方案维护中核对其可选(只读核对或抽样)"],
"启用指标与方案引用衔接正确。"),
],
)
)
PAGES.append(
page(
code="VENDOR-HLD",
prefix="HLD",
name="供应商节假日管理",
path="/spd/basic/mfrs/basic/prov/holiday",
scan="vendor-holiday",
count=80,
spec="UI-SPEC-SPD-VENDOR-010",
spec_file="vendor-holiday.spec.ts",
iframe=False,
write_note="左侧新增只打开后取消;不批量删除真实数据。",
samples="左侧首行读取 `${sampleHolidayName}`/年度;mock:`__NOT_EXIST_ORDER__99999`。",
cases=[
case(1, "页面进入", "功能验证点", "P0", "菜单进入供应商节假日管理页", 1,
["按菜单进入供应商节假日管理", "断言 URL 含 `prov/holiday`",
"断言可见节假日主区与新增"],
"进入供应商节假日管理页。",
pre="已登录且具备厂商管理权限", auto="VENDOR-HLD-001"),
case(2, "左右分栏", "功能验证点", "P0", "选中左侧节假日展示详情", 9,
["点击左侧首行", "断言默认假日详情或自定义列表区可见"],
"详情联动成功。",
pre="左侧有数据", auto="VENDOR-HLD-002"),
case(3, "左右分栏", "异常验证点", "P1", "左侧搜索无匹配空态(mock反例)", 15,
["左侧搜索框填 mock 并回车", "断言左侧列表空态"],
"无匹配空态明确。", auto="VENDOR-HLD-003"),
case(4, "维护", "功能验证点", "P0", "左侧新增取消", 11,
["点击新增", "取消/关闭不保存", "断言仍停留本页且无异常"],
"取消不落库。", auto="VENDOR-HLD-004"),
case(5, "左右分栏", "功能验证点", "P1", "自定义节假日列表区可见", 12,
["选中左侧行", "断言「供应商自定义节假日列表」区域可见(可为空态)"],
"自定义区可见。", auto="VENDOR-HLD-005"),
case(6, "维护", "异常验证点", "P0", "结束早于开始日期拦截", 16,
["打开新增/编辑,结束日期早于开始日期尝试保存", "断言拒绝保存并提示"],
"非法日期被拦截。"),
case(7, "维护", "边界验证点", "P2", "开始结束为同一天", 15,
["打开新增/编辑,开始与结束日期选同一天尝试保存(或仅校验控件)",
"断言允许保存或明确提示规则(按产品规则),不报错白屏"],
"同日区间规则明确。"),
case(8, "左右分栏", "集成验证点", "P1", "进院供应商主数据一致", 18,
["在自定义列表抽样进院供应商(若有数据)", "断言可在厂商/供应商主数据中找到对应记录"],
"自定义节假日供应商与主数据一致。"),
],
)
)
def tp_id(p: dict, c: dict) -> str:
tp = c["tp"]
if isinstance(tp, int):
return f"TP_VENDOR_{p['prefix']}_{tp:03d}"
return tp
def prio_csv(p: str) -> str:
return {"P0": "高", "P1": "中", "P2": "低"}[p]
def render_md(p: dict) -> str:
lines = [
f"# 基础管理-厂商管理-{p['name']} UI 自动化测试用例",
"",
f"**来源测试点**:`testpoint/基础管理-厂商管理-{p['name']}-测试点分析-01.md` ",
f"**执行 CSV**:`testcase/基础管理-厂商管理-{p['name']}-UI自动化测试用例-01.csv` ",
f"**页面 URL**:`{p['path']}` ",
f"**定位器 YAML**:`locators/spd/{p['scan']}.locator.yaml` ",
f"**元素扫描**:`data/scan/{p['scan']}-elements.json`({p['count']} 项) ",
f"**关联规范**:`knowledge-base/08-UI专项规范库/{p['spec']}.md` ",
f"**版本**:2026-09-30 ",
f"**自动化框架**:Playwright + TypeScript(`tests/spd/{p['spec_file']}`,依赖 `auth.setup` 登录态)",
"",
f"**通用前置**:已执行 `auth.setup`,`storageState` 有效;测试账号具备「基础设置 → 厂商管理 → {p['name']}」权限。 ",
f"**页面约定**:{'主体在 scjs **iframe**。' if p['iframe'] else '整页渲染,**无报表 iframe**。'} ",
f"**写操作**:{p['write_note']} ",
f"**动态取数**:{p['samples']}",
"",
"---",
"",
"## 基础设置 · 厂商管理",
"",
]
current_h3 = ""
current_h4 = ""
for c in p["cases"]:
h3 = f"{p['name']} · {c['h3']}"
if h3 != current_h3:
current_h3 = h3
current_h4 = ""
lines += [f"### {h3}", ""]
if c["h4"] != current_h4:
current_h4 = c["h4"]
lines += [f"#### {current_h4}", ""]
case_id = f"{p['code']}-{c['seq']:03d}"
tc = f"TC-UI-{case_id}"
lines += [
f"##### 【{c['prio']}】【{tc}】【{tp_id(p, c)}】{c['title']}",
"",
f"- 前置条件:{c['pre'] or ('已进入' + p['name'] + '页')}",
"- 操作步骤:",
]
for i, step in enumerate(c["steps"], 1):
lines.append(f" {i}. {step}")
if c.get("auto"):
lines.append(f"- 自动化断言:与 CSV `{c['auto']}` 及 `tests/spd/{p['spec_file']}` 一致")
else:
lines.append("- 自动化断言:本条以手工/后续扩展为主;当前冒烟未覆盖")
lines += ["", "###### 预期结果", "", c["expect"], "", "---", ""]
# trim trailing ---
while lines and lines[-1] in ("", "---"):
lines.pop()
lines.append("")
return "\n".join(lines)
def is_unlogin(c: dict) -> bool:
return "未登录" in c["title"]
def render_csv_rows(p: dict) -> list[dict]:
rows = []
# 账号密码仅存 .env / auth.setup;CSV 不写明文凭据
account = "测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「基础设置 → 厂商管理」权限"
unlogin = "测试环境可访问;使用独立 context,storageState 为空(不加载 test_1 登录态)"
for c in p["cases"]:
case_id = c.get("auto") or f"{p['code']}-{c['seq']:03d}"
if is_unlogin(c):
pre_parts = [unlogin]
if c["pre"]:
pre_parts.append(c["pre"])
steps = "\n".join(f"{i}. {s}" for i, s in enumerate(c["steps"], start=1))
else:
pre_parts = [account]
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),无需在用例内重复登录"]
+ [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)]
)
rows.append(
{
"用例编号": case_id,
"功能模块": "基础设置 · 厂商管理",
"功能测试点": f"{p['name']} · {c['h3']}",
"用例标题": c["title"],
"优先级": prio_csv(c["prio"]),
"前置条件": ";".join(pre_parts),
"测试步骤": steps,
"预期结果": c["expect"],
"实际结果": "",
}
)
return rows
def main() -> None:
MD_DIR.mkdir(parents=True, exist_ok=True)
CSV_DIR.mkdir(parents=True, exist_ok=True)
total = 0
for p in PAGES:
md_path = MD_DIR / f"基础管理-厂商管理-{p['name']}-UI自动化测试用例-01.md"
csv_path = CSV_DIR / f"基础管理-厂商管理-{p['name']}-UI自动化测试用例-01.csv"
md_path.write_text(render_md(p), encoding="utf-8")
rows = render_csv_rows(p)
with csv_path.open("w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(
f,
fieldnames=["用例编号", "功能模块", "功能测试点", "用例标题", "优先级", "前置条件", "测试步骤", "预期结果", "实际结果"],
)
w.writeheader()
w.writerows(rows)
total += len(rows)
print(f"OK {p['code']}\t{len(rows):2d} cases\t{md_path.name}")
print(f"done pages={len(PAGES)} cases={total}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Generate vendor (厂商管理) testpoint MD + patch UI-SPEC URLs from scan summary."""
from __future__ import annotations
import json
import re
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCAN_DIR = ROOT / "data" / "scan"
OUT_DIR = ROOT / "testpoint"
KB = ROOT / "knowledge-base" / "08-UI专项规范库"
TODAY = date.today().isoformat()
PAGES = [
{
"pageKey": "vendor-query",
"specId": "VENDOR-001",
"name": "厂商查询",
"prefix": "TP_VENDOR_QRY",
"file": "基础管理-厂商管理-厂商查询-测试点分析-01.md",
"kind": "query",
},
{
"pageKey": "vendor-qual",
"specId": "VENDOR-002",
"name": "供应商资质管理",
"prefix": "TP_VENDOR_QUL",
"file": "基础管理-厂商管理-供应商资质管理-测试点分析-01.md",
"kind": "qual",
},
{
"pageKey": "vendor-auth-first",
"specId": "VENDOR-003",
"name": "授权变更初审",
"prefix": "TP_VENDOR_AF1",
"file": "基础管理-厂商管理-授权变更初审-测试点分析-01.md",
"kind": "audit",
"peer": "授权变更终审",
"doc_type": "授权变更",
},
{
"pageKey": "vendor-auth-final",
"specId": "VENDOR-004",
"name": "授权变更终审",
"prefix": "TP_VENDOR_AF2",
"file": "基础管理-厂商管理-授权变更终审-测试点分析-01.md",
"kind": "audit",
"peer": "授权变更初审",
"doc_type": "授权变更",
},
{
"pageKey": "vendor-cert-first",
"specId": "VENDOR-005",
"name": "注册证变更初审",
"prefix": "TP_VENDOR_CF1",
"file": "基础管理-厂商管理-注册证变更初审-测试点分析-01.md",
"kind": "audit",
"peer": "注册证变更终审",
"doc_type": "注册证变更",
},
{
"pageKey": "vendor-cert-final",
"specId": "VENDOR-006",
"name": "注册证变更终审",
"prefix": "TP_VENDOR_CF2",
"file": "基础管理-厂商管理-注册证变更终审-测试点分析-01.md",
"kind": "audit",
"peer": "注册证变更初审",
"doc_type": "注册证变更",
},
{
"pageKey": "vendor-cert-diff",
"specId": "VENDOR-007",
"name": "注册证差异对比",
"prefix": "TP_VENDOR_CDF",
"file": "基础管理-厂商管理-注册证差异对比-测试点分析-01.md",
"kind": "diff",
},
{
"pageKey": "vendor-eval-scheme",
"specId": "VENDOR-008",
"name": "评价方案维护",
"prefix": "TP_VENDOR_EVS",
"file": "基础管理-厂商管理-评价方案维护-测试点分析-01.md",
"kind": "scheme",
},
{
"pageKey": "vendor-eval-indicator",
"specId": "VENDOR-009",
"name": "评价指标维护",
"prefix": "TP_VENDOR_EVI",
"file": "基础管理-厂商管理-评价指标维护-测试点分析-01.md",
"kind": "indicator",
},
{
"pageKey": "vendor-holiday",
"specId": "VENDOR-010",
"name": "供应商节假日管理",
"prefix": "TP_VENDOR_HLD",
"file": "基础管理-厂商管理-供应商节假日管理-测试点分析-01.md",
"kind": "holiday",
},
]
def load_summary() -> dict[str, dict]:
path = SCAN_DIR / "vendor-scan-summary.json"
rows = json.loads(path.read_text(encoding="utf-8"))
return {r["page"]: r for r in rows if r.get("ok")}
def tp(prefix: str, n: int, prio: str, title: str, check: str, spec: str) -> str:
return f"- **{prefix}_{n:03d}**({prio})|{title}|验证要点:{check}|关联需求:{spec}"
def section(title: str, blocks: list[tuple[str, list[str]]]) -> str:
parts = [f"### {title}", ""]
for h4, lines in blocks:
parts.append(f"#### {h4}")
parts.append("")
parts.extend(lines or ["- 无"])
parts.append("")
return "\n".join(parts)
def nav_section(p: dict, url: str, spec: str) -> str:
pref = p["prefix"]
name = p["name"]
n = 1
lines_f = [
tp(pref, n, "高", f"菜单路径可达{name}", f"登录后通过「基础设置 → 厂商管理 → {name}」进入,菜单与 Tab 高亮正确", spec),
]
n += 1
lines_f.append(
tp(pref, n, "高", "页面主区加载完成", "整页渲染,筛选/主表/分页或左右分栏关键区可见", spec)
)
n += 1
lines_f.append(
tp(pref, n, "中", "同组厂商管理菜单可切换", f"切到其它厂商管理页再返回{name},URL 与内容对应", spec)
)
lines_b = [
tp(pref, 4, "低", "已登录直接打开 URL", f"携带有效登录态打开 `{url}`,页面正常渲染", spec),
]
lines_e = [
tp(pref, 5, "高", "未登录访问拦截", "无登录态访问跳转登录或不展示业务数据", spec),
tp(pref, 6, "中", "登录态过期", "会话失效后查询/写操作被拦截并提示重新登录", spec),
tp(pref, 7, "中", "无菜单权限", "无权限账号不可见菜单或 URL 被拒绝", spec),
]
lines_i = [
tp(pref, 8, "中", "顶栏院区与厂商范围一致", "列表/筛选中的院区或供应商范围与顶栏院区不矛盾", spec),
]
return section(
"页面访问与导航",
[
("功能验证点", lines_f),
("边界验证点", lines_b),
("异常验证点", lines_e),
("集成验证点", lines_i),
],
), 8
def body_for(p: dict, spec: str, start: int) -> tuple[str, int]:
kind = p["kind"]
pref = p["prefix"]
n = start
def nxt(prio: str, title: str, check: str) -> str:
nonlocal n
n += 1
return tp(pref, n, prio, title, check, spec)
chunks: list[str] = []
if kind == "query":
func_points = [
nxt("高", "器械注册人 Tab", "切换「器械注册人」后列表为注册人数据,分页「共 N 条」与结果一致"),
nxt("高", "经销商 Tab", "切换「经销商」后列表为经销商数据,总数与器械注册人可不同"),
nxt("高", "按厂商名称搜索", "输入厂商名称关键字后结果名称匹配,无匹配时空态明确"),
nxt("高", "列表核心列", "展示序号、厂商编号、厂商名称、产地、详细地址、简称、开户银行、银行账号"),
nxt("中", "分页翻页", "总条数大于一页时可翻至第 2 页,页码高亮且首行更换;不足 2 页则该点不适用(自动化 `VENDOR-QRY-008`)"),
]
bound_points = [
nxt("中", "超长厂商名称关键字", "超长输入可查询或被截断并提示,不白屏"),
nxt("低", "仅空格关键字", "按空查询处理或提示,不报错"),
]
except_points = [
nxt("中", "查询接口失败", "失败有错误提示,列表不白屏"),
nxt("低", "无匹配空态", "总数为 0,空态文案明确"),
]
integ_points = [
nxt("中", "Tab 与分页总数一致", "当前 Tab 下可见行逻辑与「共 N 条」一致"),
]
func_points += [
nxt("中", "清空搜索恢复更宽结果", "按厂商名称过滤后清空搜索并回车,分页总数不少于过滤后(自动化 `VENDOR-QRY-005`)"),
nxt("中", "切换每页条数", "当前页行数 = min(总条数, 所选档位);总条数不变;切换 Tab 后分页重置合理;无数据则不适用"),
]
chunks.append(
section(
"Tab、搜索与列表",
[
("功能验证点", func_points),
("边界验证点", bound_points),
("异常验证点", except_points),
("集成验证点", integ_points),
],
)
)
elif kind == "qual":
chunks.append(
section(
"证件 Tab、效期与列表",
[
(
"功能验证点",
[
nxt("高", "证件分类 Tab 切换", "证件效期提醒/授权链路效期预警/供应商三证/器械注册人/注册证号/授权书/承诺书/法人委托书/二级供应商三证可切换且列表字段随 Tab 变化"),
nxt("高", "效期快捷筛选", "近三个月到期、近一个月到期、已过期切换后列表与 Tab 计数一致"),
nxt("高", "证件效期提醒筛选", "按进院供应商名称、证件类型查询,结果匹配"),
nxt("高", "授权链路预警筛选", "按授权书名称、授权单位、被授权单位查询,结果匹配"),
nxt("中", "重置筛选", "重置后条件恢复默认,列表按默认规则刷新"),
nxt("高", "行内查看", "点击「查看」打开证件/授权详情,关键字段与列表行一致"),
nxt("中", "导出", "导出/导出明细与当前 Tab+筛选条件一致"),
],
),
(
"边界验证点",
[
nxt("中", "某效期 Tab 计数为 0", "列表空态明确,总数为 0"),
nxt("低", "效期临界日", "即将到期与已过期边界日分类正确(以业务规则为准)"),
],
),
(
"异常验证点",
[
nxt("中", "查询失败", "接口失败有提示,不白屏"),
nxt("中", "导出失败", "导出失败有提示,不误导为成功"),
],
),
(
"集成验证点",
[
nxt("中", "效期提醒文案与有效期至一致", "「N 天后过期/即将过期」与有效期至抽样一致"),
],
),
],
)
)
elif kind == "audit":
doc = p.get("doc_type", "变更")
peer = p.get("peer", "")
is_auth = "授权" in doc
filter_points = [
nxt("高", "制单日期查询", "按制单日期区间查询,结果制单时间落在区间内"),
]
if is_auth:
filter_points.extend(
[
nxt("高", "单号与供应商筛选", "按单号、进院供应商、预转/原供应商查询,结果匹配"),
nxt("高", "状态与变更类型", "状态下拉与变更类型筛选后列表状态/类型匹配"),
]
)
if "初审" in p["name"]:
filter_points.append(nxt("中", "医院ID/医院名称筛选", "有该字段时按医院条件查询结果匹配"))
filter_points.append(nxt("中", "组合查询与重置", "多条件同时生效;重置恢复默认"))
filter_points.extend(
[
nxt(
"高",
"列表核心列",
f"展示进院供应商名称、单号、状态、制单时间、制单人、变更类型及{doc}相关关键列、详情",
),
nxt("高", "打开详情", "行内「详情」展示单据头与明细,单号与列表一致"),
]
)
else:
filter_points.extend(
[
nxt("高", "单号与注册证号筛选", "按单号、注册证号(新)、变更类型、状态查询,结果匹配"),
nxt("中", "变更情况说明筛选", "有该字段时按说明关键字查询或精确匹配"),
nxt("中", "组合查询与重置", "多条件同时生效;重置恢复默认;收起后再展开值保留"),
nxt(
"高",
"列表核心列",
"展示进院供应商名称、单号、状态、制单时间、制单人、变更类型、注册证号(新)、详情",
),
nxt("高", "打开详情", "行内「详情」展示变更前后注册证信息,单号与列表一致"),
]
)
chunks.append(
section(
f"{doc}列表与详情",
[
("功能验证点", filter_points),
(
"边界验证点",
[
nxt("中", "制单日期起止同一天", "仅返回该日单据或空态明确"),
nxt("低", "无匹配单据", "总数为 0,空态明确"),
],
),
(
"异常验证点",
[
nxt("中", "日期开始晚于结束", "拒绝查询或自动纠正并提示"),
nxt("中", "查询失败", "失败有提示,不白屏"),
],
),
(
"集成验证点",
[
nxt(
"高",
f"与{peer}单据衔接",
f"同一业务单在初审/终审列表状态流转一致,不串到非{doc}菜单",
),
],
),
],
)
)
elif kind == "diff":
chunks.append(
section(
"差异筛选与对比结果",
[
(
"功能验证点",
[
nxt("高", "默认查询", "进入后查询有数据或空态明确,分页总数正确"),
nxt("高", "物料/规格/厂家筛选", "按物料名称、规格型号、生产厂家查询,结果匹配"),
nxt("高", "望海与 FLI+ 注册证号筛选", "按两侧注册证号查询,结果匹配"),
nxt("中", "是否医疗器械与状态", "状态下拉与是否医疗器械条件生效"),
nxt("高", "一致/不一致 Tab", "全部、一致、不一致切换后对比结论匹配,计数合理"),
nxt("中", "立即更新", "点击立即更新后时间戳刷新或提示成功,列表可刷新"),
nxt("中", "导出", "导出与当前筛选+Tab 条件一致"),
nxt("高", "对比列完整", "展示物料编号/名称、规格、厂家、医保编码及望海/FLI+注册证号与效期字段"),
],
),
(
"边界验证点",
[
nxt("中", "不一致 Tab 无数据", "空态明确,总数为 0"),
nxt("低", "包含长期有效勾选项", "勾选「包含FLI+长期有效数据」后结果集变化符合规则"),
],
),
(
"异常验证点",
[
nxt("中", "查询或更新失败", "失败有提示,不白屏"),
nxt("中", "iframe/内嵌页加载失败", "内嵌对比页失败有提示,外壳不假成功"),
],
),
(
"集成验证点",
[
nxt("高", "一致行两侧关键字段抽样一致", "标记为一致的行望海与 FLI+ 注册证号/效期抽样相同"),
nxt("高", "不一致行至少一侧差异", "标记为不一致的行两侧字段至少一处不同"),
],
),
],
)
)
elif kind == "scheme":
chunks.append(
section(
"方案筛选与维护",
[
(
"功能验证点",
[
nxt("高", "按创建/启用日期筛选", "按日期区间查询结果落在区间内"),
nxt("高", "方案编码/名称筛选", "按编码、名称查询结果匹配"),
nxt("中", "重置", "重置后条件恢复默认"),
nxt("高", "打开新增", "点击「新增」进入方案编辑,含方案编码/名称等必填区"),
nxt("高", "列表核心列", "展示方案编码、名称、创建人/时间、启用人/时间、停用人/时间、状态、备注、操作"),
nxt("中", "空列表态", "无数据时「暂无数据」空态明确"),
],
),
(
"边界验证点",
[
nxt("中", "起止同一天", "仅返回该日创建/启用的方案"),
],
),
(
"异常验证点",
[
nxt("高", "必填为空提交拦截", "方案编码或名称为空时提交失败并提示"),
nxt("中", "查询失败", "失败有提示"),
],
),
(
"集成验证点",
[
nxt("中", "方案与评价指标可关联", "方案引用的指标在评价指标维护中存在或可配置"),
],
),
],
)
)
elif kind == "indicator":
chunks.append(
section(
"指标分类树与指标列表",
[
(
"功能验证点",
[
nxt("高", "左树加载", "指标分类树可见,可搜索分类名称"),
nxt("高", "选中分类刷新右表", "点击分类后右侧列表为该分类指标"),
nxt("高", "指标编码/名称筛选", "右侧按编码、名称查询结果匹配"),
nxt("高", "列表核心列", "展示评价类型、指标编码、指标名称、状态、操作(编辑/详情/日志)"),
nxt("高", "新增分类", "左侧「新增」可创建分类,树中出现新节点"),
nxt("高", "新增指标", "右侧「新增」可创建指标并回填到列表"),
nxt("中", "编辑/详情/日志", "行内编辑打开回填;详情只读;日志可打开"),
],
),
(
"边界验证点",
[
nxt("中", "分类下无指标", "右表空态明确,总数为 0"),
nxt("低", "分类名超长", "超长被拦截或截断并提示"),
],
),
(
"异常验证点",
[
nxt("高", "必填为空拦截", "指标编码/名称为空提交失败并提示"),
nxt("中", "删除或停用受引用指标", "若被方案引用则拒绝或提示(以产品规则为准)"),
],
),
(
"集成验证点",
[
nxt("中", "状态启用后可供方案使用", "状态为启用的指标可在评价方案中被选择"),
],
),
],
)
)
elif kind == "holiday":
chunks.append(
section(
"默认假日与供应商自定义",
[
(
"功能验证点",
[
nxt("高", "左列表加载", "供应商节假日设置列表展示年度、名称,可按名称/年度搜索"),
nxt("高", "选中行展示默认详情", "点击左侧行后右侧默认假日详情名称/年度/起止/创建人时间与选中行一致"),
nxt("高", "左侧新增/编辑", "新增或编辑默认假日设置后列表与详情刷新"),
nxt("高", "供应商自定义新增", "右侧「新增」可添加进院供应商自定义节假日"),
nxt("中", "批量删除", "勾选自定义行后批量删除,列表条数减少"),
nxt("中", "自定义列表列", "展示进院供应商编码/名称、起止、备注、创建人时间、操作"),
],
),
(
"边界验证点",
[
nxt("中", "起止同一天", "默认或自定义假日起止同一天可保存或按规则提示"),
nxt("低", "自定义列表空态", "无自定义数据时「暂无数据」明确"),
],
),
(
"异常验证点",
[
nxt("高", "结束早于开始", "日期非法时拒绝保存并提示"),
nxt("中", "未勾选批量删除", "无勾选时批量删除不可用或提示"),
],
),
(
"集成验证点",
[
nxt("中", "进院供应商主数据一致", "自定义列表中的进院供应商可在厂商/供应商主数据中找到"),
],
),
],
)
)
else:
chunks.append("### 业务功能\n\n#### 功能验证点\n\n- 无\n")
return "\n".join(chunks), n
VENDOR_REVIEW = {
"query": "① 厂商名称搜索是回车还是即时过滤 ② 器械注册人与经销商分页是否独立 ③ 与供应商资质管理主数据对照口径",
"qual": "① 审批通过/驳回写操作是否纳入自动化(建议只读详情冒烟) ② 证件 Tab 切换后稳定列名 ③ 导出是否含当前 Tab 明细",
"audit": "① 审批通过/驳回写操作是否纳入自动化(建议只读详情冒烟) ② 可操作角色范围",
"diff": "① 内嵌/iframe 对比页加载失败时外壳是否假成功 ② 立即更新后列表刷新时效 ③ 导出是否含当前 Tab",
"scheme": "① 方案引用已停用指标时的拦截规则 ② 删除方案是否二次确认",
"indicator": "① 分类树搜索是即时过滤还是回车 ② 停用指标是否允许被方案继续引用",
"holiday": "① 默认假日与供应商自定义的覆盖优先级 ② 重置是否同时清掉自定义假日",
}
VENDOR_DOWNSTREAM = {
"vendor-query": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-query.spec.ts` + `pages/spd/vendor-query.page.ts`",
"vendor-qual": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-qual.spec.ts` + `pages/spd/vendor-qual.page.ts`",
"vendor-auth-first": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-audit.spec.ts` + `pages/spd/vendor-auth-first.page.ts`",
"vendor-auth-final": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-audit.spec.ts` + `pages/spd/vendor-auth-final.page.ts`",
"vendor-cert-first": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-audit.spec.ts` + `pages/spd/vendor-cert-first.page.ts`",
"vendor-cert-final": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-audit.spec.ts` + `pages/spd/vendor-cert-final.page.ts`",
"vendor-cert-diff": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-cert-diff.spec.ts` + `pages/spd/vendor-cert-diff.page.ts`",
"vendor-eval-scheme": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-eval-scheme.spec.ts` + `pages/spd/vendor-eval-scheme.page.ts`",
"vendor-eval-indicator": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-eval-indicator.spec.ts` + `pages/spd/vendor-eval-indicator.page.ts`",
"vendor-holiday": "已落地 CSV/MD 与 Playwright:`tests/spd/vendor-holiday.spec.ts` + `pages/spd/vendor-holiday.page.ts`",
}
def h3_for(kind: str, p: dict) -> str:
if kind == "query":
return "页面访问与导航、Tab、搜索与列表"
if kind == "qual":
return "页面访问与导航、证件 Tab、效期与列表"
if kind == "audit":
return f"页面访问与导航、{p.get('doc_type', '变更')}列表与详情"
if kind == "diff":
return "页面访问与导航、差异筛选与对比结果"
if kind == "scheme":
return "页面访问与导航、方案筛选与维护"
if kind == "indicator":
return "页面访问与导航、指标分类树与指标列表"
return "页面访问与导航、默认假日与供应商自定义"
def render_testpoint(p: dict, summary: dict) -> str:
row = summary[p["pageKey"]]
url = row["url"]
count = row["elementCount"]
spec = f"UI-SPEC-SPD-{p['specId']}"
nav, last = nav_section(p, url, spec)
body, last = body_for(p, spec, last)
kind = p["kind"]
report = (
"非统计报表页,不适用第四节报表能力清单;本页含内嵌/iframe 对比区,导航与内嵌加载失败须分开断言"
if kind == "diff"
else "非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe"
)
return f"""# 基础管理-厂商管理-{p['name']} — 测试点分析
**关联需求**:`knowledge-base/08-UI专项规范库/{spec}.md`
**页面 URL**:`{url}`
**版本 / 定稿日期**:{TODAY}
**元素扫描**:`data/scan/{p['pageKey']}-elements.json`({count} 项)
**定位器**:`locators/spd/{p['pageKey']}.locator.yaml`
**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围
---
## 基础设置 · 厂商管理 · {p['name']}
{nav}
---
{body}
---
## 质量自检摘要
| 项 | 结论 |
|----|------|
| 范围 | 在范围内:{h3_for(kind, p)}。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 |
| 覆盖 | 已写功能域:{h3_for(kind, p)};共 **{last}** 条原子测试点(未声称全覆盖) |
| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 |
| 去重 | 登录拦截、会话过期与同模块其它厂商管理页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `{p["prefix"]}` |
| 待评审项 | {VENDOR_REVIEW.get(kind, "见正文;无新增冲突")} |
| 报表能力 | {report} |
| 下游 | {VENDOR_DOWNSTREAM.get(p["pageKey"], f"可转结构化 MD/CSV;Playwright `pages/spd/{p['pageKey']}.page.ts`")} |
"""
def patch_ui_spec(p: dict, summary: dict) -> None:
row = summary[p["pageKey"]]
path = KB / f"UI-SPEC-SPD-{p['specId']}.md"
text = path.read_text(encoding="utf-8")
text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1)
text = re.sub(
r'related_tc: "[^"]*"',
f'related_tc: "testpoint/{p["file"]}"',
text,
count=1,
)
url = row["url"]
count = row["elementCount"]
page_key = p["pageKey"]
table = f"""| 项 | 值 |
|----|-----|
| 主框架 URL | `{url}` |
| iframe 选择器 | {"内嵌 scjs iframe(对比页)" if p["kind"] == "diff" else "无(整页渲染,非 iframe 报表)"} |
| 元素扫描 JSON | `data/scan/{page_key}-elements.json`({count} 项) |
| 定位器 YAML | `locators/spd/{page_key}.locator.yaml` |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |"""
text = re.sub(
r"\| 项 \| 值 \|\n\|----\|-----\|\n(?:\| .+\|\n)+",
table + "\n",
text,
count=1,
)
# 自动化建议首条改为已扫描
text = text.replace(
"1. 完成专项元素扫描后,补充 `data/scan/`、`locators/spd/` 与 Page Object。",
f"1. 元素扫描已完成({count} 项):`data/scan/{page_key}-elements.json`;下一步补充 Page Object 与用例。",
)
path.write_text(text, encoding="utf-8")
def main() -> None:
summary = load_summary()
OUT_DIR.mkdir(parents=True, exist_ok=True)
for p in PAGES:
if p["pageKey"] not in summary:
raise SystemExit(f"missing scan summary for {p['pageKey']}")
md = render_testpoint(p, summary)
out = OUT_DIR / p["file"]
out.write_text(md, encoding="utf-8")
patch_ui_spec(p, summary)
print(f"OK {p['specId']}\t{p['name']}\t{summary[p['pageKey']]['elementCount']} el\t{out.name}")
print("done", len(PAGES))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""生成库房管理 / 流程设置结构化 MD(testcases/)与同源 CSV(testcase/)。
正例登录账号可写入 CSV;密码只放 .env TEST_PASS / auth.setup,禁止入库。
反例筛选用 mock(__NOT_EXIST_ZONE__/PROBE/FLOW)。
"""
from __future__ import annotations
import csv
from collections import Counter
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MD_DIR = ROOT / "testcases"
CSV_DIR = ROOT / "testcase"
TODAY = date.today().isoformat()
POSITIVE_USER = "test_1"
CSV_FIELDS = [
"用例编号",
"功能模块",
"功能测试点",
"用例标题",
"优先级",
"前置条件",
"测试步骤",
"预期结果",
"实际结果",
]
def page(**kwargs) -> dict:
return kwargs
def case(
seq: int,
h3: str,
h4: str,
prio: str,
title: str,
tp: int,
steps: list[str],
expect: str,
pre: str = "",
) -> dict:
return {
"seq": seq,
"h3": h3,
"h4": h4,
"prio": prio,
"title": title,
"tp": tp,
"steps": steps,
"expect": expect,
"pre": pre,
}
PAGES = [
page(
code="WH-ZON",
tp_prefix="TP_WH_ZON",
group="库房管理",
name="库区设置",
path="/spd/basic/storehouse/platform/storehouse/area",
scan="warehouse-zone",
count=106,
spec="UI-SPEC-SPD-WH-001",
spec_file="warehouse-zone.spec.ts",
tp_total=74,
nav_note=(
"菜单「基础设置 → 库房管理 → 库区设置」;建议 "
"`openPath('/spd/basic/storehouse/platform/storehouse/area')` 并断言 URL,勿误入探针绑定。"
),
write_note=(
"新增/编辑只打开后取消或点确定做必填拦截,不提交成功保存;"
"不停用/启用落库;人员管理/货位管理只打开后关闭;同步仅点一次并断言树仍在。"
),
samples=(
"从左侧树读取 `${sampleWhName}`,从右侧首行读取 `${sampleZoneNo}`、`${sampleZoneName}`;"
"mock:`__NOT_EXIST_ZONE__99999`。"
),
skip="真实保存新增/编辑、停用启用持久化、接口失败注入、会话过期、无菜单权限、人员/货位子页字段细节",
cases=[
case(
1, "页面进入", "功能验证点", "P0", "菜单进入库区设置页", 1,
[
"按菜单「基础设置 → 库房管理 → 库区设置」进入,或 `openPath` 打开 `/spd/basic/storehouse/platform/storehouse/area`",
"断言 URL 含 `/spd/basic/storehouse/platform/storehouse/area`",
"断言左侧「库房信息」树、右侧库区列表、「新 增」与分页可见",
],
"进入库区设置页;不误入探针绑定;无报错遮罩。",
),
case(
2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
[
"清除登录态后直接打开 `/spd/basic/storehouse/platform/storehouse/area`",
"断言跳转登录或不展示库房/库区业务数据",
],
"未登录不可见库区数据。",
),
case(
3, "左侧库房树", "功能验证点", "P0", "库房树默认有选中项", 9,
[
"断言左侧「库房信息」至少 1 个节点且有选中项",
"读取选中节点 `${sampleWhName}`",
"断言右侧列表为该库房库区,或空态且「共 0 条」",
],
"树有选中;右侧对应当前库房。",
),
case(
4, "左侧库房树", "功能验证点", "P0", "按库房名称过滤树", 10,
[
"读取当前选中 `${sampleWhName}` 取关键字(可截取前若干字)",
"在左侧「库房名称」输入关键字",
"断言可见节点名称均包含该关键字",
],
"树过滤结果与关键字匹配。",
pre="库房树至少 1 个节点",
),
case(
5, "左侧库房树", "功能验证点", "P1", "清空库房名称恢复全量树", 11,
[
"记录过滤前节点数",
"输入关键字过滤后再清空",
"断言节点数恢复为过滤前",
],
"清空后树恢复全量。",
),
case(
6, "左侧库房树", "功能验证点", "P0", "切换库房刷新右侧列表", 12,
[
"记录当前库房 `${sampleWhName}` 与右侧首行库区编号(若有)",
"点选另一库房节点(仅 1 个则 skip)",
"断言「共 N 条」可变化;若有行则库区编号/名称属于新库房",
],
"切换库房后右侧列表刷新,不残留上一库房库区。",
),
case(
7, "左侧库房树", "异常验证点", "P1", "无匹配库房空树(mock反例)", 15,
[
"库房名称填入 `__NOT_EXIST_ZONE__99999`",
"断言树为空或明确无数据,右侧不展示上一库房库区为成功态",
],
"无匹配不报错;不残留上一库房列表当成功。",
),
case(
8, "右侧库区列表与分页", "功能验证点", "P0", "列表核心列完整", 18,
[
"断言表头含库区编号、库区名称、库区地址、是否专项库区、操作",
"若有数据行,断言操作列可见「编辑」「人员管理」「货位管理」及「停用」或「启用」",
],
"核心列完整;行内操入口可见。",
),
case(
9, "右侧库区列表与分页", "功能验证点", "P0", "默认库区以标签展示", 19,
[
"在当前库房列表中查找「默认库区」标签",
"断言该标签出现在库区名称旁,不是独立列",
"断言当前库房至多 1 个默认库区标签(无默认则本条只断言不是独立列)",
],
"默认库区为名称旁标签;同一库房至多一个。",
),
case(
10, "库区搜索", "功能验证点", "P0", "按库区编号搜索", 29,
[
"从首行读取 `${sampleZoneNo}`",
"在「库区编号/名称」填入该编号并触发搜索",
"断言结果包含该编号行,且均属于当前选中库房",
],
"搜索命中该编号;不串其它库房。",
pre="当前库房至少 1 条库区且编号非空",
),
case(
11, "库区搜索", "功能验证点", "P0", "按库区名称搜索", 30,
[
"从首行读取 `${sampleZoneName}` 取关键字",
"在「库区编号/名称」填入关键字并搜索",
"断言结果行名称匹配",
],
"名称关键字命中;无未捕获异常。",
pre="当前库房至少 1 条且名称非空",
),
case(
12, "库区搜索", "异常验证点", "P1", "无匹配库区空态(mock反例)", 34,
[
"「库区编号/名称」填入 `__NOT_EXIST_ZONE__99999` 并搜索",
"断言列表空且分页为 0",
],
"无匹配不报错;总数为 0。",
),
case(
13, "新增库区", "功能验证点", "P0", "打开新增库区弹窗", 36,
[
"点击「新 增」",
"断言弹出「新增库区」,可见库区编号、库区名称、库区地址、联系人、联系方式及「取 消」「确 定」",
],
"新增弹窗可见;关键字段与按钮齐全。",
),
case(
14, "新增库区", "功能验证点", "P1", "新增取消不落库", 38,
[
"打开新增,编号填 `AUTO${timestamp}`,名称填任意合法值",
"点击「取 消」",
"按该编号搜索,断言无该记录",
],
"弹窗关闭;列表不出现该编号。",
),
case(
15, "新增库区", "异常验证点", "P0", "必填为空拦截", 41,
[
"打开新增,库区编号或名称留空后点击「确 定」",
"断言出现必填提示,弹窗仍打开",
"点「取 消」",
],
"确定被拦截;不落库;弹窗不关闭。",
),
case(
16, "编辑库区", "功能验证点", "P0", "打开编辑并回填", 46,
[
"点击首行「编辑」",
"断言弹窗标题含「编辑库区信息」",
"断言库区编号、名称与该行 `${sampleZoneNo}`、`${sampleZoneName}` 一致(允许规范化空白)",
"点「取 消」",
],
"编辑弹窗可见;回填与列表行一致。",
pre="当前库房至少 1 条库区",
),
case(
17, "编辑库区", "功能验证点", "P1", "编辑取消不保存", 48,
[
"打开首行编辑,记录原名称",
"将名称改为临时值后点「取 消」",
"再次打开编辑,断言名称仍为原值",
],
"取消后列表与回看均为修改前名称。",
pre="当前库房至少 1 条库区",
),
case(
18, "编辑库区", "异常验证点", "P0", "编辑清空必填拦截", 52,
[
"打开首行编辑,清空库区名称后点「确 定」",
"断言被拦截并提示,弹窗仍开",
"点「取 消」,断言列表名称未被写成空",
],
"清空必填不能保存;原记录名称保持。",
pre="当前库房至少 1 条库区",
),
case(
19, "人员管理", "功能验证点", "P0", "打开人员管理后关闭", 61,
[
"点击首行「人员管理」",
"断言打开弹层或子页,标题含人员管理或对应当前库区",
"关闭后断言仍在库区设置,当前库房选中保持",
],
"人员管理可打开;关闭后回到当前库房列表。本条不改人员。",
pre="当前库房至少 1 条库区",
),
case(
20, "货位管理", "功能验证点", "P0", "打开货位管理后关闭", 66,
[
"点击首行「货位管理」",
"断言打开弹层或子页,标题含货位且对应当前库区",
"关闭后断言库房选中与库区列表保持",
],
"货位管理可打开;关闭后列表状态保持。本条不改货位。",
pre="当前库房至少 1 条库区",
),
],
),
page(
code="WH-PRB",
tp_prefix="TP_WH_PRB",
group="库房管理",
name="探针绑定",
path="/spd/basic/storehouse/storehouse/probe",
scan="warehouse-probe",
count=78,
spec="UI-SPEC-SPD-WH-002",
spec_file="warehouse-probe.spec.ts",
tp_total=49,
nav_note=(
"菜单「基础设置 → 库房管理 → 探针绑定」;建议 "
"`openPath('/spd/basic/storehouse/storehouse/probe')` 并断言 URL。"
),
write_note="绑定货位只打开弹窗后取消,不点确定保存;不改绑定关系。",
samples=(
"查询后从首行读取 `${sampleProbeId}`、`${sampleHostCode}`、`${sampleBindFlag}`、`${sampleBindCount}`;"
"mock:`__NOT_EXIST_PROBE__99999`。"
),
skip="真实勾选确定绑定、换绑/占用规则落库、接口失败、会话过期、无菜单权限",
cases=[
case(
1, "页面进入", "功能验证点", "P0", "菜单进入探针绑定页", 1,
[
"按菜单「基础设置 → 库房管理 → 探针绑定」进入,或 `openPath` 打开 `/spd/basic/storehouse/storehouse/probe`",
"断言 URL 含 `/spd/basic/storehouse/storehouse/probe`",
"断言探针ID、主机编码、是否绑定货位、查询、重置、列表与分页可见",
],
"进入探针绑定页;不误入库区设置。",
),
case(
2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
[
"清除登录态后直接打开 `/spd/basic/storehouse/storehouse/probe`",
"断言跳转登录或不展示探针业务列表",
],
"未登录不可见探针数据。",
),
case(
3, "筛选与查询", "功能验证点", "P0", "默认查询列表有数据或空态明确", 9,
[
"点击「查 询」并等待列表稳定",
"断言出现数据行,或「暂无数据」且分页总数为 0",
],
"查询无报错;有数据时「共 N 条」N≥1。",
),
case(
4, "筛选与查询", "功能验证点", "P0", "按探针ID查询", 10,
[
"从首行读取 `${sampleProbeId}`",
"探针ID填入该值,点击「查 询」",
"断言结果探针ID匹配,精确命中时行数合理",
],
"探针ID条件生效。",
pre="默认查询至少 1 条且探针ID非空",
),
case(
5, "筛选与查询", "功能验证点", "P0", "按主机编码查询", 11,
[
"从首行读取 `${sampleHostCode}`",
"主机编码填入该值,点击「查 询」",
"断言结果行「主机编码」与条件一致",
],
"主机编码条件生效;允许多探针共用同一主机。",
pre="默认查询至少 1 条且主机编码非空",
),
case(
6, "筛选与查询", "功能验证点", "P0", "按是否绑定货位查询", 12,
[
"是否绑定货位选择「已绑定」,点击「查 询」",
"断言结果「是否绑定货位」均为已绑定,且「绑定的货位」>0",
"再选「未绑定」查询,断言均为未绑定且数量为 0(无数据则空态)",
],
"已绑定数量大于 0;未绑定数量为 0。",
),
case(
7, "筛选与查询", "功能验证点", "P0", "探针ID+主机编码组合查询", 13,
[
"填写 `${sampleProbeId}` 与 `${sampleHostCode}` 后点击「查 询」",
"断言结果同时满足已填条件",
],
"组合条件同时生效。",
pre="默认查询至少 1 条",
),
case(
8, "筛选与查询", "功能验证点", "P1", "重置恢复默认", 14,
[
"填写探针ID后点击「重 置」",
"断言条件恢复默认,列表按默认规则刷新",
],
"条件恢复默认;列表刷新。",
),
case(
9, "筛选与查询", "异常验证点", "P1", "无匹配空态(mock反例)", 18,
[
"探针ID填入 `__NOT_EXIST_PROBE__99999`,点击「查 询」",
"断言空态且分页总数为 0",
],
"无匹配不报错;总数为 0。",
),
case(
10, "列表与分页", "功能验证点", "P0", "列表核心列与数量规则", 20,
[
"断言表头含序号、探针ID、主机编码、是否绑定货位、绑定的货位、操作",
"抽样已绑定行:绑定数量为数字且 >0;抽样未绑定行:数量为 0",
"断言「绑定的货位」列不是货位号格式",
],
"核心列完整;数量列是数字不是货位号。",
),
case(
11, "打开绑定货位弹窗", "功能验证点", "P0", "行内打开货位绑定探针", 30,
[
"点击首行「绑定货位」",
"断言弹出「货位绑定探针」,可见当前库房/库区/货位查询及货位列表",
],
"弹窗标题正确;筛选与列表可见。",
pre="列表至少 1 条",
),
case(
12, "打开绑定货位弹窗", "功能验证点", "P1", "取消关闭不改主列表", 31,
[
"记录主列表首行绑定数量与当前筛选",
"打开绑定弹窗后点「取 消」",
"断言主列表绑定数量与筛选条件保持",
],
"取消不改绑定关系。",
pre="列表至少 1 条",
),
case(
13, "打开绑定货位弹窗", "边界验证点", "P1", "未选货位时确定计数为 0", 32,
[
"打开绑定弹窗,不勾选货位",
"断言底部按钮文案为「确定(0)」或等价",
"点「取 消」,不点确定保存",
],
"未选时计数为 0;本条不保存绑定。",
pre="列表至少 1 条",
),
case(
14, "打开绑定货位弹窗", "集成验证点", "P1", "弹窗分页独立于主列表", 34,
[
"记录主列表「共 N 条」",
"打开绑定弹窗,记录弹窗分页「共 M 条」",
"断言不得用 M 断言主列表 N(货位量级可远大于探针条数)",
"点「取 消」",
],
"弹窗总条数与主列表探针条数相互独立。",
pre="列表至少 1 条",
),
case(
15, "弹窗筛选货位", "功能验证点", "P0", "按当前库房筛选货位", 35,
[
"打开绑定弹窗,记录默认 `${sampleModalWh}`",
"点「查 询」",
"断言可见行「当前库房」与所选库房一致(有数据时)",
"点「取 消」",
],
"弹窗库房条件生效。",
pre="列表至少 1 条",
),
case(
16, "弹窗筛选货位", "功能验证点", "P1", "弹窗内重置", 38,
[
"打开绑定弹窗,填写货位查询关键字后点「重 置」",
"断言货位查询条件恢复,列表按弹窗默认规则刷新",
"点「取 消」",
],
"弹窗重置不影响主列表筛选。",
pre="列表至少 1 条",
),
case(
17, "勾选货位并保存绑定", "功能验证点", "P1", "勾选后确定计数增加再取消勾选", 43,
[
"打开绑定弹窗,勾选 1 个货位",
"断言确定按钮计数为 1",
"取消该勾选,断言计数回到 0",
"点「取 消」,不保存",
],
"计数随勾选变化;取消勾选不落库。",
pre="弹窗货位列表至少 1 条",
),
],
),
page(
code="FLOW-DEF",
tp_prefix="TP_FLOW_DEF",
group="流程设置",
name="审批流程设置",
path="/spd/basic/flowSet/workflow/flowSet",
scan="flow-def",
count=43,
spec="UI-SPEC-SPD-FLOW-001",
spec_file="flow-def.spec.ts",
tp_total=41,
nav_note=(
"菜单「基础设置 → 流程设置 → 审批流程设置」;建议 "
"`openPath('/spd/basic/flowSet/workflow/flowSet')`,与「审批流实例」用 exact 区分。"
),
write_note=(
"禁止点「保 存」提交线上审批路由。新增流程/改节点后必须「重 置」或取消条件配置;"
"必填拦截若需点保存,仅空行拦截成功后立刻重置。"
),
samples=(
"从左侧读取 `${sampleBizType}`(如采购计划);自定义行读取 `${sampleFlowName}`、`${sampleCondText}`;"
"mock:`__NOT_EXIST_FLOW__99999`。"
),
skip="真实保存审批链、自定义流持久化、条件保存落库、历史实例快照、接口失败、会话过期",
cases=[
case(
1, "页面进入", "功能验证点", "P0", "菜单进入审批流程设置页", 1,
[
"按菜单「基础设置 → 流程设置 → 审批流程设置」进入,或 `openPath` 打开 `/spd/basic/flowSet/workflow/flowSet`",
"断言 URL 含 `/spd/basic/flowSet/workflow/flowSet`",
"断言左侧业务类型、右侧默认审批流与自定义审批流、「保 存」「重 置」可见",
],
"进入审批流程设置;不误入审批流实例。",
),
case(
2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
[
"清除登录态后直接打开 `/spd/basic/flowSet/workflow/flowSet`",
"断言跳转登录或不展示审批流配置",
],
"未登录不可见审批流配置。",
),
case(
3, "左侧业务类型", "功能验证点", "P0", "业务类型列表可见", 9,
[
"断言左侧可见采购计划等类型且有当前高亮项",
"读取 `${sampleBizType}`",
],
"类型列表可见;当前项高亮。",
),
case(
4, "左侧业务类型", "功能验证点", "P0", "按流程名称过滤类型", 10,
[
"在「流程名称」输入 `${sampleBizType}` 关键字",
"断言可见类型名称均匹配关键字",
],
"类型过滤与关键字匹配。",
),
case(
5, "左侧业务类型", "功能验证点", "P1", "清空关键字恢复全量", 11,
[
"记录过滤前类型数量",
"输入关键字后再清空",
"断言数量恢复",
],
"清空后类型列表恢复全量。",
),
case(
6, "左侧业务类型", "功能验证点", "P0", "切换类型刷新右侧配置", 12,
[
"记录当前类型默认流节点文案",
"点选另一业务类型(仅 1 个则 skip)",
"断言右侧默认流/自定义流已刷新,不残留上一类型节点人员",
],
"切换类型后右侧与该类型已保存配置一致。",
),
case(
7, "左侧业务类型", "异常验证点", "P1", "无匹配类型(mock反例)", 15,
[
"流程名称填入 `__NOT_EXIST_FLOW__99999`",
"断言左侧无匹配或空,右侧不把上一类型配置展示为当前成功配置",
],
"无匹配不报错。",
),
case(
8, "默认审批流", "功能验证点", "P0", "默认审批流节点可见", 17,
[
"选中「采购计划」(或当前默认类型)",
"断言「默认审批流」区域可见节点卡片,含节点/账号/人员三行信息(按卡片容器取文本)",
],
"默认流节点结构可见;不点保存。",
),
case(
9, "自定义审批流", "功能验证点", "P0", "新增流程增加一行后重置", 22,
[
"记录当前自定义流行数",
"点击「新增流程」",
"断言行数 +1,新行含流程名称、条件、审批流程、删除",
"点击「重 置」,断言行数回到操作前,不点「保 存」",
],
"可新增行;重置放弃未保存行。",
),
case(
10, "自定义审批流", "异常验证点", "P0", "空行保存被拦截后重置", 28,
[
"点击「新增流程」得到空行",
"点击「保 存」",
"断言出现必填拦截提示,配置未按成功态关闭",
"点击「重 置」,放弃该空行",
],
"空名称/条件/节点不能落库;拦截后重置恢复。",
),
case(
11, "条件配置", "功能验证点", "P0", "打开条件配置后取消", 31,
[
"若无自定义行则 skip",
"点击首条自定义流条件列「编辑」",
"断言打开「条件配置」",
"点「取 消」,断言行内条件文案与打开前一致",
],
"条件配置可打开;取消不改行内条件。",
pre="当前类型至少 1 条自定义流",
),
case(
12, "保存与重置", "功能验证点", "P0", "重置放弃未保存改动", 38,
[
"点击「新增流程」增加一行",
"点击「重 置」",
"断言自定义流行回到上次保存结果",
],
"重置不提交;未保存行消失。",
),
case(
13, "左侧业务类型", "集成验证点", "P1", "类型与审批流实例业务类型同源", 16,
[
"记录本页左侧类型名称集合(可见范围)",
"打开审批流实例,展开「业务类型」下拉",
"断言选项与本页类型同源(允许按权限裁剪,本页有的关键类型如采购计划应能对应)",
],
"两页业务类型同源。",
),
],
),
page(
code="FLOW-INS",
tp_prefix="TP_FLOW_INS",
group="流程设置",
name="审批流实例",
path="/spd/basic/flowSet/workflow/flowCase",
scan="flow-inst",
count=62,
spec="UI-SPEC-SPD-FLOW-002",
spec_file="flow-inst.spec.ts",
tp_total=41,
nav_note=(
"菜单「基础设置 → 流程设置 → 审批流实例」;建议 "
"`openPath('/spd/basic/flowSet/workflow/flowCase')`,不要点到审批流程设置。"
),
write_note="本页只读。详情只打开后返回,不改审批人。断言「共 N 条」前先收窄日期。",
samples=(
"查询后从首行读取 `${sampleOrderNo}`、`${sampleBizType}`、`${sampleStatus}`、`${sampleSummary}`;"
"mock:`__NOT_EXIST_FLOW__99999`。"
),
skip="改审批人、接口失败、会话过期、无菜单权限、详情与流程设置节点逐条对照(可另开集成专项)",
cases=[
case(
1, "页面进入", "功能验证点", "P0", "菜单进入审批流实例页", 1,
[
"按菜单「基础设置 → 流程设置 → 审批流实例」进入,或 `openPath` 打开 `/spd/basic/flowSet/workflow/flowCase`",
"断言 URL 含 `/spd/basic/flowSet/workflow/flowCase`",
"断言单据编号、审批状态、发起时间、业务类型、摘要、查询、重置、列表与分页可见",
],
"进入审批流实例;不误入审批流程设置。",
),
case(
2, "页面进入", "异常验证点", "P0", "未登录访问拦截", 5,
[
"清除登录态后直接打开 `/spd/basic/flowSet/workflow/flowCase`",
"断言跳转登录或不展示审批单据列表",
],
"未登录不可见审批实例。",
),
case(
3, "筛选与查询", "功能验证点", "P0", "默认日期区间查询", 9,
[
"点击「查 询」并等待列表稳定",
"断言出现数据行或空态明确,「共 N 条」与结果逻辑一致",
],
"默认查询无报错;有数据时 N≥1。",
),
case(
4, "筛选与查询", "功能验证点", "P0", "单据编号精确查询", 10,
[
"从首行读取 `${sampleOrderNo}`",
"单据编号填入完整单号,点击「查 询」",
"断言至多 1 条,且编号、业务类型与该行一致",
],
"精确单号命中当前行。",
pre="默认查询至少 1 条且单据编号非空",
),
case(
5, "筛选与查询", "功能验证点", "P0", "按审批状态筛选", 11,
[
"审批状态下拉选择「审批通过」(无该选项则选列表中已出现的状态)",
"点击「查 询」",
"断言结果「审批状态」列与所选一致",
],
"状态筛选生效。",
),
case(
6, "筛选与查询", "功能验证点", "P0", "按业务类型筛选", 12,
[
"从首行读取 `${sampleBizType}`",
"业务类型选择该值,点击「查 询」",
"断言结果「业务类型」均为该类型",
],
"业务类型筛选生效。",
pre="默认查询至少 1 条",
),
case(
7, "筛选与查询", "功能验证点", "P0", "按发起时间区间筛选", 13,
[
"记录当前默认起止日期",
"点击「查 询」",
"抽样结果行「发起时间」落在起止日内(含边界)",
],
"发起时间落在区间内。",
),
case(
8, "筛选与查询", "功能验证点", "P1", "按摘要关键字筛选", 14,
[
"找到摘要非 `--` 的行读取 `${sampleSummary}` 关键字;若全为 `--` 则填 mock 关键字",
"点击「查 询」",
"有关键字命中时摘要包含该关键字;mock 无命中时空态且 `--` 行不被命中",
],
"非空摘要可检索;`--` 不被非空关键字命中。",
),
case(
9, "筛选与查询", "功能验证点", "P0", "状态+类型组合查询", 15,
[
"填写 `${sampleStatus}` 与 `${sampleBizType}` 后点击「查 询」",
"断言抽样行同时满足全部已填条件",
],
"组合条件同时生效。",
pre="默认查询至少 1 条",
),
case(
10, "筛选与查询", "功能验证点", "P1", "重置恢复默认", 16,
[
"填写单据编号等条件后点击「重 置」",
"断言日期回到默认区间,其余条件清空,列表刷新",
],
"重置后日期与其它条件恢复默认。",
),
case(
11, "筛选与查询", "异常验证点", "P1", "无匹配空态(mock反例)", 21,
[
"单据编号填入 `__NOT_EXIST_FLOW__99999`,点击「查 询」",
"断言空态且分页总数为 0",
],
"无匹配不报错;总数为 0。",
),
case(
12, "列表与分页", "功能验证点", "P0", "列表核心列完整", 24,
[
"断言表头含序号、单据编号、业务类型、发起人、发起部门、发起时间、摘要、审批状态、操作",
"断言行内可见「详情」",
],
"核心列完整;详情入口可见。",
),
case(
13, "列表与分页", "功能验证点", "P1", "单据编号前缀与业务类型对应", 25,
[
"抽样 `PLAN`/`DB`/`GC`/`BUY` 前缀行(有则断言)",
"断言 PLAN→采购计划、DB→二级库调拨、GC→产品信息变更、BUY→请领单(以现网枚举为准,缺前缀 skip 该项)",
],
"前缀与类型对应。",
pre="默认查询有数据",
),
case(
14, "列表与分页", "功能验证点", "P1", "分页翻至第 2 页", 28,
[
"记录第 1 页首行单据编号",
"翻到第 2 页",
"断言数据更换;筛选条件保持",
],
"翻页成功;筛选不丢失。",
pre="总条数大于一页,否则 skip",
),
case(
15, "审批详情", "功能验证点", "P0", "打开详情对应当前行", 36,
[
"记录首行 `${sampleOrderNo}`",
"点击「详情」",
"断言打开该单审批过程,标题或单号含 `${sampleOrderNo}`",
],
"详情单号与当前行一致。",
pre="列表至少 1 条",
),
case(
16, "审批详情", "功能验证点", "P1", "关闭详情保留列表状态", 38,
[
"填写单据编号查询后打开详情",
"点返回/关闭",
"断言筛选条件与列表仍是打开前的查询结果",
],
"返回后条件与页码保持。",
pre="列表至少 1 条",
),
case(
17, "审批详情", "功能验证点", "P1", "详情只读", 37,
[
"打开首行详情",
"断言无保存审批人/改条件的提交按钮,或此类控件不可用",
"返回列表",
],
"详情只读;改配置须到审批流程设置。",
pre="列表至少 1 条",
),
],
),
]
def tp_id(p: dict, c: dict) -> str:
return f"{p['tp_prefix']}_{c['tp']:03d}"
def prio_csv(prio: str) -> str:
return {"P0": "高", "P1": "中", "P2": "低"}[prio]
def is_unlogin(c: dict) -> bool:
return "未登录" in c["title"]
def render_csv_rows(p: dict) -> list[dict]:
logged = (
f"测试环境可访问;正例账号 {POSITIVE_USER} 已通过 auth.setup 写入 storageState 登录"
f"(密码仅存 .env TEST_PASS,勿写入本表);具备「基础设置 → {p['group']} → {p['name']}」权限"
)
unlogin = (
f"测试环境可访问;使用独立 context,storageState 为空(不加载 {POSITIVE_USER} 登录态)"
)
rows: list[dict] = []
for c in p["cases"]:
case_id = f"{p['code']}-{c['seq']:03d}"
if is_unlogin(c):
pre_parts = [unlogin]
if c["pre"]:
pre_parts.append(c["pre"])
steps = "\n".join(
["1. 新建独立 BrowserContext,不加载 auth/user.json"]
+ [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)]
)
else:
pre_parts = [logged]
if c["seq"] > 1:
pre_parts.append(f"已进入{p['name']}页({p['path']},无 iframe)")
if c["pre"]:
pre_parts.append(c["pre"])
steps = "\n".join(
[
f"1. 确认 chromium 已加载 storageState(auth.setup,正例账号 {POSITIVE_USER}),无需在用例内重复登录"
]
+ [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)]
)
rows.append(
{
"用例编号": case_id,
"功能模块": f"基础设置 · {p['group']}",
"功能测试点": f"{p['name']} · {c['h3']}",
"用例标题": c["title"],
"优先级": prio_csv(c["prio"]),
"前置条件": ";".join(pre_parts),
"测试步骤": steps,
"预期结果": c["expect"],
"实际结果": "",
}
)
return rows
def render_md(p: dict) -> str:
n = len(p["cases"])
cnt = Counter(c["prio"] for c in p["cases"])
prio_txt = " / ".join(f"{k}={cnt[k]}" for k in ("P0", "P1", "P2") if cnt[k])
lines = [
f"# 基础管理-{p['group']}-{p['name']} UI 自动化测试用例",
"",
f"**来源测试点**:`testpoint/基础管理-{p['group']}-{p['name']}-测试点分析-01.md` ",
f"**执行 CSV**:`testcase/基础管理-{p['group']}-{p['name']}-UI自动化测试用例-01.csv` ",
f"**页面 URL**:`{p['path']}` ",
f"**定位器 YAML**:`locators/spd/{p['scan']}.locator.yaml` ",
f"**元素扫描**:`data/scan/{p['scan']}-elements.json`({p['count']} 项) ",
f"**关联规范**:`knowledge-base/08-UI专项规范库/{p['spec']}.md` ",
f"**版本**:{TODAY} ",
f"**自动化框架**:Playwright + TypeScript(规划 `tests/spd/{p['spec_file']}`,依赖 `auth.setup` 登录态)",
"",
f"**通用前置**:正例账号 `{POSITIVE_USER}` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`);具备「基础设置 → {p['group']} → {p['name']}」权限。反例筛选用 mock。 ",
"**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上。 ",
f"**菜单导航**:{p['nav_note']} ",
f"**写操作**:{p['write_note']} ",
f"**动态取数**:{p['samples']}",
"",
"---",
"",
f"## 基础设置 · {p['group']}",
"",
]
current_h3 = ""
current_h4 = ""
for c in p["cases"]:
h3 = f"{p['name']} · {c['h3']}"
if h3 != current_h3:
current_h3 = h3
current_h4 = ""
lines += [f"### {h3}", ""]
if c["h4"] != current_h4:
current_h4 = c["h4"]
lines += [f"#### {current_h4}", ""]
case_id = f"{p['code']}-{c['seq']:03d}"
tc = f"TC-UI-{case_id}"
lines += [
f"##### 【{c['prio']}】【{tc}】【{tp_id(p, c)}】{c['title']}",
"",
f"- 前置条件:{c['pre'] or ('已进入' + p['name'] + '页')}",
"- 操作步骤:",
]
for i, step in enumerate(c["steps"], 1):
lines.append(f" {i}. {step}")
lines.append(f"- 自动化断言:规划与 CSV `{case_id}` 及 `tests/spd/{p['spec_file']}` 一致")
lines += ["", "###### 预期结果", "", c["expect"], "", "---", ""]
while lines and lines[-1] in ("", "---"):
lines.pop()
lines += [
"",
"",
"## 质量与覆盖说明",
"",
"| 项 | 说明 |",
"|----|------|",
f"| 用例数 | **{n}** 条({prio_txt}) |",
f"| 来源 | 从 {p['tp_total']} 条测试点选取可稳定自动化的主路径与关键反向 |",
f"| 未纳入自动化 | {p['skip']} |",
"| 单一职责 | 每条只断言一个核心业务结果 |",
"| 独立性 | 每条独立 `open()`,不复用 BrowserContext;不依赖上一条写结果 |",
"",
]
return "\n".join(lines)
def main() -> None:
MD_DIR.mkdir(parents=True, exist_ok=True)
CSV_DIR.mkdir(parents=True, exist_ok=True)
total = 0
for p in PAGES:
seqs = [c["seq"] for c in p["cases"]]
if seqs != list(range(1, len(seqs) + 1)):
raise SystemExit(f"{p['code']} seq not consecutive: {seqs}")
md_path = MD_DIR / f"基础管理-{p['group']}-{p['name']}-UI自动化测试用例-01.md"
csv_path = CSV_DIR / f"基础管理-{p['group']}-{p['name']}-UI自动化测试用例-01.csv"
md_path.write_text(render_md(p), encoding="utf-8")
rows = render_csv_rows(p)
with csv_path.open("w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=CSV_FIELDS)
w.writeheader()
w.writerows(rows)
total += len(rows)
print(f"OK {p['code']}\t{len(rows):2d} cases\t{md_path.name}\t{csv_path.name}")
print(f"done pages={len(PAGES)} cases={total}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Generate 库房管理 / 流程设置 testpoint MD and patch UI-SPEC / INDEX."""
from __future__ import annotations
import json
import re
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCAN_DIR = ROOT / "data" / "scan"
OUT_DIR = ROOT / "testpoint"
KB = ROOT / "knowledge-base" / "08-UI专项规范库"
INDEX = KB / "INDEX.md"
TODAY = date.today().isoformat()
PAGES = [
{
"pageKey": "warehouse-zone",
"specId": "WH-001",
"group": "库房管理",
"name": "库区设置",
"prefix": "TP_WH_ZON",
"file": "基础管理-库房管理-库区设置-测试点分析-01.md",
"kind": "zone",
"path": "/spd/basic/storehouse/platform/storehouse/area",
"po": "pages/spd/warehouse-zone.page.ts",
"scan_cmd": "discover:scan-warehouse",
},
{
"pageKey": "warehouse-probe",
"specId": "WH-002",
"group": "库房管理",
"name": "探针绑定",
"prefix": "TP_WH_PRB",
"file": "基础管理-库房管理-探针绑定-测试点分析-01.md",
"kind": "probe",
"path": "/spd/basic/storehouse/storehouse/probe",
"po": "pages/spd/warehouse-probe.page.ts",
"scan_cmd": "discover:scan-warehouse",
},
{
"pageKey": "flow-def",
"specId": "FLOW-001",
"group": "流程设置",
"name": "审批流程设置",
"prefix": "TP_FLOW_DEF",
"file": "基础管理-流程设置-审批流程设置-测试点分析-01.md",
"kind": "def",
"path": "/spd/basic/flowSet/workflow/flowSet",
"po": "pages/spd/flow-def.page.ts",
"scan_cmd": "discover:scan-flow",
},
{
"pageKey": "flow-inst",
"specId": "FLOW-002",
"group": "流程设置",
"name": "审批流实例",
"prefix": "TP_FLOW_INS",
"file": "基础管理-流程设置-审批流实例-测试点分析-01.md",
"kind": "inst",
"path": "/spd/basic/flowSet/workflow/flowCase",
"po": "pages/spd/flow-inst.page.ts",
"scan_cmd": "discover:scan-flow",
},
]
def load_scan(page_key: str) -> dict:
path = SCAN_DIR / f"{page_key}-elements.json"
data = json.loads(path.read_text(encoding="utf-8"))
return {"url": data["url"].split("?")[0], "elementCount": data["elementCount"]}
def tp(prefix: str, n: int, prio: str, title: str, check: str, spec: str) -> str:
return f"- **{prefix}_{n:03d}**({prio})|{title}|验证要点:{check}|关联需求:{spec}"
def section(title: str, blocks: list[tuple[str, list[str]]]) -> str:
parts = [f"### {title}", ""]
for h4, lines in blocks:
parts.append(f"#### {h4}")
parts.append("")
parts.extend(lines or ["- 无(本功能点无独立该类场景,规则已归入其它功能点)"])
parts.append("")
return "\n".join(parts)
class Seq:
def __init__(self, prefix: str, spec: str):
self.prefix = prefix
self.spec = spec
self.n = 0
def add(self, prio: str, title: str, check: str) -> str:
self.n += 1
return tp(self.prefix, self.n, prio, title, check, self.spec)
def nav_section(p: dict, seq: Seq) -> str:
name = p["name"]
group = p["group"]
path = p["path"]
if p["kind"] in ("zone", "probe"):
peers = "库区设置 / 探针绑定"
ready = (
"左右分栏:左侧「库房信息」树、右侧库区列表、新增与分页可见,无报错遮罩"
if p["kind"] == "zone"
else "整页单表:探针ID/主机编码/是否绑定货位筛选、查询重置、列表与分页可见,无报错遮罩"
)
ctx = "顶栏当前库房/院区与本页可维护库房范围不矛盾"
else:
peers = "审批流程设置 / 审批流实例"
ready = (
"左右分栏:左侧业务类型、右侧默认审批流与自定义审批流、保存/重置可见,无报错遮罩"
if p["kind"] == "def"
else "整页单表:单据编号/审批状态/发起时间/业务类型/摘要筛选、查询重置、列表与分页可见,无报错遮罩"
)
ctx = "顶栏登录身份与本页可查看的审批单据范围不矛盾"
return section(
"页面访问与导航",
[
(
"功能验证点",
[
seq.add(
"高",
f"菜单路径可达{name}",
f"登录后通过「基础设置 → {group} → {name}」进入 `{path}`,左侧菜单与顶栏 Tab 高亮为{name},不串到同组其它页",
),
seq.add("高", "页面主区加载完成", ready),
seq.add(
"中",
f"同组{group}菜单可切换",
f"在 {peers} 间切换后 URL 与页面内容对应,再返回{name}状态正确",
),
],
),
(
"边界验证点",
[
seq.add("低", "已登录直接打开 URL", f"携带有效登录态打开 `{path}`,页面正常渲染"),
],
),
(
"异常验证点",
[
seq.add("高", "未登录访问拦截", "无登录态访问 URL 跳转登录或不展示业务数据"),
seq.add("中", "登录态过期", "会话失效后查询/写操作被拦截并提示重新登录,不误写"),
seq.add("中", "无菜单权限", f"无{name}权限的账号不可见菜单或 URL 被拒绝"),
],
),
(
"集成验证点",
[
seq.add("中", "顶栏身份与可管理范围一致", ctx),
],
),
],
)
def zone_body(seq: Seq) -> str:
chunks = [
section(
"左侧库房树",
[
(
"功能验证点",
[
seq.add("高", "库房树默认有选中项", "进入后左侧「库房信息」有节点且一项处于选中,右侧为该库房的库区"),
seq.add("高", "按库房名称过滤树", "在左侧「库房名称」输入关键字后,仅保留名称匹配的库房节点"),
seq.add("中", "清空库房名称恢复全量树", "清空关键字后树恢复为过滤前的全量节点"),
seq.add("高", "切换库房刷新右侧列表", "点选另一库房后,右侧库区编号/名称/地址属于该库房,「共 N 条」同步变化"),
],
),
(
"边界验证点",
[
seq.add("中", "超长库房名称关键字", "输入达到或超过允许长度,过滤被接受或截断,页面不崩溃"),
seq.add("低", "仅空格库房名称", "按空过滤处理或提示,不报错"),
],
),
(
"异常验证点",
[
seq.add("低", "无匹配库房空树", "关键字无匹配时树为空或明确无数据,右侧列表不残留上一库房库区"),
seq.add("中", "库房树加载失败", "库房接口失败有错误提示,不把过期树展示为成功态"),
],
),
(
"集成验证点",
[
seq.add("中", "库房节点与院区主数据同源", "树中库房名称与院区/库房主数据一致(允许按权限裁剪)"),
],
),
],
),
section(
"右侧库区列表与分页",
[
(
"功能验证点",
[
seq.add(
"高",
"列表核心列完整",
"主表展示库区编号、库区名称、库区地址、是否专项库区、操作(编辑/人员管理/货位管理/停用或启用)",
),
seq.add("高", "默认库区以标签展示", "默认库区在名称旁显示「默认库区」标签,不是独立列;同一库房至多一个默认库区"),
seq.add("中", "专项库区取值", "「是否专项库区」仅为「是」或「否」"),
seq.add("中", "分页翻页", "总条数大于一页时可翻至第 2 页,数据更换且页码高亮正确"),
seq.add("中", "每页条数切换", "切换「50 条/页」等后当前页行数不超过所选值,总数不变"),
],
),
(
"边界验证点",
[
seq.add("中", "首页上一页不可用", "第 1 页时「上一页」禁用或点击无效"),
seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"),
seq.add("低", "库房无库区时分页为 0", "选中尚无库区的库房时列表空态明确,「共 0 条」"),
],
),
(
"异常验证点",
[
seq.add("中", "库区列表加载失败", "失败有错误提示,不白屏、不把上一库房数据展示为当前库房成功态"),
],
),
(
"集成验证点",
[
seq.add("中", "翻页不串库房", "带当前库房选中翻页后,新页库区仍属于该库房"),
seq.add("中", "列表条数与共 N 条一致", "可见行逻辑与底部分页「共 N 条」一致"),
],
),
],
),
section(
"库区搜索",
[
(
"功能验证点",
[
seq.add("高", "按库区编号搜索", "在「库区编号/名称」输入已存在编号,结果包含该编号行"),
seq.add("高", "按库区名称搜索", "输入已存在名称关键字,结果名称匹配"),
seq.add("中", "搜索范围限于当前库房", "搜索结果全部属于左侧当前选中库房"),
],
),
(
"边界验证点",
[
seq.add("中", "超长库区关键字", "超长输入可检索或被截断,页面不崩溃"),
seq.add("低", "仅空格库区关键字", "按空搜索处理或提示,不报错"),
],
),
(
"异常验证点",
[
seq.add("低", "无匹配库区空态", "无命中时列表为空且分页为 0"),
],
),
(
"集成验证点",
[
seq.add("中", "切换库房后搜索条件不串结果", "换库房后结果属于新库房;若保留关键字则在新库房范围内过滤"),
],
),
],
),
section(
"新增库区",
[
(
"功能验证点",
[
seq.add("高", "打开新增库区弹窗", "点「新增」弹出「新增库区」,含库区编号、库区名称、库区地址、联系人、联系方式及取消/确定"),
seq.add("高", "合法必填保存成功", "填写必填后确定,弹窗关闭,当前库房列表出现新记录且字段与提交一致"),
seq.add("中", "取消不落库", "填写后点「取消」或关闭,列表不新增对应库区"),
],
),
(
"边界验证点",
[
seq.add("中", "编号名称达最大允许长度", "库区编号/名称取最大允许长度可保存成功"),
seq.add("低", "地址可与编号或名称相同", "库区地址填写与编号或名称相同的文本可保存"),
],
),
(
"异常验证点",
[
seq.add("高", "必填为空拦截", "库区编号或名称为空点确定被拦截并提示,不关闭弹窗、不落库"),
seq.add("高", "同一库房库区编号重复拦截", "使用当前库房已存在编号保存失败并提示,不覆盖原库区"),
seq.add("中", "保存接口失败", "失败有错误提示,弹窗仍在,列表不出现半成品成功态"),
seq.add("中", "重复点击确定", "连续快速点击只产生一条记录或第二次被防重"),
],
),
(
"集成验证点",
[
seq.add("中", "新增归属当前选中库房", "新增成功后该库区仅出现在当前库房列表,切换其它库房不可见"),
],
),
],
),
section(
"编辑库区",
[
(
"功能验证点",
[
seq.add("高", "打开编辑并回填", "行内「编辑」打开「编辑库区信息」,回填库区编号、名称、地址、联系人、联系方式"),
seq.add("高", "修改后确定落库", "修改名称或地址后确定,列表与再次编辑回看均为新值"),
seq.add("中", "编辑取消不保存", "修改后点「取消」,列表与再次打开仍为修改前值"),
seq.add("中", "专项库区回填一致", "列表「是否专项库区」与编辑页回填一致(若编辑页含该字段)"),
],
),
(
"边界验证点",
[
seq.add("低", "不改字段直接确定", "打开编辑不改内容点确定,记录保持原值且不报错"),
seq.add("中", "默认库区编辑后仍唯一", "编辑保存后该库房仍至多一个「默认库区」标签"),
],
),
(
"异常验证点",
[
seq.add("高", "编辑清空必填拦截", "清空库区名称后确定被拦截,原记录不被写成空"),
seq.add("中", "编辑已停用库区", "停用行仍可打开编辑;若禁止编辑则入口禁用并提示,不以成功态改数"),
],
),
(
"集成验证点",
[
seq.add("中", "编辑不影响其它库房同名库区", "仅更新当前库房该行,其它库房同编号/名称库区保持原值"),
],
),
],
),
section(
"停用与启用",
[
(
"功能验证点",
[
seq.add("高", "停用后操作变为启用", "启用行点「停用」后该行操作变为「启用」,状态持久化"),
seq.add("高", "启用后操作变为停用", "停用行点「启用」后该行操作变为「停用」,再次进入仍保持"),
],
),
(
"边界验证点",
[
seq.add("中", "默认库区停用限制", "默认库区若不允许停用则被拦截并提示;若允许停用则该库房须仍满足「至多一个默认库区」"),
],
),
(
"异常验证点",
[
seq.add("中", "停用/启用失败有提示", "接口失败有错误提示,行操作文案不翻转"),
seq.add("中", "停用须确认才生效", "若有确认框,取消后行仍为启用、操作仍为「停用」"),
],
),
(
"集成验证点",
[
seq.add("中", "停用库区不可再被探针绑定为新货位", "停用后探针绑定弹窗按货位状态/启用过滤时不应再选出该库区下可绑定货位(以现网过滤规则为准)"),
],
),
],
),
section(
"人员管理",
[
(
"功能验证点",
[
seq.add("高", "打开人员管理", "行内「人员管理」打开弹层或子页,标题表明为人员管理且对应当前库区"),
seq.add("中", "关闭后回到库区列表", "关闭人员管理后仍停留在当前库房的库区列表,选中库房不变"),
],
),
(
"边界验证点",
[
seq.add("低", "无人员时列表空态", "未配置人员时空态明确,不报错"),
],
),
(
"异常验证点",
[
seq.add("中", "人员管理加载失败", "失败有错误提示,不把其它库区人员展示为当前库区"),
],
),
(
"集成验证点",
[
seq.add("中", "人员与用户管理账号同源", "可选人员来自用户/角色主数据(允许按权限裁剪)"),
],
),
],
),
section(
"货位管理",
[
(
"功能验证点",
[
seq.add("高", "打开货位管理", "行内「货位管理」打开弹层或子页,标题表明为货位且对应当前库区"),
seq.add("中", "关闭后回到库区列表", "关闭货位管理后当前库房选中与库区列表保持"),
],
),
(
"边界验证点",
[
seq.add("低", "无货位时空态", "库区无货位时列表空态明确"),
],
),
(
"异常验证点",
[
seq.add("中", "货位管理加载失败", "失败有提示,不串其它库区货位"),
],
),
(
"集成验证点",
[
seq.add("高", "货位可被探针绑定弹窗检索到", "本库区启用货位应出现在探针绑定「货位绑定探针」弹窗对应库房/库区筛选结果中"),
],
),
],
),
section(
"同步库房信息",
[
(
"功能验证点",
[
seq.add("中", "同步刷新库房树", "点「同步」后树与列表按最新主数据刷新,不丢当前可维护库房"),
],
),
(
"边界验证点",
[
seq.add("低", "同步期间不可重复提交", "同步进行中按钮禁用或再次点击被忽略,不并发写坏树"),
],
),
(
"异常验证点",
[
seq.add("中", "同步失败有提示", "失败有错误提示,树不空白、不把失败展示为成功"),
],
),
(
"集成验证点",
[
seq.add("中", "同步结果与院区库房主数据一致", "同步后新增/更名的库房节点与主数据一致"),
],
),
],
),
]
return "\n---\n\n".join(chunks)
def probe_body(seq: Seq) -> str:
chunks = [
section(
"筛选与查询",
[
(
"功能验证点",
[
seq.add("高", "默认条件查询", "进入后点「查询」,列表有数据或空态明确,底部分页「共 N 条」与行数逻辑一致"),
seq.add("高", "按探针ID查询", "输入已存在探针ID后结果探针ID匹配,精确命中时至多对应行"),
seq.add("高", "按主机编码查询", "输入主机编码后结果「主机编码」与条件一致(多探针可共用同一主机)"),
seq.add("高", "按是否绑定货位查询", "选择「已绑定」仅保留已绑定行;选择「未绑定」仅保留未绑定行"),
seq.add("高", "探针ID+主机编码+是否绑定组合查询", "多个已填条件同时生效,结果同时满足全部条件"),
seq.add("中", "重置恢复默认", "填写筛选后点「重置」,条件恢复默认,列表按默认规则刷新"),
],
),
(
"边界验证点",
[
seq.add("中", "超长探针ID或主机编码", "超长输入可查询或被截断,页面不崩溃"),
seq.add("低", "仅空格关键字", "按空查询处理或提示,不报错"),
],
),
(
"异常验证点",
[
seq.add("中", "查询接口失败", "失败有错误提示,不白屏、不把过期数据展示为成功态"),
seq.add("低", "无匹配结果空态", "条件无匹配时「暂无数据」明确,分页总数为 0"),
],
),
(
"集成验证点",
[
seq.add("中", "筛选结果与分页总数一致", "查询后可见行逻辑与「共 N 条」一致"),
],
),
],
),
section(
"列表与分页",
[
(
"功能验证点",
[
seq.add(
"高",
"列表核心列完整",
"主表展示序号、探针ID、主机编码、是否绑定货位、绑定的货位、操作(绑定货位)",
),
seq.add("高", "已绑定行数量大于 0", "「是否绑定货位」为已绑定时,「绑定的货位」数量大于 0"),
seq.add("高", "未绑定行数量为 0", "未绑定行「绑定的货位」为 `0`"),
seq.add("中", "绑定的货位列为数量不是货位号", "主表该列为数字;货位号只在弹窗「货位号」列"),
seq.add("中", "分页翻页", "总条数大于一页时可翻页,数据更换且页码正确"),
seq.add("中", "每页条数切换", "切换每页条数后当前页行数不超过所选值,总数不变"),
],
),
(
"边界验证点",
[
seq.add("中", "首页上一页不可用", "第 1 页时「上一页」禁用或点击无效"),
seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"),
],
),
(
"异常验证点",
[
seq.add("低", "跳至非数字页码", "非法页码不跳转异常页"),
],
),
(
"集成验证点",
[
seq.add("中", "翻页不丢失当前筛选", "带筛选翻页后条件保持,新页仍满足原筛选"),
],
),
],
),
section(
"打开绑定货位弹窗",
[
(
"功能验证点",
[
seq.add("高", "行内打开货位绑定探针", "点「绑定货位」弹出「货位绑定探针」,含库房/库区/货位筛选与货位列表"),
seq.add("中", "取消关闭不改主列表", "未确定即点「取消」或关闭,主列表绑定数量与筛选条件保持"),
],
),
(
"边界验证点",
[
seq.add("低", "未选货位时确定计数为 0", "底部按钮文案为「确定(0)」或等价,未选时确定不新增绑定"),
],
),
(
"异常验证点",
[
seq.add("中", "弹窗加载失败", "货位列表失败有提示,不把主列表「共 N 条」与弹窗总条数混用"),
],
),
(
"集成验证点",
[
seq.add("中", "弹窗分页独立于主列表", "弹窗「共 N 条」(货位量级)不得用于断言主列表探针条数"),
],
),
],
),
section(
"弹窗筛选货位",
[
(
"功能验证点",
[
seq.add("高", "按当前库房筛选", "选择库房后列表「当前库房」与所选一致"),
seq.add("高", "按当前库区筛选", "选择库区后列表「当前库区」与所选一致"),
seq.add("高", "按货位号查询", "「货位查询」输入货位号关键字后结果货位号匹配"),
seq.add("中", "弹窗内重置", "填写弹窗筛选后点「重置」,条件恢复,列表按弹窗默认规则刷新"),
],
),
(
"边界验证点",
[
seq.add("中", "超长货位号关键字", "超长输入可查询或被截断,弹窗不崩溃"),
seq.add("低", "库区下拉随库房收敛", "切换库房后库区选项属于该库房,不残留上一库房库区"),
],
),
(
"异常验证点",
[
seq.add("低", "弹窗无匹配空态", "条件无命中时货位列表空、弹窗分页为 0"),
],
),
(
"集成验证点",
[
seq.add("高", "库房库区与库区设置同源", "弹窗库房/库区选项来自库区设置中已维护且可用的库房与库区"),
],
),
],
),
section(
"勾选货位并保存绑定",
[
(
"功能验证点",
[
seq.add("高", "勾选后确定计数增加", "勾选 N 个货位后确定按钮计数为 N"),
seq.add("高", "确定后主列表绑定数量增加", "为未绑定探针勾选货位并确定后,该行变为已绑定,数量等于本次保存的货位数(或按产品规则累加)"),
seq.add("中", "已绑货位行展示探针ID", "弹窗中已被该探针绑定的货位「探针ID」列显示该探针号,未绑定为 `--`"),
],
),
(
"边界验证点",
[
seq.add("中", "取消勾选后计数回退", "勾选后再取消,确定计数回到实际已选数"),
],
),
(
"异常验证点",
[
seq.add("中", "绑定保存失败", "失败有提示,主列表绑定数量不增加,再次打开不是半成功勾选"),
seq.add("中", "货位已被其它探针占用", "占用行再绑定时按换绑或拒绝执行;以保存后主列表数量与弹窗探针ID列为准,不得静默双绑"),
],
),
(
"集成验证点",
[
seq.add("高", "绑定结果与货位管理一致", "保存后在对应库区货位侧能看到该探针占用;解绑/换绑后两侧同步"),
],
),
],
),
]
return "\n---\n\n".join(chunks)
def def_body(seq: Seq) -> str:
chunks = [
section(
"左侧业务类型",
[
(
"功能验证点",
[
seq.add("高", "业务类型列表可见", "左侧列出采购计划、请领单、产品准入等类型,当前项高亮"),
seq.add("高", "按流程名称过滤类型", "输入关键字后仅保留名称匹配的业务类型"),
seq.add("中", "清空关键字恢复全量", "清空后类型列表恢复过滤前全量"),
seq.add("高", "切换类型刷新右侧配置", "点选另一类型后,默认审批流与自定义审批流与该类型已保存配置一致,不残留上一类型节点"),
],
),
(
"边界验证点",
[
seq.add("中", "超长流程名称关键字", "超长输入可过滤或被截断,页面不崩溃"),
seq.add("低", "仅空格关键字", "按空过滤处理或提示,不报错"),
],
),
(
"异常验证点",
[
seq.add("低", "无匹配类型", "无命中时左侧为空或明确无数据,右侧不展示上一类型配置为当前配置"),
],
),
(
"集成验证点",
[
seq.add("高", "类型与审批流实例业务类型同源", "左侧类型集合与审批流实例「业务类型」下拉选项一致(允许按权限裁剪)"),
],
),
],
),
section(
"默认审批流",
[
(
"功能验证点",
[
seq.add("高", "默认审批流节点可见", "右侧「默认审批流」展示节点卡片,含节点类型、账号、人员"),
seq.add("中", "可追加默认流节点", "点节点旁「+」可增加审批节点(保存前仅在当前页可见)"),
],
),
(
"边界验证点",
[
seq.add("低", "未配置自定义流时走默认链", "该类型无自定义流时,新单据按默认审批人链流转(以审批流实例抽样为准)"),
],
),
(
"异常验证点",
[
seq.add("中", "默认流节点人员为空拦截", "保存时默认链缺少人员被拦截并提示,不写入空节点"),
],
),
(
"集成验证点",
[
seq.add("中", "只改自定义流时默认链不变", "仅增删自定义行并保存后,默认审批人节点保持原值"),
],
),
],
),
section(
"自定义审批流",
[
(
"功能验证点",
[
seq.add("高", "新增流程增加一行", "点「新增流程」自定义流增加一行,含流程名称、条件、审批流程、删除"),
seq.add("中", "删除行未保存不落库", "删除自定义行后不点保存,重新进入该类型行仍在"),
seq.add("高", "保存后自定义流持久化", "填写名称/条件/节点后保存,重新进入该类型配置与保存时一致"),
],
),
(
"边界验证点",
[
seq.add("中", "流程名称必填", "名称空时保存被拦截"),
seq.add("中", "条件必填", "条件空时保存被拦截"),
seq.add("中", "审批流程必填", "无审批节点时保存被拦截"),
],
),
(
"异常验证点",
[
seq.add("高", "必填为空拦截", "流程名称/条件/审批流程任一为空点保存被拦截并提示,不关闭、不落库"),
seq.add("中", "保存接口失败", "失败有提示,再次进入不是半成功配置"),
],
),
(
"集成验证点",
[
seq.add("高", "自定义条件命中后新单走该链", "保存后新产生的符合条件单据在审批流实例中按该自定义链流转,而非默认链"),
],
),
],
),
section(
"条件配置",
[
(
"功能验证点",
[
seq.add("高", "打开条件配置", "条件列「编辑」打开「条件配置」,可添加条件行并保存/取消"),
seq.add("高", "保存条件回写行内文案", "配置如「是否超限:是」或「科室:中心库(洪山)」后保存,行内条件展示与配置一致"),
seq.add("中", "取消不改行内条件", "修改后点取消,行内条件仍为打开前的值"),
],
),
(
"边界验证点",
[
seq.add("低", "可删除条件行", "条件配置内「删除」去掉一行,保存后不再出现该条件"),
],
),
(
"异常验证点",
[
seq.add("中", "条件不完整拦截", "条件类型或取值空时保存被拦截"),
],
),
(
"集成验证点",
[
seq.add("中", "科室条件与库房/科室主数据同源", "条件选科室时选项来自主数据,与库区设置/用户所属科室不矛盾"),
],
),
],
),
section(
"保存与重置",
[
(
"功能验证点",
[
seq.add("高", "保存写入当前业务类型", "点「保存」只持久化当前选中类型的默认流与自定义流"),
seq.add("高", "重置放弃未保存改动", "改节点或自定义行后点「重置」,页面回到上次保存结果"),
],
),
(
"边界验证点",
[
seq.add("低", "无改动点保存不报错", "未修改直接保存成功或提示无变更,配置不变"),
],
),
(
"异常验证点",
[
seq.add("中", "未保存切换类型", "有未保存改动时切换业务类型须提示后丢弃或不允许切换,不得把上一类型的改动写入下一类型"),
],
),
(
"集成验证点",
[
seq.add("中", "保存不影响历史审批实例", "改审批人后,已存在的审批流实例详情仍为发起时的快照或按产品规则说明;不得无说明地改写历史已通过单"),
],
),
],
),
]
return "\n---\n\n".join(chunks)
def inst_body(seq: Seq) -> str:
chunks = [
section(
"筛选与查询",
[
(
"功能验证点",
[
seq.add("高", "默认日期区间查询", "进入后带默认发起时间区间,点「查询」列表有数据或空态明确,「共 N 条」与结果逻辑一致"),
seq.add("高", "单据编号精确查询", "输入完整单号后至多一条,且编号、业务类型与该行一致"),
seq.add("高", "按审批状态筛选", "选择「审批通过」或「审批中」后列表状态列与所选一致"),
seq.add("高", "按业务类型筛选", "选择某类型后列表「业务类型」均为该类型"),
seq.add("高", "按发起时间区间筛选", "结果行「发起时间」落在起止日期内(含边界日)"),
seq.add("中", "按摘要关键字筛选", "输入摘要关键字后结果摘要包含该关键字;摘要为 `--` 的行不被非空关键字命中"),
seq.add("高", "多条件组合查询", "状态、业务类型、发起时间、摘要同时生效,抽样行满足全部已填条件"),
seq.add("中", "重置恢复默认", "填写后点「重置」,日期回到默认区间,其余条件清空,「共 N 条」恢复"),
],
),
(
"边界验证点",
[
seq.add("中", "超长单据编号或摘要", "超长输入可查询或被截断,页面不崩溃"),
seq.add("中", "开始日期晚于结束日期", "非法区间被拒绝或自动纠正,不返回错乱时间结果"),
seq.add("低", "仅空格摘要", "按空查询处理或提示,不报错"),
],
),
(
"异常验证点",
[
seq.add("中", "查询接口失败", "失败有错误提示,不白屏、不把过期列表展示为成功态"),
seq.add("低", "无匹配空态", "总数为 0,空态明确"),
],
),
(
"集成验证点",
[
seq.add("中", "筛选结果与分页总数一致", "查询后可见行逻辑与「共 N 条」一致"),
seq.add("高", "业务类型与审批流程设置同源", "「业务类型」下拉与审批流程设置左侧类型一致(允许按权限裁剪)"),
],
),
],
),
section(
"列表与分页",
[
(
"功能验证点",
[
seq.add(
"高",
"列表核心列完整",
"主表展示序号、单据编号、业务类型、发起人、发起部门、发起时间、摘要、审批状态、操作(详情)",
),
seq.add("中", "单据编号前缀与业务类型对应", "`PLAN` 对应采购计划、`DB` 对应二级库调拨、`GC` 对应产品信息变更、`BUY` 对应请领单(以现网枚举为准)"),
seq.add("中", "空摘要展示为 --", "无摘要行显示 `--`,长摘要可截断"),
seq.add("中", "窄列截断不影响查询", "发起人/部门/时间截断展示时,按完整单号搜索仍能命中"),
seq.add("中", "分页翻页", "总条数大于一页时可翻页,数据更换"),
seq.add("中", "每页条数切换", "切换后当前页行数不超过所选值,总数不变"),
seq.add("中", "跳至指定页", "输入合法页码后跳到对应页"),
],
),
(
"边界验证点",
[
seq.add("中", "首页上一页不可用", "第 1 页时「上一页」禁用或点击无效"),
seq.add("中", "末页下一页不可用", "最后一页时「下一页」禁用或点击无效"),
seq.add("低", "跳至超出总页数", "大于总页数时被拒绝、纠正到末页或提示"),
],
),
(
"异常验证点",
[
seq.add("低", "跳至非数字页码", "非法输入不跳转异常页"),
],
),
(
"集成验证点",
[
seq.add("中", "翻页不丢失当前筛选", "带筛选翻页后条件保持,新页数据仍满足原筛选"),
],
),
],
),
section(
"审批详情",
[
(
"功能验证点",
[
seq.add("高", "打开详情对应当前行", "点「详情」打开该单据审批过程,标题/单号与当前行单据编号一致"),
seq.add("中", "详情只读", "详情内不能改审批人或条件;改配置须回到审批流程设置"),
seq.add("中", "关闭详情保留列表状态", "返回后筛选条件与页码保持,列表仍是打开前的查询结果"),
],
),
(
"边界验证点",
[
seq.add("低", "审批中与已通过均可打开详情", "两种状态行都能进入详情,节点展示与状态相符"),
],
),
(
"异常验证点",
[
seq.add("中", "详情加载失败", "失败有提示,不把其它单据内容展示为当前单"),
],
),
(
"集成验证点",
[
seq.add("高", "详情审批链与流程设置一致", "新单据详情中的节点/人员与该业务类型在审批流程设置中命中的默认或自定义链一致"),
],
),
],
),
]
return "\n---\n\n".join(chunks)
def quality(p: dict, el_count: int, tp_count: int) -> str:
kind = p["kind"]
if kind == "zone":
cover = "导航、库房树过滤/切换、库区列表分页、库区搜索、新增/编辑弹窗、停用启用、人员/货位入口、同步"
pending = (
"① 人员管理/货位管理扫描未打开面板,入口以行内按钮为准,子页字段待补扫 "
"② 默认库区如何指定(编辑字段或单独接口)以现网为准 "
"③ 停用默认库区是否允许待产品确认 "
"④ 新增弹窗扫描未见「是否专项库区」控件,列表有该列,编辑是否可改为待评审"
)
elif kind == "probe":
cover = "导航、探针筛选查询、列表数量规则、绑定弹窗筛选分页、勾选确定/取消、与库区货位集成"
pending = (
"① 同一货位已有其它探针ID时是换绑还是拒绝,以保存后主列表数量与弹窗探针ID列为准 "
"② 确定按钮文案为「确定(N)」,未选为 0 "
"③ 主表无新增探针入口,探针主数据来源不在本页维护"
)
elif kind == "def":
cover = "导航、业务类型过滤/切换、默认审批流、自定义流增删、条件配置、保存重置、与审批流实例类型同源"
pending = (
"① 保存会改线上审批路由,自动化冒烟只读结构、不提交 "
"② 未保存切类型按「提示后丢弃、不串写」写点,若自动保存需改判定 "
"③ 历史已通过实例是否快照冻结待产品确认"
)
else:
cover = "导航、单据编号/状态/类型/时间/摘要筛选、列表列与前缀规则、分页、只读详情、与流程设置类型同源"
pending = (
"① 默认发起时间跨度大,断言「共 N 条」须先收窄日期 "
"② 本页无新增/导出/改审批人;写配置只在审批流程设置 "
"③ 详情扫描打开「采购计划详情」,其它业务类型详情标题以现网为准"
)
domains = {
"zone": "页面访问与导航、左侧库房树、右侧库区列表与分页、库区搜索、新增库区、编辑库区、停用与启用、人员管理、货位管理、同步库房信息",
"probe": "页面访问与导航、筛选与查询、列表与分页、打开绑定货位弹窗、弹窗筛选货位、勾选货位并保存绑定",
"def": "页面访问与导航、左侧业务类型、默认审批流、自定义审批流、条件配置、保存与重置",
"inst": "页面访问与导航、筛选与查询、列表与分页、审批详情",
}[kind]
return f"""## 质量自检摘要
| 项 | 结论 |
|----|------|
| 范围 | 在范围内:{domains}。明确排除:断网/弱网、视觉像素、导出文件内容校验(未在本文件展开)。未声称需求 100% 全覆盖。 |
| 覆盖 | 已覆盖:{cover};共 **{tp_count}** 条原子测试点(未声称全覆盖,断网类与仓库其它基础管理页同样省略) |
| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分;无某类时保留标题并说明 |
| 去重 | 登录拦截、会话过期与同模块其它页规则相同,本页仅保留菜单/URL/字段差异;编号前缀 `{p["prefix"]}` |
| 待评审项 | {pending} |
| 报表能力 | 非统计报表 iframe 页,不适用第四节能力清单;本页为整页渲染,无报表 iframe |
| 下游 | 可转结构化 MD/CSV;扫描 `npm run {p["scan_cmd"]}`;自动化建议 `{p["po"]}` |
"""
def render_testpoint(p: dict, scan: dict) -> tuple[str, int]:
spec = f"UI-SPEC-SPD-{p['specId']}"
seq = Seq(p["prefix"], spec)
nav = nav_section(p, seq)
if p["kind"] == "zone":
body = zone_body(seq)
elif p["kind"] == "probe":
body = probe_body(seq)
elif p["kind"] == "def":
body = def_body(seq)
else:
body = inst_body(seq)
md = f"""# 基础管理-{p['group']}-{p['name']} — 测试点分析
**关联需求**:`knowledge-base/08-UI专项规范库/{spec}.md`
**页面 URL**:`{scan["url"]}`
**版本 / 定稿日期**:{TODAY}
**元素扫描**:`data/scan/{p["pageKey"]}-elements.json`({scan["elementCount"]} 项)
**定位器**:`locators/spd/{p["pageKey"]}.locator.yaml`
**本次生成范围**:见本文件既有功能域(历史生成,无独立 `testpoint/scope` 文件);本次按 ui-test-point-rules 补齐文档头与质量自检,不扩大范围
---
## 基础设置 · {p['group']} · {p['name']}
{nav}
---
{body}
---
{quality(p, scan["elementCount"], seq.n)}
"""
return md, seq.n
def patch_ui_spec(p: dict, scan: dict, tp_file: str) -> None:
path = KB / f"UI-SPEC-SPD-{p['specId']}.md"
text = path.read_text(encoding="utf-8")
text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1)
text = re.sub(r'related_tc: "[^"]*"', f'related_tc: "testpoint/{tp_file}"', text, count=1)
url = scan["url"]
count = scan["elementCount"]
page_key = p["pageKey"]
extra = f"| 浏览器页签标题 | `{p['name']}` |\n"
table = f"""| 项 | 值 |
|----|-----|
| 主框架 URL | `{url}` |
{extra}| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
| 元素扫描 JSON | `data/scan/{page_key}-elements.json`({count} 项) |
| 定位器 YAML | `locators/spd/{page_key}.locator.yaml` |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |"""
text = re.sub(
r"\| 项 \| 值 \|\n\|----\|-----\|\n(?:\| .+\|\n)+",
table + "\n",
text,
count=1,
)
path.write_text(text, encoding="utf-8")
def patch_index(counts: dict[str, int], scans: dict[str, dict]) -> None:
text = INDEX.read_text(encoding="utf-8")
text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1)
rows = {
"WH-001": (
"| 库区设置 | WH-001 | `/spd/basic/storehouse/platform/storehouse/area` | "
f"已扫描 · `warehouse-zone` · **{scans['WH-001']['elementCount']}** 项 · "
f"测试点 **{counts['WH-001']}** 条 · `TP_WH_ZON` |"
),
"WH-002": (
"| 探针绑定 | WH-002 | `/spd/basic/storehouse/storehouse/probe` | "
f"已扫描 · `warehouse-probe` · **{scans['WH-002']['elementCount']}** 项 · "
f"测试点 **{counts['WH-002']}** 条 · `TP_WH_PRB` |"
),
"FLOW-001": (
"| 审批流程设置 | FLOW-001 | `/spd/basic/flowSet/workflow/flowSet` | "
f"已扫描 · `flow-def` · **{scans['FLOW-001']['elementCount']}** 项 · "
f"测试点 **{counts['FLOW-001']}** 条 · `TP_FLOW_DEF` |"
),
"FLOW-002": (
"| 审批流实例 | FLOW-002 | `/spd/basic/flowSet/workflow/flowCase` | "
f"已扫描 · `flow-inst` · **{scans['FLOW-002']['elementCount']}** 项 · "
f"测试点 **{counts['FLOW-002']}** 条 · `TP_FLOW_INS` |"
),
}
text = re.sub(
r"\| 库区设置 \| WH-001 \| `/spd/basic/storehouse/platform/storehouse/area` \| .+ \|",
rows["WH-001"],
text,
count=1,
)
text = re.sub(
r"\| 探针绑定 \| WH-002 \| `/spd/basic/storehouse/storehouse/probe` \| .+ \|",
rows["WH-002"],
text,
count=1,
)
text = re.sub(
r"\| 审批流程设置 \| FLOW-001 \| `/spd/basic/flowSet/workflow/flowSet` \| .+ \|",
rows["FLOW-001"],
text,
count=1,
)
text = re.sub(
r"\| 审批流实例 \| FLOW-002 \| `/spd/basic/flowSet/workflow/flowCase` \| .+ \|",
rows["FLOW-002"],
text,
count=1,
)
if "generate:testpoints-warehouse" not in text:
text = text.replace(
"npm run discover:scan-warehouse\n",
"npm run discover:scan-warehouse\nnpm run generate:testpoints-warehouse\n",
)
if "generate:testpoints-flow" not in text:
text = text.replace(
"npm run discover:scan-flow\n",
"npm run discover:scan-flow\nnpm run generate:testpoints-flow\n",
)
INDEX.write_text(text, encoding="utf-8")
def main() -> None:
import sys
filter_keys = set(sys.argv[1:])
pages = [p for p in PAGES if not filter_keys or p["pageKey"] in filter_keys]
if not pages:
raise SystemExit("No pages matched. Available: " + ", ".join(p["pageKey"] for p in PAGES))
OUT_DIR.mkdir(parents=True, exist_ok=True)
counts: dict[str, int] = {}
scans: dict[str, dict] = {}
for p in pages:
scan = load_scan(p["pageKey"])
md, n = render_testpoint(p, scan)
out = OUT_DIR / p["file"]
out.write_text(md, encoding="utf-8")
patch_ui_spec(p, scan, p["file"])
counts[p["specId"]] = n
scans[p["specId"]] = scan
print(f"OK {p['specId']}\t{p['name']}\t{scan['elementCount']} el\t{n} TP\t{out.name}")
# INDEX 四行一起补全:缺页时用已落盘测试点文件行数占位,避免半更新
if len(counts) < len(PAGES):
for p in PAGES:
if p["specId"] in counts:
continue
scans[p["specId"]] = load_scan(p["pageKey"])
tp_file = OUT_DIR / p["file"]
n = 0
if tp_file.exists():
n = len(re.findall(r"\*\*TP_[A-Z0-9_]+_\d{3}\*\*", tp_file.read_text(encoding="utf-8")))
counts[p["specId"]] = n
patch_index(counts, scans)
print("done", counts)
if __name__ == "__main__":
main()
......@@ -5,6 +5,8 @@
* Usage:
* node scripts/json-to-locator-yaml.mjs data/scan/warn-strand-elements.json
* node scripts/json-to-locator-yaml.mjs --all-extended
* node scripts/json-to-locator-yaml.mjs --all-basic
* node scripts/json-to-locator-yaml.mjs --all-new
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
......@@ -13,6 +15,8 @@ import { writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SCAN_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
const EXTENDED_KEYS = [
'warn-strand',
......@@ -35,23 +39,106 @@ const EXTENDED_KEYS = [
'reag-entry',
];
const NEW_PAGES = {
'basic-notice': { pageTitle: '基础设置 > 医院资料 > 公告管理', module: 'spd-basic' },
'basic-hospital-info': { pageTitle: '基础设置 > 医院资料 > 医院信息登记', module: 'spd-basic' },
'basic-finance-month': { pageTitle: '基础设置 > 医院资料 > 财务月份设置', module: 'spd-basic' },
'basic-branch': { pageTitle: '基础设置 > 医院资料 > 院区管理', module: 'spd-basic' },
'basic-user': { pageTitle: '基础设置 > 用户管理 > 用户管理', module: 'spd-basic' },
'basic-user-union': { pageTitle: '基础设置 > 用户管理 > 医共体用户管理', module: 'spd-basic' },
'basic-role': { pageTitle: '基础设置 > 用户管理 > 角色管理', module: 'spd-basic' },
'admit-material': { pageTitle: '基础设置 > 准入管理 > 物料准入管理', module: 'spd-basic' },
'admit-product-apply': { pageTitle: '基础设置 > 准入管理 > 产品准入申请', module: 'spd-basic' },
'admit-material-list': { pageTitle: '基础设置 > 准入管理 > 物料准入单列表', module: 'spd-basic' },
'admit-product-list': { pageTitle: '基础设置 > 准入管理 > 产品准入单列表', module: 'spd-basic' },
'admit-supply': { pageTitle: '基础设置 > 准入管理 > 供货目录申请', module: 'spd-basic' },
'admit-task': { pageTitle: '基础设置 > 准入管理 > 准入任务分配', module: 'spd-basic' },
'vendor-query': { pageTitle: '基础设置 > 厂商管理 > 厂商查询', module: 'spd-basic' },
'vendor-qual': { pageTitle: '基础设置 > 厂商管理 > 供应商资质管理', module: 'spd-basic' },
'vendor-auth-first': { pageTitle: '基础设置 > 厂商管理 > 授权变更初审', module: 'spd-basic' },
'vendor-auth-final': { pageTitle: '基础设置 > 厂商管理 > 授权变更终审', module: 'spd-basic' },
'vendor-cert-first': { pageTitle: '基础设置 > 厂商管理 > 注册证变更初审', module: 'spd-basic' },
'vendor-cert-final': { pageTitle: '基础设置 > 厂商管理 > 注册证变更终审', module: 'spd-basic' },
'vendor-cert-diff': { pageTitle: '基础设置 > 厂商管理 > 注册证差异对比', module: 'spd-basic' },
'vendor-eval-scheme': { pageTitle: '基础设置 > 厂商管理 > 评价方案维护', module: 'spd-basic' },
'vendor-eval-indicator': { pageTitle: '基础设置 > 厂商管理 > 评价指标维护', module: 'spd-basic' },
'vendor-holiday': { pageTitle: '基础设置 > 厂商管理 > 供应商节假日管理', module: 'spd-basic' },
'surgery-base-pkg': { pageTitle: '基础设置 > 手术套包设置 > 基础包维护', module: 'spd-basic' },
'surgery-oper-type': { pageTitle: '基础设置 > 手术套包设置 > 术种维护', module: 'spd-basic' },
'surgery-std-pkg': { pageTitle: '基础设置 > 手术套包设置 > 标准套包维护', module: 'spd-basic' },
'warehouse-zone': { pageTitle: '基础设置 > 库房管理 > 库区设置', module: 'spd-basic' },
'warehouse-probe': { pageTitle: '基础设置 > 库房管理 > 探针绑定', module: 'spd-basic' },
'flow-def': { pageTitle: '基础设置 > 流程设置 > 审批流程设置', module: 'spd-basic' },
'flow-inst': { pageTitle: '基础设置 > 流程设置 > 审批流实例', module: 'spd-basic' },
};
const GROUPS = {
'--all-extended': EXTENDED_KEYS,
'--all-basic': ['basic-notice', 'basic-hospital-info', 'basic-finance-month', 'basic-branch'],
'--all-user': ['basic-user', 'basic-user-union', 'basic-role'],
'--all-admit': [
'admit-material',
'admit-product-apply',
'admit-material-list',
'admit-product-list',
'admit-supply',
'admit-task',
],
'--all-vendor': [
'vendor-query',
'vendor-qual',
'vendor-auth-first',
'vendor-auth-final',
'vendor-cert-first',
'vendor-cert-final',
'vendor-cert-diff',
'vendor-eval-scheme',
'vendor-eval-indicator',
'vendor-holiday',
],
'--all-surgery': ['surgery-base-pkg', 'surgery-oper-type', 'surgery-std-pkg'],
'--all-warehouse': ['warehouse-zone', 'warehouse-probe'],
'--all-flow': ['flow-def', 'flow-inst'],
'--all-new': Object.keys(NEW_PAGES),
};
function jsonPathFor(key) {
return path.join(SCAN_DIR, `${key}-elements.json`);
}
function writeOne(jsonPath, pageKey) {
const meta = NEW_PAGES[pageKey] || {};
const result = writeYamlFromScanJson(jsonPath, {
pageKey: pageKey || path.basename(jsonPath, '-elements.json'),
pageTitle: meta.pageTitle,
module: meta.module || (pageKey && NEW_PAGES[pageKey] ? 'spd-basic' : undefined),
jsonSourceName: path.basename(jsonPath),
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${pageKey || path.basename(jsonPath, '-elements.json')}.locator.yaml`),
});
console.log(`YAML: ${result.yamlOut} (${result.elementCount} elements)`);
return result;
}
async function main() {
const args = process.argv.slice(2);
const targets =
args[0] === '--all-extended'
? EXTENDED_KEYS.map((key) => path.join(ROOT, 'data/scan', `${key}-elements.json`))
: args.length
? [path.resolve(args[0])]
: [];
if (!targets.length) {
console.error('Usage: node scripts/json-to-locator-yaml.mjs <json-path|--all-extended>');
if (!args.length) {
console.error(
'Usage: node scripts/json-to-locator-yaml.mjs <json-path|--all-extended|--all-basic|--all-user|--all-admit|--all-vendor|--all-surgery|--all-warehouse|--all-flow|--all-new>',
);
process.exit(1);
}
for (const jsonPath of targets) {
const result = writeYamlFromScanJson(jsonPath);
console.log(`YAML: ${result.yamlOut} (${result.elementCount} elements)`);
for (const arg of args) {
if (GROUPS[arg]) {
for (const key of GROUPS[arg]) {
writeOne(jsonPathFor(key), key);
}
continue;
}
const jsonPath = path.resolve(arg);
const pageKey = path.basename(jsonPath).replace(/-elements\.json$/, '');
writeOne(jsonPath, pageKey);
}
}
......
......@@ -3,7 +3,7 @@
* 凭证不入库;源码变更后 hash 失效,必须重跑。
*/
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
......@@ -57,7 +57,9 @@ export function hashSources(files) {
const abs = path.join(ROOT, file);
hash.update(file);
hash.update('\0');
hash.update(existsSync(abs) ? readFileSync(abs) : Buffer.from(''));
if (existsSync(abs) && statSync(abs).isFile()) {
hash.update(readFileSync(abs));
}
hash.update('\0');
}
return hash.digest('hex');
......
......@@ -20,6 +20,8 @@ import {
readdirSync,
readFileSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import path from 'node:path';
......@@ -30,6 +32,9 @@ import { formatSuiteList, getSuite } from '../config/suites.config.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PLAYWRIGHT_CLI = path.join(ROOT, 'node_modules/playwright/cli.js');
/** --keep-archives=N 的命令行值;undefined 时退回环境变量 / 默认全留。 */
let KEEP_ARCHIVES_ARG;
function parseArgs(argv) {
const out = {
suite: process.env.SUITE || 'full',
......@@ -40,6 +45,7 @@ function parseArgs(argv) {
skipTypecheck: process.env.SKIP_TYPECHECK === '1',
shards: Number(process.env.SHARDS || 1),
shard: process.env.PW_SHARD || '',
keepArchives: undefined,
};
for (const arg of argv) {
if (arg === '--list') out.list = true;
......@@ -49,13 +55,14 @@ function parseArgs(argv) {
else if (arg.startsWith('--suite=')) out.suite = arg.slice(8);
else if (arg.startsWith('--shards=')) out.shards = Math.max(1, Number(arg.slice(9)) || 1);
else if (arg.startsWith('--shard=')) out.shard = arg.slice(8);
else if (arg.startsWith('--keep-archives=')) out.keepArchives = arg.slice(16);
else out.extra.push(arg);
}
return out;
}
function printHelp() {
console.log(`Usage: node scripts/run-ci.mjs [--suite=smoke] [--shards=2] [--shard=1/2] [--skip-preflight] [--list]
console.log(`Usage: node scripts/run-ci.mjs [--suite=smoke] [--shards=2] [--shard=1/2] [--keep-archives=N] [--skip-preflight] [--list]
Suites:
${formatSuiteList()}
......@@ -64,15 +71,19 @@ ${formatSuiteList()}
默认按「文件」并行(PW_WORKERS),同一 spec 内 serial 组不拆开。
--shards=N 本机拉起 N 个 Playwright 进程(先登录一次再分片)
--shard=i/N 只跑第 i 片(Jenkins 多 Agent 矩阵用)
--keep-archives=N 只保留最近 N 份 reports/ci-*(0=跑完即删本次归档,等价 PW_KEEP_ARCHIVES=0)
Env:
SUITE / PW_WORKERS / PW_RETRIES / RUN_FULL / BASE_URL / TEST_USER / TEST_PASS
SUITE / PW_WORKERS / PW_RETRIES / RUN_FULL / BASE_URL / TEST_USER / TEST_PASS / TEST_USER_2 / TEST_PASS_2
SHARDS / PW_SHARD / PW_FULLY_PARALLEL=1 / PW_MAX_FAILURES
RUN_QUARANTINE=1 只跑 config/quarantine.json 隔离项
SKIP_PREFLIGHT=1 跳过站点探活
SKIP_TYPECHECK=1 跳过 tsc --noEmit
ALLURE=0 关闭 Allure
INCLUDE_DEBUG_SPECS=1 纳入 *.stat-*.spec.ts 调试用例
ALLURE=1 额外生成 Allure(默认关,避免与 HTML 重复保存 trace/截图)
PW_JUNIT_COMPACT=0 保留 JUnit 完整堆栈(默认 1:只留 message)
PW_KEEP_TRACES=N 归档内只留最大 N 个 trace/截图附件(默认全留)
PW_KEEP_ARCHIVES=N 本机只保留最近 N 份 ci-<buildId>(默认全留;0=跑完即删)
PW_KEEP_INTERMEDIATE=1 保留分片目录与 blob-report(默认合并后删除)
`);
}
......@@ -105,6 +116,161 @@ function appendFileIfExists(src, dest) {
return true;
}
/** 旧归档保留份数:-1=全留(默认,保持原行为),0=跑完即删,N=保留最新 N 份。 */
function resolveKeepArchives() {
const raw = process.env.PW_KEEP_ARCHIVES ?? KEEP_ARCHIVES_ARG;
if (raw === undefined || raw === '') return -1;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : -1;
}
function dirSizeBytes(dir) {
let total = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) total += dirSizeBytes(full);
else if (entry.isFile()) {
try {
total += statSync(full).size;
} catch {
// 文件被占用/已删除则跳过
}
}
}
return total;
}
/** 删除本次归档内的中间目录(分片产物、blob),它们只在合并阶段有用。 */
function dropIntermediateDirs(archiveDir, blobDir) {
if (process.env.PW_KEEP_INTERMEDIATE === '1') return;
rmSync(blobDir, { recursive: true, force: true });
for (const item of readdirSync(archiveDir, { withFileTypes: true })) {
if (item.isDirectory() && /^shard-\d+$/.test(item.name)) {
rmSync(path.join(archiveDir, item.name), { recursive: true, force: true });
}
}
}
function pruneArchives(currentDir, keep) {
if (keep === 0) {
rmSync(currentDir, { recursive: true, force: true });
console.log(`[ci] PW_KEEP_ARCHIVES=0,已删除本次归档(latest/ 仍保留最近一次)`);
return;
}
const base = path.join(ROOT, 'reports');
const archives = readdirSync(base, { withFileTypes: true })
.filter((item) => item.isDirectory() && /^ci-/.test(item.name))
.map((item) => {
const full = path.join(base, item.name);
return { full, name: item.name, mtime: statSync(full).mtimeMs };
})
.sort((a, b) => b.mtime - a.mtime);
for (const item of archives.slice(Math.max(keep, 1))) {
try {
rmSync(item.full, { recursive: true, force: true });
console.log(`[ci] 清理旧归档 ${item.name}`);
} catch (err) {
console.warn(`[ci] 清理 ${item.name} 失败(可能被占用):${err.message}`);
}
}
}
/** 附件保留份数:-1=全留(默认),0=全部删除,N=仅留体积最大的 N 个。 */
function resolveKeepTraces() {
const raw = process.env.PW_KEEP_TRACES;
if (raw === undefined || raw === '') return -1;
const n = Number(raw);
return Number.isFinite(n) && n >= -1 ? Math.trunc(n) : -1;
}
/** mergeAllureResults 会给文件名加 `<分片>-` 前缀,但 result.json 里仍是原始 uuid,比对时要剥掉前缀。 */
function attachmentUuid(fileName) {
return fileName.replace(/^shard-\d+-/, '');
}
/**
* Allure 与 HTML 各自保存一份 trace.zip / 截图(同一失败用例 2 份)。
* 保留一份 HTML 报告即可:默认不再把二进制附件复制进 allure-results,
* 并同步删掉 result.json 里的附件条目,避免 Allure 页面出现坏链接。
*/
function pruneAllureBinaryAttachments(resultsDir, keep) {
if (!existsSync(resultsDir)) return;
const binaries = readdirSync(resultsDir, { withFileTypes: true })
.filter((item) => item.isFile() && /-attachment\.(zip|png|jpg|jpeg|webm|mp4)$/i.test(item.name))
.map((item) => {
const full = path.join(resultsDir, item.name);
return { name: item.name, uuid: attachmentUuid(item.name), full, size: statSync(full).size };
});
if (!binaries.length) return;
binaries.sort((a, b) => b.size - a.size);
const dropped = new Set();
let freed = 0;
for (const item of binaries.slice(Math.max(keep, 0))) {
try {
unlinkSync(item.full);
dropped.add(item.uuid);
freed += item.size;
} catch {
// 删除失败则保留引用
}
}
if (!dropped.size) return;
for (const item of readdirSync(resultsDir, { withFileTypes: true })) {
if (!item.isFile() || !item.name.endsWith('-result.json')) continue;
const full = path.join(resultsDir, item.name);
try {
writeFileSync(full, stripAttachmentRefs(readFileSync(full, 'utf8'), dropped));
} catch {
// 单个 result 解析失败不影响其余文件
}
}
console.log(
`[ci] allure 二进制附件瘦身:删除 ${dropped.size} 个,释放 ${(freed / 1024 / 1024).toFixed(0)} MB`,
);
}
function stripAttachmentRefs(raw, dropped) {
const isMarked = (ref) => typeof ref === 'string' && dropped.has(ref);
const clean = (node) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (let i = node.length - 1; i >= 0; i -= 1) {
if (isMarked(node[i]?.source)) node.splice(i, 1);
else clean(node[i]);
}
return;
}
if (Array.isArray(node.attachments)) {
node.attachments = node.attachments.filter((entry) => !isMarked(entry?.source));
}
for (const value of Object.values(node)) clean(value);
};
const json = JSON.parse(raw);
clean(json);
return JSON.stringify(json);
}
/** 紧凑 JUnit:去掉每条 failure/error 的整段 <system-err>/堆栈,只留 message 属性。 */
function compactJunit(file) {
const xml = readFileSync(file, 'utf8');
const compact = xml.replace(/<(failure|error)\b([^>]*)>[\s\S]*?<\/\1>/g, (match, tag, attrs) =>
attrs.includes('message=') ? `<${tag}${attrs} />` : match,
);
writeFileSync(file, compact);
}
/** 归档瘦身:产物只供 Jenkins 浏览,可重建的中间文件与重复附件不再全量落盘。 */
function slimArchive({ archiveDir, htmlDir }) {
if (process.env.PW_JUNIT_COMPACT === '1' && existsSync(path.join(archiveDir, 'junit.xml'))) {
compactJunit(path.join(archiveDir, 'junit.xml'));
}
const resultsDir = path.join(archiveDir, 'allure-results');
pruneAllureBinaryAttachments(resultsDir, resolveKeepTraces());
const htmlBytes = existsSync(htmlDir) ? dirSizeBytes(htmlDir) : 0;
if (htmlBytes > 0) console.log(`[ci] HTML 体积 ${(htmlBytes / 1024 / 1024).toFixed(0)} MB`);
}
function mergeFlakeDirs(parts, dest) {
mkdirSync(dest, { recursive: true });
for (const dir of parts) {
......@@ -263,7 +429,11 @@ function buildBaseEnv(workers, retries, runFull, dirs) {
PW_RETRIES: retries,
RUN_FULL: runFull,
RUN_QUARANTINE: process.env.RUN_QUARANTINE || '0',
ALLURE: process.env.ALLURE === '0' ? '0' : '1',
// Allure 默认关:Allure 与 HTML 保存同一份 trace/截图,且 Allure 插件只读 latest/allure-results,
// 多出的归档副本没有消费方。需要时 ALLURE=1 打开。
ALLURE: process.env.ALLURE === '1' ? '1' : '0',
// JUnit 默认用紧凑版:Jenkins recordIssues 只读 message,不需要整段堆栈。
PW_JUNIT_COMPACT: process.env.PW_JUNIT_COMPACT ?? '1',
JUNIT: '1',
PLAYWRIGHT_BROWSERS_PATH:
process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(ROOT, '.playwright-browsers'),
......@@ -281,6 +451,7 @@ async function main() {
console.log(formatSuiteList());
process.exit(0);
}
KEEP_ARCHIVES_ARG = opts.keepArchives;
const suite = getSuite(opts.suite);
const buildId = resolveBuildId();
......@@ -452,7 +623,11 @@ async function main() {
finishFlakeReport(flakeDir);
const allureOk = process.env.ALLURE !== '0' && tryGenerateAllure(allureResultsDir, allureReportDir);
// 只保留一份 HTML + JUnit + summary 等文本产物;中间目录先删,再生成 Allure,避免附件重复落盘。
dropIntermediateDirs(archiveDir, blobDir);
slimArchive({ archiveDir, htmlDir });
const allureOk = process.env.ALLURE === '1' && tryGenerateAllure(allureResultsDir, allureReportDir);
const summary = {
suite: suite.name,
......@@ -482,7 +657,8 @@ async function main() {
mkdirSync(latestDir, { recursive: true });
copyIfExists(junitFile, path.join(latestDir, 'junit.xml'));
copyIfExists(htmlDir, path.join(latestDir, 'playwright-report'));
copyIfExists(allureResultsDir, path.join(latestDir, 'allure-results'));
// latest 只需要供 Jenkins 插件消费的报告文件;默认不开 Allure,也就不复制 allure-results。
if (allureOk) copyIfExists(allureResultsDir, path.join(latestDir, 'allure-results'));
if (allureOk) copyIfExists(allureReportDir, path.join(latestDir, 'allure-report'));
copyIfExists(path.join(archiveDir, 'summary.json'), path.join(latestDir, 'summary.json'));
copyIfExists(path.join(archiveDir, 'meta.txt'), path.join(latestDir, 'meta.txt'));
......@@ -501,6 +677,10 @@ async function main() {
console.log(`[ci] JUnit: ${path.relative(ROOT, junitFile)}`);
if (allureOk) console.log(`[ci] Allure: ${path.relative(ROOT, allureReportDir)}`);
console.log(`[ci] latest: reports/latest/`);
if (existsSync(archiveDir)) {
console.log(`[ci] 归档体积 ${(dirSizeBytes(archiveDir) / 1024 / 1024).toFixed(0)} MB`);
}
pruneArchives(archiveDir, resolveKeepArchives());
process.exit(exitCode);
}
......
#!/usr/bin/env bash
# 2026-10-23 19:00 全量回归(PW_WORKERS=2);由 LaunchAgent 触发
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
LOG="$ROOT/reports/scheduled-full-20261023.log"
LABEL="com.mytestui.full-run-20261023"
PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
UID_NUM="$(id -u)"
mkdir -p "$ROOT/reports"
export PATH="/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${PATH:-}"
{
echo "[$(date '+%Y-%m-%d %H:%M:%S')] LaunchAgent 触发:PW_WORKERS=2 npm run test:ci:full"
cd "$ROOT"
export CI=1
export PW_WORKERS=2
set +e
/usr/bin/caffeinate -dims npm run test:ci:full
EC=$?
set -e
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 结束 exit=$EC"
launchctl bootout "gui/${UID_NUM}/${LABEL}" 2>/dev/null || true
rm -f "$PLIST"
} >> "$LOG" 2>&1
exit "${EC:-0}"
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 准入管理 (6 pages).
* Saves data/scan/*-elements.json, then regenerates locators/spd/*.locator.yaml from that JSON.
*
* Usage:
* node scripts/scan-admit-batch.mjs
* node scripts/scan-admit-batch.mjs admit-material admit-supply
*/
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const MENUS = [
{ pageKey: 'admit-material', menu: '物料准入管理', pageTitle: '基础设置 > 准入管理 > 物料准入管理' },
{ pageKey: 'admit-product-apply', menu: '产品准入申请', pageTitle: '基础设置 > 准入管理 > 产品准入申请' },
{ pageKey: 'admit-material-list', menu: '物料准入单列表', pageTitle: '基础设置 > 准入管理 > 物料准入单列表' },
{ pageKey: 'admit-product-list', menu: '产品准入单列表', pageTitle: '基础设置 > 准入管理 > 产品准入单列表' },
{ pageKey: 'admit-supply', menu: '供货目录申请', pageTitle: '基础设置 > 准入管理 > 供货目录申请' },
{ pageKey: 'admit-task', menu: '准入任务分配', pageTitle: '基础设置 > 准入管理 > 准入任务分配' },
];
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await page.getByRole('button', { name: /登\s*录/ }).click();
await page.waitForURL(/\/spd\/home/, { timeout: 120_000 });
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
async function discoverMenuUrls(menus) {
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
storageState: AUTH_STATE,
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
});
const page = await context.newPage();
const urls = {};
for (const item of menus) {
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(1500);
if (page.url().includes('/login')) {
throw new Error('登录态失效,无法打开准入管理菜单');
}
await page.getByRole('menuitem', { name: '基础设置' }).click({ timeout: 20_000 });
await page.waitForTimeout(500);
const admitTitle = page.locator('.ant-menu-submenu-title').filter({ hasText: /^准入管理$/ }).first();
if (await admitTitle.isVisible().catch(() => false)) {
const expanded = await admitTitle.locator('xpath=..').evaluate((el) => el.classList.contains('ant-menu-submenu-open')).catch(() => false);
if (!expanded) await admitTitle.click({ timeout: 10_000 });
}
await page.locator('.ant-menu-item').filter({ hasText: new RegExp(`^\\s*${item.menu}\\s*$`) }).first().click({ timeout: 20_000 });
await page.waitForTimeout(2000);
const url = page.url().split('?')[0];
if (url.includes('/login') || url.endsWith('/spd/home/index')) {
throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`);
}
urls[item.pageKey] = url;
console.log(`URL ${item.menu}: ${url}`);
}
await browser.close();
return urls;
}
async function openFirstDropdown(page) {
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').first().waitFor({
state: 'visible',
timeout: 8_000,
}).catch(() => undefined);
await page.waitForTimeout(400);
return { dropdownOpened: true };
}
async function layerOpened(page) {
const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
if (modal) return true;
return page.locator('.put-page').first().isVisible().catch(() => false);
}
async function openNamedButton(page, pattern, flag) {
const btn = page.getByRole('button', { name: pattern }).first();
if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
await btn.click({ timeout: 12_000, force: true });
await page.locator('.ant-modal, .ant-drawer-open, .put-page').first().waitFor({ state: 'visible', timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(500);
const opened = await layerOpened(page);
return { [flag]: opened, skipRescan: !opened };
}
async function openRowAction(page, pattern, flag) {
const row = page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
let action = row.getByRole('button', { name: pattern }).first();
if (!(await action.isVisible().catch(() => false))) {
action = row.locator('a,button,span').filter({ hasText: pattern }).first();
}
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
const before = page.url();
const hadDetail = await page.locator('.put-page').first().isVisible().catch(() => false);
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(800);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { [flag]: true, navigated: true, returnUrl: before };
const opened = (await layerOpened(page)) && (!hadDetail || await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false) || await page.locator('.put-page').first().isVisible().catch(() => false));
return { [flag]: Boolean(opened), skipRescan: !opened };
}
function pageCfg(targetUrl, item) {
return {
targetUrl,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-basic',
pageKey: item.pageKey,
pageTitle: item.pageTitle,
jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
jsonSourceName: `${item.pageKey}-elements.json`,
waitSelectors: ['.ant-form-item input', '.ant-form', '.ant-table', '.ant-btn'],
waitSelector: '.ant-form, .ant-table, .ant-btn',
waitUntil: 'domcontentloaded',
expandFirstRow: false,
openAdvancedFilter: false,
interactivePhases: [
openFirstDropdown,
(page) => openNamedButton(page, /新\s*增/, 'dialogOpened'),
(page) => openNamedButton(page, /关联产品/, 'dialogOpened'),
(page) => openRowAction(page, /详\s*情/, 'detailOpened'),
(page) => openRowAction(page, /编\s*辑/, 'editDialogOpened'),
(page) => openRowAction(page, /日\s*志/, 'logOpened'),
],
};
}
async function main() {
const filter = process.argv.slice(2);
const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS;
if (!menus.length) {
console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const urls = await discoverMenuUrls(menus);
const summary = [];
for (const item of menus) {
const targetUrl = urls[item.pageKey];
console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`);
try {
const result = await runScan(pageCfg(targetUrl, item));
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: item.pageKey,
pageTitle: item.pageTitle,
module: 'spd-basic',
jsonSourceName: `${item.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
});
summary.push({ page: item.pageKey, ok: true, ...result, yamlOut: yaml.yamlOut });
} catch (err) {
console.error(`FAILED ${item.pageKey}:`, err);
summary.push({ page: item.pageKey, ok: false, error: String(err) });
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
#!/usr/bin/env node
/**
* Batch scan: 基础设置 > 医院资料 (4 pages) from UI-SPEC BASIC-001~004.
* Multi-phase: list + dropdown/dialog/edit/confirm/upload/richtext.
*
* Usage:
* node scripts/scan-basic-batch.mjs
* node scripts/scan-basic-batch.mjs basic-notice basic-branch
*/
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const BASIC_BASE = `${BASE}/spd/basic/hospital`;
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const COMMON_WAIT = [
'.ant-form-item input',
'.ant-form',
'.ant-table',
'.ant-btn',
'#form_item_title',
'#form_item_settleMonthDate',
];
async function clickButton(content, pattern) {
const btn = content.getByRole('button', { name: pattern }).first();
if (await btn.isVisible().catch(() => false)) {
await btn.click({ timeout: 15_000 });
return true;
}
return false;
}
async function closeOverlays(page) {
const footerCancel = page.locator('.ant-modal .ant-modal-footer button').filter({ hasText: /取\s*消/ }).last();
if (await footerCancel.isVisible().catch(() => false)) {
await footerCancel.click({ timeout: 8_000 }).catch(() => undefined);
await page.locator('.ant-modal-wrap').first().waitFor({ state: 'hidden', timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(400);
}
for (let i = 0; i < 3; i += 1) {
const popCancel = page
.locator('.ant-popconfirm button, .ant-popover-open button, .ant-modal-confirm button')
.filter({ hasText: /取\s*消/ })
.last();
if (await popCancel.isVisible().catch(() => false)) {
await popCancel.click({ timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(300);
continue;
}
const modalClose = page.locator('.ant-modal-wrap .ant-modal-close').last();
if (await modalClose.isVisible().catch(() => false)) {
await modalClose.click({ timeout: 5_000 }).catch(() => undefined);
await page.locator('.ant-modal-wrap').first().waitFor({ state: 'hidden', timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(400);
continue;
}
break;
}
}
function firstDataRow(page) {
return page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
}
async function waitModal(content, titleRe, timeout = 15_000) {
const loc = titleRe
? content.locator('.ant-modal').filter({ has: content.locator('.ant-modal-title', { hasText: titleRe }) })
: content.locator('.ant-modal-wrap .ant-modal, .ant-modal').first();
await loc.first().waitFor({ state: 'visible', timeout }).catch(() => undefined);
return loc.first();
}
async function openAddModal(content, titleRe) {
const clicked = await clickButton(content, /新\s*增/);
if (!clicked) return { dialogOpened: false };
await waitModal(content, titleRe);
return { dialogOpened: true };
}
async function openPublishScopeDropdown(page) {
const select = page.locator('.ant-form-item').filter({ hasText: '发布范围' }).locator('.ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false };
await select.click({ timeout: 10_000 });
await page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').first().waitFor({
state: 'visible',
timeout: 10_000,
}).catch(() => undefined);
await page.waitForTimeout(400);
return { dropdownOpened: true };
}
async function openNoticeAddWithEditor(page) {
await closeOverlays(page);
const clicked = await clickButton(page, /新\s*增/);
if (!clicked) return { dialogOpened: false, richTextOpened: false };
await waitModal(page, /新增公告/);
await page.locator('.ant-modal .ql-editor, .ant-modal [contenteditable="true"]').first().waitFor({
state: 'visible',
timeout: 10_000,
}).catch(() => undefined);
await page.waitForTimeout(500);
const rich = await page.locator('.ant-modal .ql-editor').first().isVisible().catch(() => false);
return { dialogOpened: true, richTextOpened: Boolean(rich) };
}
async function openNoticeDeleteConfirm(page) {
await closeOverlays(page);
const del = firstDataRow(page).getByRole('button', { name: /删\s*除/ });
if (!(await del.isVisible().catch(() => false))) {
const fallback = page.getByRole('button', { name: /删\s*除/ }).first();
if (!(await fallback.isVisible().catch(() => false))) return { deleteConfirmOpened: false };
await fallback.click({ timeout: 10_000 });
} else {
await del.click({ timeout: 10_000 });
}
await page
.locator('.ant-popconfirm, .ant-popover-open, .ant-modal-confirm, .ant-modal')
.first()
.waitFor({ state: 'visible', timeout: 10_000 })
.catch(() => undefined);
await page.waitForTimeout(400);
return { deleteConfirmOpened: true };
}
async function openNoticeEditModal(page) {
await closeOverlays(page);
const edit = firstDataRow(page).getByRole('button', { name: /编\s*辑/ });
if (await edit.isVisible().catch(() => false)) {
await edit.click({ timeout: 10_000 });
} else {
const fallback = page.getByRole('button', { name: /编\s*辑/ }).first();
if (!(await fallback.isVisible().catch(() => false))) return { editDialogOpened: false };
await fallback.click({ timeout: 10_000 });
}
await waitModal(page, /编辑公告|新增公告|公告/);
await page.locator('.ant-modal .ql-editor, .ant-modal [contenteditable="true"]').first().waitFor({
state: 'visible',
timeout: 8_000,
}).catch(() => undefined);
await page.waitForTimeout(500);
return { editDialogOpened: true };
}
async function openHospitalEdit(page) {
const clicked = await clickButton(page, /编\s*辑/);
if (!clicked) return { editModeOpened: false };
await page.getByRole('button', { name: /保\s*存/ }).first().waitFor({ state: 'visible', timeout: 15_000 }).catch(() => undefined);
await page.waitForTimeout(400);
return { editModeOpened: true };
}
async function openFinanceMonthEdit(page) {
const clicked = await clickButton(page, /修\s*改/);
if (!clicked) return { editModeOpened: false };
await page
.locator('.ant-table-tbody .ant-picker, .ant-table-tbody input, .ant-table-tbody button')
.first()
.waitFor({ state: 'visible', timeout: 10_000 })
.catch(() => undefined);
await page.waitForTimeout(600);
return { editModeOpened: true };
}
async function openBranchEditModal(page) {
await closeOverlays(page);
const edit = firstDataRow(page).getByRole('button', { name: /编\s*辑/ });
if (await edit.isVisible().catch(() => false)) {
await edit.click({ timeout: 10_000 });
} else {
const fallback = page.getByRole('button', { name: /编\s*辑/ }).first();
if (!(await fallback.isVisible().catch(() => false))) return { editDialogOpened: false };
await fallback.click({ timeout: 10_000 });
}
await waitModal(page, /编辑院区/);
await page.waitForTimeout(500);
return { editDialogOpened: true };
}
function pageCfg(reportPath, rest) {
return {
targetUrl: `${BASIC_BASE}${reportPath}`,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-basic',
jsonOut: path.join(OUT_DIR, `${rest.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${rest.pageKey}.locator.yaml`),
jsonSourceName: `${rest.pageKey}-elements.json`,
waitSelectors: COMMON_WAIT,
waitSelector: '.ant-form-item input, .ant-table, .ant-btn, .ant-form',
expandFirstRow: false,
openAdvancedFilter: false,
skipQueryClick: rest.skipQueryClick ?? false,
...rest,
};
}
const PAGES = [
pageCfg('/paltform/notice', {
pageKey: 'basic-notice',
pageTitle: '基础设置 > 医院资料 > 公告管理',
interactivePhases: [
openPublishScopeDropdown,
openNoticeAddWithEditor,
openNoticeDeleteConfirm,
openNoticeEditModal,
],
}),
pageCfg('/paltform/info', {
pageKey: 'basic-hospital-info',
pageTitle: '基础设置 > 医院资料 > 医院信息登记',
skipQueryClick: true,
interactivePhases: [openHospitalEdit],
}),
pageCfg('/balance/month', {
pageKey: 'basic-finance-month',
pageTitle: '基础设置 > 医院资料 > 财务月份设置',
interactivePhases: [openFinanceMonthEdit],
}),
pageCfg('/paltform/area', {
pageKey: 'basic-branch',
pageTitle: '基础设置 > 医院资料 > 院区管理',
skipQueryClick: true,
interactivePhases: [
async (page) => {
await closeOverlays(page);
return openAddModal(page, /新增院区/);
},
openBranchEditModal,
],
}),
];
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await page.getByRole('button', { name: /登\s*录/ }).click();
await page.waitForURL(/\/spd\/home/, { timeout: 120_000 });
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
async function main() {
const filter = process.argv.slice(2);
const pages = filter.length ? PAGES.filter((p) => filter.includes(p.pageKey)) : PAGES;
if (!pages.length) {
console.error('No pages matched. Available:', PAGES.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const summary = [];
for (const page of pages) {
console.log(`\n=== Scanning ${page.pageKey} ===`);
try {
const result = await runScan(page);
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: page.pageKey,
pageTitle: page.pageTitle,
module: 'spd-basic',
jsonSourceName: `${page.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${page.pageKey}.locator.yaml`),
});
summary.push({ page: page.pageKey, ok: true, ...result, yamlOut: yaml.yamlOut });
} catch (err) {
console.error(`FAILED ${page.pageKey}:`, err);
summary.push({ page: page.pageKey, ok: false, error: String(err) });
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
......@@ -52,7 +52,9 @@ const SCAN_SCRIPT = `(() => {
'.ant-pagination-options-size-changer,.ant-pagination-options-quick-jumper input,' +
'.ant-table-column-sorter,.ant-dropdown-trigger,.ant-tabs-tab,.ant-drawer-close,' +
'.ant-modal-close,.advance-filter,.ant-cascader,.ant-drawer-footer .ant-btn,' +
'.ant-table-row-expand-icon,.ant-table-row-expand-icon-cell';
'.ant-table-row-expand-icon,.ant-table-row-expand-icon-cell,' +
// antd Tree:节点无 tabindex/role,checkbox 不是原生 input,需显式纳入
'.ant-tree-checkbox,.ant-tree-switcher,.ant-tree-title';
function isVisible(el) {
const style = window.getComputedStyle(el);
......@@ -68,6 +70,9 @@ const SCAN_SCRIPT = `(() => {
}
function labelFor(el) {
// 树节点(权限树 / 分类树)的语义来自自身文本,不能回溯到外层表单标签,
// 否则所有节点都会被打上同一个 label(如「角色名称」)而无法区分。
if (el.closest('.ant-tree')) return '';
if (el.id) {
const lbl = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
if (lbl) return norm(lbl.textContent);
......@@ -91,6 +96,9 @@ const SCAN_SCRIPT = `(() => {
if (el.classList.contains('ant-picker')) return 'datepicker';
if (el.classList.contains('ant-btn')) return 'button';
if (el.classList.contains('ant-checkbox-wrapper')) return 'checkbox';
if (el.classList.contains('ant-tree-checkbox')) return 'checkbox';
if (el.classList.contains('ant-tree-switcher')) return 'tree_switcher';
if (el.classList.contains('ant-tree-title')) return 'tree_title';
if (el.classList.contains('ant-radio-wrapper')) return 'radio';
if (el.classList.contains('ant-tabs-tab')) return 'tab';
if (el.classList.contains('advance-filter')) return 'filter_trigger';
......@@ -122,6 +130,35 @@ const SCAN_SCRIPT = `(() => {
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\\]^ \`{|}~])/g, '\\\\$1');
}
// 树节点祖先路径:用于生成可读且唯一的树定位器键(如 基础设置 > 医院资料 > 院区管理)。
// 注意:antd Tree 的 DOM 是扁平的(靠 level 缩进表示层级),不能靠 parentElement 回溯。
function treePathOf(el) {
const tree = el.closest('.ant-tree');
if (!tree) return null;
const titleNode = el.closest('.ant-tree-treenode') || (el.classList.contains('ant-tree-title') ? el.parentElement?.closest('.ant-tree-treenode') : null);
if (!titleNode) return null;
const all = [...tree.querySelectorAll('.ant-tree-treenode')];
const idx = all.indexOf(titleNode);
if (idx < 0) return null;
const levelOf = (n) => Number((n.className.match(/ant-tree-treenode-level-(\\d+)/) || [])[1] ?? 0);
const targetLevel = levelOf(titleNode);
const path = [];
const self = titleNode.querySelector('.ant-tree-title');
const selfText = self ? norm(self.textContent) : '';
if (!selfText) return null;
path.unshift(selfText);
let need = targetLevel - 1;
for (let i = idx - 1; i >= 0 && need >= 0; i -= 1) {
if (levelOf(all[i]) === need) {
const t = all[i].querySelector('.ant-tree-title');
const txt = t ? norm(t.textContent) : '';
if (txt) path.unshift(txt);
need -= 1;
}
}
return path;
}
function buildLocators(el, label, text, type) {
const locators = [];
const testId = el.getAttribute('data-testid') || el.getAttribute('data-test');
......@@ -164,18 +201,64 @@ const SCAN_SCRIPT = `(() => {
if (name && name.length <= 40) {
locators.push({ strategy: 'text', value: name, priority: 8 });
}
// 树节点:优先用树容器 + 节点文本精确定位,避免与页面同名菜单/行混淆
if (el.closest('.ant-tree') && text) {
const treeCls = el.closest('.ant-tree')?.className || '';
const treeSel = String(treeCls).includes('menu-role-tree') ? '.menu-role-tree' : '.ant-tree';
if (el.classList.contains('ant-tree-title')) {
locators.unshift({
strategy: 'tree_node',
value: text,
tree: treeSel,
priority: 1,
});
} else if (el.classList.contains('ant-tree-checkbox')) {
const nodeText = norm(el.closest('.ant-tree-treenode')?.querySelector('.ant-tree-title')?.textContent || '');
if (nodeText) {
locators.unshift({
strategy: 'tree_checkbox',
value: nodeText,
tree: treeSel,
priority: 1,
});
}
}
}
locators.sort((a, b) => a.priority - b.priority);
return locators;
}
function sectionOf(el) {
if (el.closest('.ant-pagination')) return 'pagination';
const drawer = el.closest('.ant-drawer-open, .ant-drawer');
const detail = el.closest('.put-page');
if (detail) {
const title = detail.querySelector('.ant-descriptions-title, .ant-page-header-heading-title');
const t = title ? norm(title.textContent) : '';
return t ? 'detail:' + t : 'detail';
}
const modal = el.closest('.ant-modal');
if (modal) {
const wrap = modal.closest('.ant-modal-wrap');
const hidden = wrap && (wrap.style.display === 'none' || wrap.classList.contains('ant-modal-wrap-hidden'));
if (!hidden) {
const title = modal.querySelector('.ant-modal-title');
const t = title ? norm(title.textContent) : '';
return t ? 'modal:' + t : 'modal';
}
}
const pop = el.closest('.ant-popconfirm, .ant-modal-confirm');
if (pop) return 'confirm';
const dd = el.closest('.ant-select-dropdown, .ant-picker-dropdown, .ant-dropdown');
if (dd && !dd.classList.contains('ant-select-dropdown-hidden') && !dd.classList.contains('ant-picker-dropdown-hidden') && !dd.classList.contains('ant-dropdown-hidden')) {
return 'dropdown';
}
const drawer = el.closest('.ant-drawer');
if (drawer && drawer.classList.contains('ant-drawer-open')) {
if (el.closest('.ant-drawer-footer')) return 'panel:高级筛选:footer';
const title = drawer.querySelector('.ant-drawer-title, .ant-modal-title');
if (title) return 'panel:' + norm(title.textContent);
if (el.closest('.ant-drawer-footer')) return 'panel:drawer:footer';
const title = drawer.querySelector('.ant-drawer-title');
if (title && norm(title.textContent)) return 'panel:' + norm(title.textContent);
return 'panel:drawer';
}
if (el.closest('.ant-pagination')) return 'pagination';
const tab = el.closest('.ant-tabs-tabpane-active');
if (tab) {
const tabBtn = document.querySelector('.ant-tabs-tab-active');
......@@ -231,6 +314,7 @@ const SCAN_SCRIPT = `(() => {
},
locators,
recommended: best,
treePath: treePathOf(el),
});
});
......@@ -291,6 +375,16 @@ function toPlaywrightExpr(loc, frameLabel) {
return `${prefix}.getByText('${loc.value.replace(/'/g, "\\'")}', { exact: true })`;
case 'text':
return `${prefix}.getByText('${loc.value.replace(/'/g, "\\'")}')`;
case 'tree_node': {
const tree = loc.tree || '.ant-tree';
const t = loc.value.replace(/'/g, "\\'");
return `${prefix}.locator('${tree} .ant-tree-title').filter({ hasText: /^\\s*${t}\\s*$/ })`;
}
case 'tree_checkbox': {
const tree = loc.tree || '.ant-tree';
const t = loc.value.replace(/'/g, "\\'");
return `${prefix}.locator('${tree} .ant-tree-treenode').filter({ hasText: /^\\s*${t}\\s*$/ }).locator('.ant-tree-checkbox')`;
}
case 'css':
return `${prefix}.locator('${loc.value.replace(/'/g, "\\'")}')`;
default:
......@@ -328,6 +422,49 @@ const FORM_ID_KEY_MAP = {
form_item_billDate: 'documentDateStart',
form_item_statPeriod: 'statPeriodStart',
form_item_goodsKind: 'consumableType',
// 手术套包设置(基础包维护 / 术种维护 / 标准套包维护)
table_search_form_keyword: 'productName',
table_search_form_goodsSpec: 'specModel',
table_search_form_goodsMfrsName: 'manufacturer',
table_search_form_operationName: 'operationName',
table_search_form_disease: 'diseaseName',
form_item_pkgName: 'basePkgName',
form_item_pkgCode: 'basePkgCode',
form_item_kindId: 'basePkgCategory',
form_item_kindName: 'basePkgCategoryName',
form_item_remark: 'remark',
basic_kindName: 'operTypeCategoryName',
// 医院资料 / 厂商 / 准入 / 用户
form_item_title: 'noticeTitle',
form_item_expireDate: 'expireDate',
form_item_provId: 'supplierId',
form_item_subProvId: 'subSupplierId',
table_search_form_provName: 'supplierName',
table_search_form_provId: 'supplierId',
table_search_form_dateValue: 'dateRange',
table_search_form_orderNo: 'documentNo',
table_search_form_purModes: 'purchaseMode',
form_item_goodsMfrsId: 'manufacturer',
form_item_goodsSpec: 'specModel',
form_item_twoHourFlag: 'twoHourArrival',
form_item_daysOfStockout: 'stockoutDays',
};
// 仅有 placeholder、无 label/id 的输入框(树搜索、弹窗内搜索等)
const PLACEHOLDER_KEY_MAP = {
请输入分类名称: 'categorySearchInput',
请输入术种分类名称: 'operTypeCategorySearchInput',
'请输入术种分类名称、术种名称、标准套包名称': 'stdPkgTreeSearchInput',
'请输入分类/基础包名称': 'addBasePkgSearchInput',
厂商名称: 'mfrsNameInput',
公告标题: 'noticeTitleInput',
院区名称: 'campusNameInput',
请输入院区名称: 'campusNameInput',
请输入方案编码: 'schemeCodeInput',
请输入指标编码: 'indicatorCodeInput',
请输入指标名称: 'indicatorNameInput',
'请输入名称/年度': 'nameOrYearInput',
'请输入产品编码、名称': 'productCodeNameInput',
};
function slugify(s) {
......@@ -402,12 +539,152 @@ function slugify(s) {
科室名称: 'dimensionDeptName',
产品编号名称: 'productName',
'产品编号/名称': 'productName',
// 手术套包设置
基础包维护: 'basePkgMaintenance',
术种维护: 'operTypeMaintenance',
标准套包维护: 'stdPkgMaintenance',
基础包名称: 'basePkgName',
包编码: 'basePkgCode',
所属分类: 'basePkgCategory',
分类名称: 'categoryName',
术种分类名称: 'operTypeCategoryName',
术种分类: 'operTypeCategory',
术种名称: 'operationName',
病种名称: 'diseaseName',
诊断编码: 'diagnosisCode',
诊断名称: 'diagnosisName',
操作编码: 'operationCode',
操作名称: 'operationName',
新增分类: 'addCategory',
新增基础包: 'addBasePkg',
新增术种: 'addOperType',
添加基础包: 'addBasePkg',
添加散件: 'addLoosePart',
添加产品: 'addProduct',
新增: 'add',
'新 增': 'add',
编辑: 'edit',
移除: 'remove',
停用: 'disable',
启用: 'enable',
保存: 'save',
'保 存': 'save',
取消: 'cancel',
'取 消': 'cancel',
确认: 'confirm',
'确 认': 'confirm',
确定: 'confirm',
'确定(0)': 'confirm',
关闭: 'close',
'关 闭': 'close',
返回: 'back',
'返 回': 'back',
展开: 'expandFilter',
收起: 'collapseFilter',
产品概览: 'productOverview',
停用Tab: 'tabDisabled',
未建分类: 'tabUncategorized',
详情: 'detail',
日志: 'log',
提交: 'submit',
'提 交': 'submit',
操作: 'action',
状态: 'status',
备注: 'remark',
内容: 'content',
年度: 'year',
变更类型: 'changeType',
截止日期: 'expireDate',
新增日期: 'createDate',
公告标题: 'noticeTitle',
发布日期: 'publishDate',
发布范围: 'publishScope',
厂商名称: 'mfrsName',
器械注册人: 'registrant',
经销商: 'distributor',
方案编码: 'schemeCode',
指标编号: 'indicatorCode',
二级供应商: 'subSupplier',
是否2小时到货: 'twoHourArrival',
授权链路效期预警: 'authExpiryWarn',
一致: 'matched',
不一致: 'mismatched',
// 基础设置子菜单
医院资料: 'hospitalInfo',
院区管理: 'branchManage',
公告管理: 'noticeManage',
财务月份设置: 'financeMonthSetting',
医院信息登记: 'hospitalInfoRegister',
厂商管理: 'vendorManage',
厂商查询: 'vendorQuery',
供应商资质管理: 'vendorQual',
授权变更初审: 'authFirst',
授权变更终审: 'authFinal',
注册证变更初审: 'certFirst',
注册证变更终审: 'certFinal',
注册证差异对比: 'certDiff',
评价方案维护: 'evalScheme',
评价指标维护: 'evalIndicator',
供应商节假日管理: 'vendorHoliday',
准入管理: 'admitManage',
物料准入管理: 'admitMaterial',
产品准入申请: 'admitProductApply',
物料准入单列表: 'admitMaterialList',
产品准入单列表: 'admitProductList',
供货目录申请: 'admitSupply',
准入任务分配: 'admitTask',
手术套包设置: 'surgerySetting',
用户管理: 'userManage',
医共体用户管理: 'unionUserManage',
角色管理: 'roleManage',
// 顶部主导航
首页: 'home',
基础设置: 'basicSetting',
采购管理: 'purchase',
期初管理: 'opening',
中心库管理: 'centerStock',
科室业务管理: 'deptBusiness',
财务管理: 'finance',
骨科带量: 'orthoVolume',
综合管理: 'general',
阳采管理: 'yangcai',
流程中心: 'processCenter',
后勤物资管理: 'logistics',
统计报表: 'statReport',
数据可视化: 'dataViz',
};
if (map[s]) return map[s];
if (map[normalized]) return map[normalized];
const ascii = s.match(/[a-zA-Z0-9_]+/g);
if (ascii?.length) return ascii.join('_').toLowerCase();
return `el_${Buffer.from(s).toString('hex').slice(0, 8)}`;
// 含汉字时不要抽中间的数字当键(否则「是否2小时到货」会塌成 Select/Input)
if (!/[\u4e00-\u9fff]/.test(normalized)) {
const ascii = s.match(/[a-zA-Z0-9_]+/g);
if (ascii?.length) return ascii.join('_').toLowerCase();
}
// 中文兜底:用完整 hex 而非前 8 位,避免「基础设置/医院资料…」等前缀相同导致键塌缩
return `el_${Buffer.from(normalized).toString('hex')}`;
}
function isNoiseElement(el) {
const rec = el.recommended || {};
const recVal = String(rec.value || el.label || '').trim();
const recText = String(el.text || '').trim();
// 图标类 aria-label(more / remove / Close 等):不稳定且无业务语义
if (rec.strategy === 'label' && /^(more|remove|close|delete|edit|info-circle|question-circle|down|up|left|right|increase value|decrease value)$/i.test(String(rec.value || ''))) {
return true;
}
// 纯图标按钮:无文案、无 placeholder、无 id
const meaninglessText = !el.text || /^(more|remove|close|delete)$/i.test(recText);
if (rec.strategy === 'label' && meaninglessText) return true;
if (/^(Increase Value|Decrease Value)$/i.test(recText)) return true;
// 表头排序器只保留一个(已在循环内处理);此处过滤无文案的排序 span
if (el.className?.includes('ant-table-column-sorter') && !el.text) return true;
// 顶部用户栏信息块(院区切换 / 消息数 / 用户名)- 非页面业务元素
if (/^中心库[((]/.test(recVal) && (rec.strategy === 'label_text' || el.className?.includes('ant-dropdown-trigger'))) {
return true;
}
if (rec.strategy === 'label_text' && /^(中心库|99\+|\d+)$/.test(recVal)) return true;
if (/^(99\+)$/.test(recText) && el.className?.includes('ant-dropdown-trigger')) return true;
return false;
}
function buildYaml(scan) {
......@@ -418,9 +695,14 @@ function buildYaml(scan) {
lines.push(`# Page: ${CONFIG.pageTitle}`);
if (scan.advancedFilterOpened) lines.push('# Note: advanced filter panel was opened during scan');
if (scan.rowExpanded) lines.push('# Note: first table row was expanded during scan');
if (scan.dropdownOpened) lines.push('# Note: filter dropdown was opened during scan');
if (scan.dialogOpened) lines.push('# Note: dialog/modal was opened during scan');
if (scan.editDialogOpened) lines.push('# Note: edit dialog was opened during scan');
if (scan.detailOpened) lines.push('# Note: detail view was opened during scan');
if (scan.logOpened) lines.push('# Note: log view was opened during scan');
lines.push('');
lines.push(`page: ${CONFIG.pageKey}`);
lines.push('module: spd-stat');
lines.push(`module: ${CONFIG.module || 'spd-stat'}`);
lines.push(`url: ${scan.url}`);
lines.push('');
lines.push('locators:');
......@@ -444,9 +726,17 @@ function buildYaml(scan) {
el.recommended = { strategy: 'css', value: '.ant-table-column-sorter', priority: 9 };
}
if (!el.recommended) continue;
// 过滤无业务价值的噪声元素(图标 aria-label、行内重复操作按钮等)
if (isNoiseElement(el)) continue;
const frameLabel = frameLabelFromUrl(el.frame);
let key = el.id && FORM_ID_KEY_MAP[el.id] ? FORM_ID_KEY_MAP[el.id] : null;
// 树节点:用祖先路径生成可读且唯一的键,避免所有节点塌缩成同一个 hex 前缀
const isTree = el.type === 'tree_title' || el.type === 'tree_switcher' || (el.type === 'checkbox' && el.treePath);
let key = isTree && el.treePath ? 'tree_' + el.treePath.map(slugify).join('_') : null;
if (!key && el.id && FORM_ID_KEY_MAP[el.id]) key = FORM_ID_KEY_MAP[el.id];
if (!key && el.placeholder && !el.label && !el.id) {
key = PLACEHOLDER_KEY_MAP[el.placeholder] || null;
}
const baseName =
el.label ||
el.text ||
......@@ -491,7 +781,24 @@ function buildYaml(scan) {
if (section.includes('panel:高级筛选')) key = `drawer_${key}`;
if (section.includes('pagination')) key = `pagination_${key}`;
let finalKey = key.replace(/^[^a-zA-Z_]/, '').replace(/[^a-zA-Z0-9_]/g, '_') || `el_${el.index}`;
let finalKey;
if (isTree && el.treePath?.length) {
// 树节点:键必须包含完整祖先路径,否则不同模块下的同名菜单(如「库存查询」)会撞键。
// 叶子若是英文菜单则用可读键,中文叶子用短 hex 后缀保证唯一且稳定。
const leaf = slugify(el.treePath[el.treePath.length - 1]);
const leafReadable = /^[a-z][a-z0-9_]*$/i.test(leaf) && !leaf.startsWith('el_');
const kind = el.type === 'checkbox' ? 'cb' : el.type === 'tree_switcher' ? 'sw' : 'node';
const pathHash = Buffer.from(el.treePath.join('>')).toString('hex').slice(0, 12);
finalKey = leafReadable ? `tree_${kind}_${leaf}_${pathHash}` : `tree_${kind}_${pathHash}`;
} else {
finalKey = key.replace(/^[^a-zA-Z_]/, '').replace(/[^a-zA-Z0-9_]/g, '_') || `el_${el.index}`;
}
// 表格行内重复的行操作按钮(编辑/移除/详情等)只保留一个,避免 editBtn_2/3...
const isRowAction =
(el.type === 'button' || el.tag === 'button' || el.className?.includes('ant-btn')) &&
/Btn$/.test(finalKey) &&
(el.section || '').includes('main');
if (isRowAction && usedKeys.has(finalKey)) continue;
let n = 2;
while (usedKeys.has(finalKey)) {
finalKey = `${key}_${n++}`;
......@@ -513,6 +820,10 @@ function buildYaml(scan) {
if (rec.role) lines.push(` role: ${rec.role}`);
if (rec.name) lines.push(` name: ${JSON.stringify(rec.name)}`);
if (rec.value) lines.push(` value: ${JSON.stringify(rec.value)}`);
if (rec.tree) lines.push(` tree: ${JSON.stringify(rec.tree)}`);
}
if (el.treePath?.length > 1) {
lines.push(` tree_path: ${JSON.stringify(el.treePath.join(' > '))}`);
}
if (pw) lines.push(` playwright: ${JSON.stringify(pw)}`);
lines.push('');
......@@ -659,10 +970,65 @@ async function clickQueryIfPresent(reportFrame) {
return false;
}
function fingerprint(el) {
return [el.section, el.type, el.id || '', el.role || '', el.label || '', el.text || '', el.placeholder || ''].join('|');
}
async function closeScanOverlays(page) {
for (let i = 0; i < 6; i += 1) {
const back = page
.locator('.put-page, .ant-drawer-open, .ant-modal-wrap, .ant-modal')
.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ })
.first();
if (await back.isVisible().catch(() => false)) {
await back.click({ timeout: 4_000, force: true }).catch(() => undefined);
await page.waitForTimeout(400);
continue;
}
const backText = page.locator('a,button,span').filter({ hasText: /^返\s*回$/ }).last();
if (await backText.isVisible().catch(() => false)) {
await backText.click({ timeout: 4_000, force: true }).catch(() => undefined);
await page.waitForTimeout(400);
continue;
}
const cancel = page
.locator('.ant-modal-footer button, .ant-modal-confirm button, .ant-popconfirm button, .ant-drawer-footer button')
.filter({ hasText: /取\s*消|关\s*闭/ })
.last();
if (await cancel.isVisible().catch(() => false)) {
await cancel.click({ timeout: 4_000, force: true }).catch(() => undefined);
await page.waitForTimeout(300);
continue;
}
const closer = page.locator('.ant-modal-wrap .ant-modal-close, .ant-drawer-close').last();
if (await closer.isVisible().catch(() => false)) {
await closer.click({ timeout: 4_000, force: true }).catch(() => undefined);
await page.waitForTimeout(300);
continue;
}
const closedByScript = await page.evaluate(() => {
const layer = document.querySelector('.put-page');
if (!layer) return false;
const btn = [...layer.querySelectorAll('button,a,span')].find((el) => /返\s*回|关\s*闭/.test((el.textContent || '').trim()));
if (!btn) return false;
btn.click();
return true;
}).catch(() => false);
if (closedByScript) {
await page.waitForTimeout(400);
continue;
}
await page.keyboard.press('Escape').catch(() => undefined);
break;
}
}
async function prepareReportPage(page) {
const reportFrame = page.frameLocator(`iframe[src*="${CONFIG.iframeMatch}"]`);
const reportFrame = CONFIG.useIframe === false
? page
: page.frameLocator(`iframe[src*="${CONFIG.iframeMatch}"]`);
const readySelector = await waitReportReady(reportFrame);
console.log(` iframe ready (${readySelector})`);
console.log(` content ready (${readySelector})`);
const meta = { queryExecuted: false, advancedFilterOpened: false, rowExpanded: false };
if (CONFIG.skipQueryClick !== true) {
......@@ -723,7 +1089,10 @@ export async function runScan(overrides = {}) {
const page = await context.newPage();
console.log('Navigating to', CONFIG.targetUrl);
await page.goto(CONFIG.targetUrl, { waitUntil: 'networkidle', timeout: 120_000 });
await page.goto(CONFIG.targetUrl, {
waitUntil: CONFIG.waitUntil || 'networkidle',
timeout: 120_000,
});
await page.waitForTimeout(5000);
const prepMeta = await prepareReportPage(page);
......@@ -731,6 +1100,43 @@ export async function runScan(overrides = {}) {
let scan = await scanPageOrFrames(page);
Object.assign(scan, prepMeta);
const seen = new Set(scan.elements.map(fingerprint));
const phases = Array.isArray(CONFIG.interactivePhases) ? CONFIG.interactivePhases : [];
for (const phase of phases) {
let meta = {};
try {
meta = (await phase(page)) || {};
} catch (err) {
console.warn(' phase failed:', err);
meta = {};
}
Object.assign(scan, meta);
if (!meta.skipRescan) {
const extra = await scanPageOrFrames(page);
for (const el of extra.elements) {
const key = fingerprint(el);
if (seen.has(key)) continue;
seen.add(key);
scan.elements.push(el);
}
scan.frames.push(...extra.frames);
}
if (meta.navigated && meta.returnUrl) {
await page.goto(meta.returnUrl, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(1500);
} else {
await closeScanOverlays(page);
}
}
scan.elements.forEach((el, i) => {
el.index = i + 1;
});
scan.elementCount = scan.elements.length;
delete scan.skipRescan;
delete scan.navigated;
delete scan.returnUrl;
scan.url = sanitizeUrl(scan.url);
for (const el of scan.elements) {
if (el.frame) el.frame = frameLabelFromUrl(el.frame);
......
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