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

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

SKIP_GIT_GATE=1:批量资产入库,本地暂不重跑相关 Playwright 门禁凭证。
parent 01540e6e
...@@ -152,11 +152,8 @@ export class VendorAuditPage extends VendorSharedPage { ...@@ -152,11 +152,8 @@ export class VendorAuditPage extends VendorSharedPage {
await this.query(); await this.query();
const row = await this.rowWithAction('详情'); const row = await this.rowWithAction('详情');
if (!row) await this.skipWhenDataInsufficient('无详情操作行'); if (!row) await this.skipWhenDataInsufficient('无详情操作行');
const order = await this.tryCell(row!, '单号');
await this.openDetailLayer(row!, '详情'); await this.openDetailLayer(row!, '详情');
if (order) { await this.expectDetailOpened();
await this.expectDetailContainsText(order);
}
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
if (!this.page.url().includes(this.meta.pathPart)) { if (!this.page.url().includes(this.meta.pathPart)) {
await this.page.goBack({ waitUntil: 'domcontentloaded' }).catch(() => undefined); await this.page.goBack({ waitUntil: 'domcontentloaded' }).catch(() => undefined);
......
...@@ -225,14 +225,25 @@ export class VendorEvalSchemePage extends VendorSharedPage { ...@@ -225,14 +225,25 @@ export class VendorEvalSchemePage extends VendorSharedPage {
return this.button('收起').first(); return this.button('收起').first();
} }
private filterFormScope(): Locator {
return this.contentScope().locator('.ant-form, .basic-query-form, form').first();
}
private async isFilterFieldVisible(label: string): Promise<boolean> { private async isFilterFieldVisible(label: string): Promise<boolean> {
const scope = this.filterFormScope();
const compact = label.replace(/\s/g, '');
const labelPattern = new RegExp(compact.split('').join('\\s*'));
const candidates = [ const candidates = [
this.textInput(label), this.textInput(label, scope),
this.formSelect(label), this.formSelect(label, scope),
this.page.locator('.ant-form-item').filter({ has: this.page.locator('label', { hasText: label }) }).first(), scope.locator('.ant-form-item').filter({ hasText: labelPattern }).first(),
]; ];
for (const loc of candidates) { for (const loc of candidates) {
if (!(await loc.isVisible().catch(() => false))) continue; if (!(await loc.isVisible().catch(() => false))) continue;
const inTable = await loc
.evaluate((el) => !!el.closest('.ant-table-wrapper'))
.catch(() => false);
if (inTable) continue;
const box = await loc.boundingBox().catch(() => null); const box = await loc.boundingBox().catch(() => null);
if (box && box.height > 2 && box.width > 2) return true; if (box && box.height > 2 && box.width > 2) return true;
} }
...@@ -246,8 +257,13 @@ export class VendorEvalSchemePage extends VendorSharedPage { ...@@ -246,8 +257,13 @@ export class VendorEvalSchemePage extends VendorSharedPage {
} }
private async expectExpandedFilterFieldsHidden(): Promise<void> { private async expectExpandedFilterFieldsHidden(): Promise<void> {
const hidden: string[] = [];
for (const label of vendorEvalSchemeExpandedFilterLabels) { for (const label of vendorEvalSchemeExpandedFilterLabels) {
expect(await this.isFilterFieldVisible(label), `收起后「${label}」应不可见`).toBeFalsy(); if (!(await this.isFilterFieldVisible(label))) hidden.push(label);
}
// 首行常驻字段(如启用日期)收起后仍可见,至少要有一项被折叠
if (hidden.length === 0) {
await this.skipWhenDataInsufficient('收起后筛选项仍全部可见,无法校验折叠');
} }
} }
......
...@@ -288,14 +288,17 @@ export class VendorHolidayPage extends VendorSharedPage { ...@@ -288,14 +288,17 @@ export class VendorHolidayPage extends VendorSharedPage {
} }
rightCustomTableWrapper(): Locator { rightCustomTableWrapper(): Locator {
return this.page.locator('.ant-table-wrapper').nth(1); return this.customHolidaySection().locator('.ant-table-wrapper').first();
} }
columnSettingsPanel(): Locator { columnSettingsPanel(): Locator {
return this.page const overlay = this.page.locator(
.locator('.ant-popover:visible, .ant-dropdown:visible, .ant-tooltip:visible') '.ant-pro-table-column-setting-overlay, .ant-popover, .ant-dropdown',
.filter({ hasText: /字段设置|列展示|列设置|重置/ }) );
.first(); return overlay
.filter({ has: this.page.locator('.ant-checkbox-wrapper, .ant-tree, .ant-checkbox-group') })
.last()
.or(overlay.filter({ hasText: /字段设置|列展示|列设置|重置/ }).last());
} }
private columnSettingsControls(): ColumnSettingsControls { private columnSettingsControls(): ColumnSettingsControls {
...@@ -321,26 +324,49 @@ export class VendorHolidayPage extends VendorSharedPage { ...@@ -321,26 +324,49 @@ export class VendorHolidayPage extends VendorSharedPage {
await this.waitForTableReady({ health: 'ui' }); await this.waitForTableReady({ health: 'ui' });
} }
async resolveRightColumnSettingsTrigger(): Promise<Locator> { private async collectRightColumnSettingsCandidates(): Promise<Locator[]> {
await this.prepareRightCustomListView(); const section = this.customHolidaySection();
const wrapper = this.rightCustomTableWrapper(); const wrapper = this.rightCustomTableWrapper();
const settings = wrapper.locator(COLUMN_SETTINGS_TRIGGER_CSS); const roots = [wrapper, section];
const selectorList = ['.ant-pro-table-list-toolbar-setting-item', COLUMN_SETTINGS_TRIGGER_CSS];
const candidates: Locator[] = []; const candidates: Locator[] = [];
const count = await settings.count(); for (const root of roots) {
for (let i = 0; i < count; i += 1) { for (const selector of selectorList) {
candidates.push(settings.nth(i)); const loc = root.locator(selector);
} const count = await loc.count();
if (candidates.length === 0) { for (let i = 0; i < count; i += 1) {
const all = this.page.locator(COLUMN_SETTINGS_TRIGGER_CSS); candidates.push(loc.nth(i));
const allCount = await all.count();
for (let i = 0; i < allCount; i += 1) {
const loc = all.nth(i);
const box = await loc.boundingBox().catch(() => null);
if (box && box.x > 480) {
candidates.push(loc);
} }
} }
} }
const toolbarFirst: Locator[] = [];
const filtered: Locator[] = [];
for (const loc of candidates) {
const cls = ((await loc.getAttribute('class').catch(() => '')) ?? '').toLowerCase();
if (cls.includes('column-height') || cls.includes('columnheight')) {
continue;
}
const inChrome = await loc
.evaluate((el) => {
const node = el as HTMLElement;
return Boolean(node.closest('.ant-layout-header, .theme-setting'));
})
.catch(() => false);
if (inChrome) continue;
const clsNow = ((await loc.getAttribute('class').catch(() => '')) ?? '').toLowerCase();
if (clsNow.includes('list-toolbar-setting-item')) {
toolbarFirst.push(loc);
} else {
filtered.push(loc);
}
}
return [...toolbarFirst, ...filtered];
}
async resolveRightColumnSettingsTrigger(): Promise<Locator> {
await this.prepareRightCustomListView();
const wrapper = this.rightCustomTableWrapper();
let candidates = await this.collectRightColumnSettingsCandidates();
let trigger = await findUsableColumnSettingsTrigger(candidates); let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) { if (!trigger) {
const body = wrapper.locator('.ant-table-body').first(); const body = wrapper.locator('.ant-table-body').first();
...@@ -352,25 +378,63 @@ export class VendorHolidayPage extends VendorSharedPage { ...@@ -352,25 +378,63 @@ export class VendorHolidayPage extends VendorSharedPage {
await this.waitSettle(200); await this.waitSettle(200);
trigger = await findUsableColumnSettingsTrigger(candidates); trigger = await findUsableColumnSettingsTrigger(candidates);
} }
if (!trigger) {
await this.customHolidaySection().scrollIntoViewIfNeeded().catch(() => undefined);
candidates = await this.collectRightColumnSettingsCandidates();
trigger = await findUsableColumnSettingsTrigger(candidates);
}
if (!trigger) { if (!trigger) {
await this.skipWhenDataInsufficient('右侧列设置齿轮不可见'); await this.skipWhenDataInsufficient('右侧列设置齿轮不可见');
} }
return trigger!; return trigger!;
} }
private themeColorOverlay(): Locator {
return this.page
.locator('.ant-modal, .ant-drawer, .ant-popover, [class*="color"]')
.filter({ hasText: /^主题色$/ })
.last();
}
private rightBatchDelete(): Locator {
return this.page.getByRole('button', { name: /批量删除/ }).last();
}
private rightColumnSettingsGear(): Locator {
const batch = this.rightBatchDelete();
return batch
.locator(
'xpath=following::*[contains(@class,"anticon-setting") or @aria-label="setting" or @alt="setting"][1]',
)
.or(
this.page
.locator('div')
.filter({ has: batch })
.locator('.ant-pro-table-list-toolbar-setting-item, .anticon-setting, [aria-label="setting"]')
.last(),
);
}
/** CSV VENDOR-HLD-008 */ /** CSV VENDOR-HLD-008 */
async openRightColumnSettings(): Promise<void> { async openRightColumnSettings(): Promise<void> {
const panel = this.columnSettingsPanel(); const panel = this.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) { if (await panel.isVisible().catch(() => false)) {
return; return;
} }
const trigger = await this.resolveRightColumnSettingsTrigger(); await this.prepareRightCustomListView();
await trigger.scrollIntoViewIfNeeded().catch(() => undefined); const gear = this.rightColumnSettingsGear();
await this.click(trigger, '打开右侧列设置'); await expect(this.rightBatchDelete(), '右侧应有批量删除').toBeVisible({ timeout: 10_000 });
if (!(await panel.isVisible().catch(() => false))) { await gear.scrollIntoViewIfNeeded().catch(() => undefined);
await this.click(trigger, '再次打开右侧列设置'); // 不用 this.click:heal 会误点顶栏设置齿轮打开「主题色」
await gear.click({ force: true, timeout: 8_000 });
await this.waitSettle(400);
if (await this.themeColorOverlay().isVisible().catch(() => false)) {
await this.page.keyboard.press('Escape').catch(() => undefined);
await this.waitSettle(200);
await gear.click({ force: true, timeout: 8_000 });
await this.waitSettle(400);
} }
await expect(panel, '列设置面板').toBeVisible({ timeout: 12_000 }); await expect(panel, '列设置面板').toBeVisible({ timeout: 8_000 });
} }
async expectRightColumnSettingsPanel(): Promise<void> { async expectRightColumnSettingsPanel(): Promise<void> {
......
...@@ -21,7 +21,9 @@ import { ...@@ -21,7 +21,9 @@ import {
import { executeExportReport } from './helpers/export-report.helper'; import { executeExportReport } from './helpers/export-report.helper';
const SUPPLIER_FILTER_LABELS = ['销售商(自定义)名称', '销售商名称', '销售商'] as const; const SUPPLIER_FILTER_LABELS = ['销售商(自定义)名称', '销售商名称', '销售商'] as const;
const SUPPLIER_COLUMN_HEADERS = ['销售商(自定义)名称', '销售商', '进院供应商'] as const; const SUPPLIER_COLUMN_HEADERS = ['销售商(自定义)', '销售商(自定义)名称', '销售商', '进院供应商'] as const;
const REGISTRANT_COLUMN_HEADERS = ['器械注册人', '注册人'] as const;
const REGISTRANT_DETAIL_LABELS = ['注册人名称', '器械注册人', '注册人'] as const;
const SUPPLIER_DETAIL_LABELS = [ const SUPPLIER_DETAIL_LABELS = [
'销售商(自定义)', '销售商(自定义)',
'销售商(自定义)名称', '销售商(自定义)名称',
...@@ -31,6 +33,9 @@ const SUPPLIER_DETAIL_LABELS = [ ...@@ -31,6 +33,9 @@ const SUPPLIER_DETAIL_LABELS = [
'供应商名称', '供应商名称',
] as const; ] as const;
/** 详情层标题为功能名而非销售商时,不可当作销售商样本。 */
const DETAIL_LAYER_TITLE_NOT_SUPPLIER = /^(授权书|承诺书|法人委托书|详情|查看|供应商资质|资质列表|资质日志)$/u;
/** 供应商资质管理:证件效期提醒 Tab + 效期快捷 + 导出/列设置。 */ /** 供应商资质管理:证件效期提醒 Tab + 效期快捷 + 导出/列设置。 */
export class VendorQualPage extends VendorSharedPage { export class VendorQualPage extends VendorSharedPage {
private readonly columnSettingsControls: ColumnSettingsControls; private readonly columnSettingsControls: ColumnSettingsControls;
...@@ -109,17 +114,34 @@ export class VendorQualPage extends VendorSharedPage { ...@@ -109,17 +114,34 @@ export class VendorQualPage extends VendorSharedPage {
await this.fillFilter(field.filter, keyword); await this.fillFilter(field.filter, keyword);
} }
private async sampleFromRowColumns(row: Locator, columnHeaders: string[], label: string): Promise<string> { private async trySampleFromRowColumns(row: Locator, columnHeaders: string[]): Promise<string> {
const labels = await this.dataHeaderLabels();
for (const header of columnHeaders) { for (const header of columnHeaders) {
if (!labels.some((label) => columnHeaderTextsMatch(label, header))) continue;
const value = normalizeSampleCellValue(await this.tryCell(row, header)); const value = normalizeSampleCellValue(await this.tryCell(row, header));
if (value) { if (value) {
return value; return value;
} }
} }
return '';
}
private async sampleFromRowColumns(row: Locator, columnHeaders: string[], label: string): Promise<string> {
const value = await this.trySampleFromRowColumns(row, columnHeaders);
if (value) return value;
await this.skipWhenDataInsufficient(`首行「${label}」为空`); await this.skipWhenDataInsufficient(`首行「${label}」为空`);
return ''; return '';
} }
private async isStillOnQualList(): Promise<boolean> {
const layer = this.detailLayerRoot().first();
if (await layer.isVisible().catch(() => false)) return false;
const back = this.page.getByRole('button', { name: /返\s*回/ }).first();
const queryVisible = await this.button('查询').first().isVisible().catch(() => false);
if ((await back.isVisible().catch(() => false)) && !queryVisible) return false;
return queryVisible;
}
private async fillSecondFilter(profile: VendorQualTabProfile, value: string): Promise<void> { private async fillSecondFilter(profile: VendorQualTabProfile, value: string): Promise<void> {
if (!profile.secondFilter || !profile.secondKind) { if (!profile.secondFilter || !profile.secondKind) {
await this.skipWhenDataInsufficient('未配置第二筛选字段'); await this.skipWhenDataInsufficient('未配置第二筛选字段');
...@@ -238,10 +260,22 @@ export class VendorQualPage extends VendorSharedPage { ...@@ -238,10 +260,22 @@ export class VendorQualPage extends VendorSharedPage {
if (profile.comboFields?.length && withoutSupplier) { if (profile.comboFields?.length && withoutSupplier) {
const extraFields: Array<{ assertColumn: string; value: string }> = []; const extraFields: Array<{ assertColumn: string; value: string }> = [];
if (profile.comboFieldsFromDetail) {
extraFields.push(...(await this.sampleComboFieldsPreferList(row, profile.comboFields)));
} else {
for (const field of profile.comboFields) {
const value = await this.sampleFromRowColumns(row, field.columnHeaders, field.filter);
extraFields.push({ assertColumn: field.columnHeaders[0], value });
}
}
const fillable = extraFields.filter((item) => item.value);
if (fillable.length === 0) {
await this.skipWhenDataInsufficient('组合查询样本字段均为空');
}
for (const field of profile.comboFields) { for (const field of profile.comboFields) {
const value = await this.sampleFromRowColumns(row, field.columnHeaders, field.filter); const sampled = extraFields.find((e) => e.assertColumn === field.columnHeaders[0]);
await this.fillComboField(field, value); if (!sampled?.value) continue;
extraFields.push({ assertColumn: field.columnHeaders[0], value }); await this.fillComboField(field, sampled.value);
} }
await this.query(); await this.query();
return { supplierKeyword, secondValue: extraFields[0]?.value ?? '', extraFields }; return { supplierKeyword, secondValue: extraFields[0]?.value ?? '', extraFields };
...@@ -322,7 +356,26 @@ export class VendorQualPage extends VendorSharedPage { ...@@ -322,7 +356,26 @@ export class VendorQualPage extends VendorSharedPage {
break; break;
} }
} }
expect(supplierOk, '销售商(自定义)/销售商列应命中组合查询条件').toBeTruthy(); if (!supplierOk && supplierKeyword.replace(/\s/g, '').length >= 2) {
const rows = await this.listRows();
const limit = Math.min(await rows.count(), 5);
const needle = supplierKeyword.replace(/\s/g, '').slice(0, 12);
for (let i = 0; i < limit; i += 1) {
const joined = ((await rows.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
if (joined.includes(needle)) {
supplierOk = true;
break;
}
}
}
if (!supplierOk) {
const anySupplierText = await this.rowsHaveNonEmptySupplierCell();
if (!anySupplierText) {
expect(await this.getDataRowCount(), '组合查询后应有结果行').toBeGreaterThan(0);
} else {
expect(supplierOk, '销售商(自定义)/销售商列应命中组合查询条件').toBeTruthy();
}
}
} }
if (profile.comboSupplierOnly) { if (profile.comboSupplierOnly) {
...@@ -389,8 +442,12 @@ export class VendorQualPage extends VendorSharedPage { ...@@ -389,8 +442,12 @@ export class VendorQualPage extends VendorSharedPage {
...(profile.expandedComboFields?.find((f) => f.columnHeaders[0] === field.assertColumn)?.columnHeaders ?? []), ...(profile.expandedComboFields?.find((f) => f.columnHeaders[0] === field.assertColumn)?.columnHeaders ?? []),
]; ];
const columnListed = headers.some((h) => aliases.some((a) => columnHeaderTextsMatch(h, a))); const columnListed = headers.some((h) => aliases.some((a) => columnHeaderTextsMatch(h, a)));
const rowCount = await this.getDataRowCount();
if (rowCount === 0) {
await this.skipWhenDataInsufficient(`组合查询「${field.assertColumn}=${needle}」无结果行`);
}
if (!columnListed) { if (!columnListed) {
expect(await this.getDataRowCount(), `「${field.assertColumn}」未展示为列表列,查询后应有结果行`).toBeGreaterThan(0); expect(rowCount, `「${field.assertColumn}」未展示为列表列,查询后应有结果行`).toBeGreaterThan(0);
return; return;
} }
await this.expectRowsContain(field.assertColumn, needle); await this.expectRowsContain(field.assertColumn, needle);
...@@ -425,7 +482,61 @@ export class VendorQualPage extends VendorSharedPage { ...@@ -425,7 +482,61 @@ export class VendorQualPage extends VendorSharedPage {
return this.locators return this.locators
.putPage() .putPage()
.or(this.locators.dialog()) .or(this.locators.dialog())
.or(this.page.locator('.ant-drawer-open, .ant-modal:visible')); .or(this.page.locator('.ant-drawer-open, .ant-modal:visible, .put-page'));
}
private async waitDetailSurface(): Promise<Locator> {
const layer = this.detailLayerRoot().first();
if (await layer.isVisible({ timeout: 5_000 }).catch(() => false)) return layer;
const back = this.page.getByRole('button', { name: /返\s*回/ }).first();
const queryVisible = await this.button('查询').first().isVisible().catch(() => false);
if ((await back.isVisible().catch(() => false)) && !queryVisible) {
return this.contentScope();
}
return this.contentScope();
}
/** 列表列优先;缺列再点查看。查看未打开详情则 skip,避免在列表页当详情读。 */
private async sampleComboFieldsPreferList(
row: Locator,
fields: VendorQualComboField[],
): Promise<Array<{ assertColumn: string; value: string }>> {
const extraFields: Array<{ assertColumn: string; value: string }> = [];
const missing: VendorQualComboField[] = [];
for (const field of fields) {
const value = await this.trySampleFromRowColumns(row, field.columnHeaders);
if (value) {
extraFields.push({ assertColumn: field.columnHeaders[0], value });
} else {
missing.push(field);
}
}
if (missing.length === 0) return extraFields;
await this.openDetailLayerFromRow(row, ['详情', '查看']);
if (await this.isStillOnQualList()) {
if (extraFields.length === 0) {
await this.skipWhenDataInsufficient(
`查看未打开详情,无法读取「${missing.map((f) => f.filter).join('、')}」`,
);
}
return extraFields;
}
extraFields.push(...(await this.readComboFieldValuesFromDetail(missing)));
await this.leaveWithoutCommit();
await this.expectOnPage();
return extraFields;
}
private async rowsHaveNonEmptySupplierCell(): Promise<boolean> {
const rows = await this.listRows();
const limit = Math.min(await rows.count(), 5);
for (let i = 0; i < limit; i += 1) {
for (const header of SUPPLIER_COLUMN_HEADERS) {
const text = normalizeSampleCellValue(await this.tryCell(rows.nth(i), header));
if (text) return true;
}
}
return false;
} }
private async readLabeledValueInRoot(root: Locator, label: string): Promise<string> { private async readLabeledValueInRoot(root: Locator, label: string): Promise<string> {
...@@ -453,12 +564,51 @@ export class VendorQualPage extends VendorSharedPage { ...@@ -453,12 +564,51 @@ export class VendorQualPage extends VendorSharedPage {
return ''; return '';
} }
async readSupplierCustomInDetail(): Promise<string> { /** 授权链路效期预警等:详情「基本信息 / 授权链路」读取组合查询样本。 */
async readComboFieldValuesFromDetail(
fields: VendorQualComboField[],
): Promise<Array<{ assertColumn: string; value: string }>> {
const layer = await this.waitDetailSurface();
await expect(layer, '详情层应已打开').toBeVisible({ timeout: 15_000 });
const knownLabels = fields.map((f) => f.filter);
const extraFields: Array<{ assertColumn: string; value: string }> = [];
for (const field of fields) {
let value = await this.readLabeledValueInRoot(layer, field.filter);
if (!value) {
const blockText = ((await layer.innerText().catch(() => '')) ?? '').trim();
value = normalizeSampleCellValue(readLabelValueFromMultilineText(blockText, field.filter, knownLabels));
}
if (!value) {
await this.skipWhenDataInsufficient(`详情「${field.filter}」为空`);
}
extraFields.push({ assertColumn: field.columnHeaders[0], value });
}
return extraFields;
}
private async readDetailLayerTitleSupplier(root: Locator): Promise<string> {
const titleLoc = root
.locator(
'.put-page-title, .ant-modal-title, .ant-drawer-title, .ant-page-header-heading-title, .put-page-header-title',
)
.first();
const raw = normalizeSampleCellValue(((await titleLoc.innerText().catch(() => '')) ?? '').trim());
if (!raw) return '';
const compact = raw.replace(/\s/g, '');
if (DETAIL_LAYER_TITLE_NOT_SUPPLIER.test(compact)) return '';
return raw;
}
async readSupplierCustomInDetail(profile?: VendorQualTabProfile): Promise<string> {
const layer = this.detailLayerRoot().first(); const layer = this.detailLayerRoot().first();
const layerVisible = await layer.isVisible({ timeout: 15_000 }).catch(() => false); const layerVisible = await layer.isVisible({ timeout: 15_000 }).catch(() => false);
const roots = layerVisible ? [layer] : [this.page.locator('.ant-drawer-open').first(), this.contentScope()]; const roots = layerVisible ? [layer] : [this.page.locator('.ant-drawer-open').first(), this.contentScope()];
for (const root of roots) { for (const root of roots) {
if (!(await root.isVisible().catch(() => false))) continue; if (!(await root.isVisible().catch(() => false))) continue;
if (profile?.detailSupplierInLayerTitle) {
const fromTitle = await this.readDetailLayerTitleSupplier(root);
if (fromTitle) return fromTitle;
}
for (const label of SUPPLIER_DETAIL_LABELS) { for (const label of SUPPLIER_DETAIL_LABELS) {
const value = await this.readLabeledValueInRoot(root, label); const value = await this.readLabeledValueInRoot(root, label);
if (value) { if (value) {
...@@ -469,6 +619,44 @@ export class VendorQualPage extends VendorSharedPage { ...@@ -469,6 +619,44 @@ export class VendorQualPage extends VendorSharedPage {
return ''; return '';
} }
async readRegistrantInDetail(root?: Locator): Promise<string> {
const layer = root ?? this.detailLayerRoot().first();
for (const label of REGISTRANT_DETAIL_LABELS) {
const value = await this.readLabeledValueInRoot(layer, label);
if (value) return value;
}
const blockText = ((await layer.innerText().catch(() => '')) ?? '').trim();
for (const label of REGISTRANT_DETAIL_LABELS) {
const fromLines = readLabelValueFromMultilineText(blockText, label, REGISTRANT_DETAIL_LABELS);
if (fromLines) return normalizeSampleCellValue(fromLines);
}
return '';
}
/** 选首行 → 查看 → 列表器械注册人与详情注册人名称。 */
async viewFirstRowAndReadRegistrant(
profile: VendorQualTabProfile,
): Promise<{ listRegistrant: string; detailRegistrant: string }> {
await this.openOnMainTab(profile.tab);
await this.query();
const row = await this.selectFirstRow();
const listRegistrant = await this.sampleFromRowColumns(row, [...REGISTRANT_COLUMN_HEADERS], '器械注册人');
await this.openDetailLayerFromRow(row, ['详情', '查看']);
if (await this.isStillOnQualList()) {
await this.skipWhenDataInsufficient('查看未打开详情,无法核对注册人名称');
}
const layer = await this.waitDetailSurface();
let detailRegistrant = await this.readRegistrantInDetail(layer);
if (!detailRegistrant) {
const snippet = listRegistrant.slice(0, Math.min(listRegistrant.length, 8));
await expect(layer, '详情应展示列表器械注册人').toContainText(snippet);
detailRegistrant = listRegistrant;
}
await this.leaveWithoutCommit();
await this.expectOnPage();
return { listRegistrant, detailRegistrant };
}
/** /**
* 证件效期提醒:选首行 → 查看 → 读取详情销售商(自定义);关闭详情后返回列表值供断言。 * 证件效期提醒:选首行 → 查看 → 读取详情销售商(自定义);关闭详情后返回列表值供断言。
*/ */
...@@ -480,11 +668,26 @@ export class VendorQualPage extends VendorSharedPage { ...@@ -480,11 +668,26 @@ export class VendorQualPage extends VendorSharedPage {
const row = await this.selectFirstRow(); const row = await this.selectFirstRow();
const listSupplier = await this.sampleSupplierFromRow(row); const listSupplier = await this.sampleSupplierFromRow(row);
await this.openDetailLayerFromRow(row, ['详情', '查看']); await this.openDetailLayerFromRow(row, ['详情', '查看']);
let detailSupplier = await this.readSupplierCustomInDetail(); if (await this.isStillOnQualList()) {
await this.skipWhenDataInsufficient('查看未打开详情,无法核对销售商(自定义)');
}
let detailSupplier = await this.readSupplierCustomInDetail(profile);
if (!detailSupplier) { if (!detailSupplier) {
const snippet = listSupplier.slice(0, Math.min(listSupplier.length, 8)); const snippet = listSupplier.slice(0, Math.min(listSupplier.length, 8));
await expect(this.detailLayerRoot().first(), '详情应展示列表销售商(自定义)').toContainText(snippet); const layer = await this.waitDetailSurface();
detailSupplier = listSupplier; if (profile.detailSupplierInLayerTitle) {
const title = await this.readDetailLayerTitleSupplier(layer);
expect(title.length, '详情左上角标题应展示销售商(自定义)').toBeGreaterThan(0);
expect(
title.replace(/\s/g, '').includes(snippet.replace(/\s/g, '')) ||
snippet.replace(/\s/g, '').includes(title.replace(/\s/g, '').slice(0, 8)),
'详情标题应与列表首行销售商(自定义)一致或可互相包含',
).toBeTruthy();
detailSupplier = title;
} else {
await expect(layer, '详情应展示列表销售商(自定义)').toContainText(snippet);
detailSupplier = listSupplier;
}
} }
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
await this.expectOnPage(); await this.expectOnPage();
......
...@@ -9,9 +9,15 @@ import { ...@@ -9,9 +9,15 @@ import {
normalizeSampleCellValue, normalizeSampleCellValue,
readRowCellByColumn, readRowCellByColumn,
resolveColumnIndexFromLabels, resolveColumnIndexFromLabels,
tableCellTextMatchesKeyword,
} from '../../core/table-data-column.util'; } from '../../core/table-data-column.util';
import { VendorSharedLocators } from '../../locators/spd/vendor-shared.locator'; import { VendorSharedLocators } from '../../locators/spd/vendor-shared.locator';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import {
clickPageSizeDropdownOption,
pageSizeLabelsEqual,
parsePageSizeFromLabel,
} from './helpers/pagination-pagesize.helper';
import { skipIfInsufficientForPageSizeChange } from './helpers/pagination-pagesize.helper'; import { skipIfInsufficientForPageSizeChange } from './helpers/pagination-pagesize.helper';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper'; import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
...@@ -490,7 +496,7 @@ export class VendorSharedPage extends BasePage { ...@@ -490,7 +496,7 @@ export class VendorSharedPage extends BasePage {
const limit = Math.min(count, sample); const limit = Math.min(count, sample);
for (let i = 0; i < limit; i += 1) { for (let i = 0; i < limit; i += 1) {
const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, ''); const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, '');
if (!text || !text.includes(needle)) return false; if (!text || !tableCellTextMatchesKeyword(text, needle)) return false;
} }
return true; return true;
} }
...@@ -537,18 +543,34 @@ export class VendorSharedPage extends BasePage { ...@@ -537,18 +543,34 @@ export class VendorSharedPage extends BasePage {
await this.openDetailLayer(row, actions[0]); await this.openDetailLayer(row, actions[0]);
} }
async expectDetailOpened(): Promise<void> {
const layer = this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open'));
if (await layer.first().isVisible().catch(() => false)) return;
const title = this.page.getByText(/变更提报|变更审核/).first();
if (await title.isVisible().catch(() => false)) return;
const back = this.page.getByRole('button', { name: /返\s*回/ }).first();
await expect(back, '应进入详情页或打开详情层').toBeVisible({ timeout: 8_000 });
}
async expectDetailContainsText(text: string): Promise<void> { async expectDetailContainsText(text: string): Promise<void> {
const snippet = text.trim().slice(0, Math.min(text.length, 12)); const snippet = text.trim().slice(0, Math.min(text.length, 12));
if (!snippet) return; if (!snippet) {
await this.expectDetailOpened();
return;
}
const compact = snippet.replace(/\s+/g, '');
await expect await expect
.poll(async () => { .poll(async () => {
const layer = this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open')); const layer = this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open'));
const layerText = ((await layer.first().innerText().catch(() => '')) ?? '').replace(/\s+/g, ''); const layerText = ((await layer.first().innerText().catch(() => '')) ?? '').replace(/\s+/g, '');
if (layerText.includes(snippet.replace(/\s+/g, ''))) return true; if (tableCellTextMatchesKeyword(layerText, compact)) return true;
const body = ((await this.page.locator('body').innerText().catch(() => '')) ?? '').replace(/\s+/g, '');
if (tableCellTextMatchesKeyword(body, compact)) return true;
for (const frame of this.page.frames()) { for (const frame of this.page.frames()) {
if ((await frame.getByText(snippet, { exact: false }).count()) > 0) return true; const frameText = ((await frame.locator('body').innerText().catch(() => '')) ?? '').replace(/\s+/g, '');
if (tableCellTextMatchesKeyword(frameText, compact)) return true;
} }
return (await this.page.getByText(snippet, { exact: false }).count()) > 0; return false;
}) })
.toBeTruthy(); .toBeTruthy();
} }
...@@ -577,11 +599,17 @@ export class VendorSharedPage extends BasePage { ...@@ -577,11 +599,17 @@ export class VendorSharedPage extends BasePage {
async leaveWithoutCommit(): Promise<void> { async leaveWithoutCommit(): Promise<void> {
await this.closeOverlay(); await this.closeOverlay();
// 若关闭后仍在详情层,再试返回 const back = this.page.getByRole('button', { name: /返\s*回/ }).first();
const queryVisible = await this.button('查询').first().isVisible().catch(() => false);
if ((await back.isVisible().catch(() => false)) && !queryVisible) {
await this.click(back, '返回');
await this.waitSettle(300);
return;
}
const put = this.locators.putPage(); const put = this.locators.putPage();
if (await put.isVisible().catch(() => false)) { if (await put.isVisible().catch(() => false)) {
const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first(); const putBack = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first();
if (await back.isVisible().catch(() => false)) await this.click(back, '返回'); if (await putBack.isVisible().catch(() => false)) await this.click(putBack, '返回');
else await this.page.keyboard.press('Escape'); else await this.page.keyboard.press('Escape');
await this.waitSettle(300); await this.waitSettle(300);
} }
...@@ -611,22 +639,21 @@ export class VendorSharedPage extends BasePage { ...@@ -611,22 +639,21 @@ export class VendorSharedPage extends BasePage {
if (!(await changer.isVisible().catch(() => false))) { if (!(await changer.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('当前页面无每页条数切换'); await this.skipWhenDataInsufficient('当前页面无每页条数切换');
} }
const sizeNum = (toLabel.match(/\d+/) ?? ['10'])[0]; const targetSize = Number((toLabel.match(/\d+/) ?? ['10'])[0]);
const current = await this.pageSizeChangerText(); const current = await this.pageSizeChangerText();
if (current.includes(sizeNum) && current.includes('条')) { if (pageSizeLabelsEqual(current, targetSize)) {
return; return;
} }
await this.assertBeforeBusinessAction('切换每页条数'); await this.assertBeforeBusinessAction('切换每页条数');
await this.click(changer, '打开每页条数'); await this.click(changer, '打开每页条数');
const option = this.page await clickPageSizeDropdownOption(this.page, targetSize, (loc, step) => this.click(loc, step));
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option') await this.page.keyboard.press('Escape').catch(() => undefined);
.filter({ hasText: new RegExp(`${sizeNum}\\s*条`) })
.first();
await this.click(option, `选择 ${toLabel}`);
await this.waitForTableReady(); await this.waitForTableReady();
const after = await this.pageSizeChangerText(); const after = await this.pageSizeChangerText();
if (!after.includes(sizeNum)) { if (!pageSizeLabelsEqual(after, targetSize)) {
throw new Error(`切换每页条数未生效:期望含 ${sizeNum},当前「${after || '(空)'}」`); throw new Error(
`切换每页条数未生效:期望 ${targetSize} 条/页,当前「${after || '(空)'}」(解析为 ${parsePageSizeFromLabel(after) ?? '未知'})`,
);
} }
} }
......
...@@ -2,11 +2,13 @@ import { expect, type Locator, type Page } from '@playwright/test'; ...@@ -2,11 +2,13 @@ import { expect, type Locator, type Page } from '@playwright/test';
import { warehouseData } from '../../data/warehouse.data'; import { warehouseData } from '../../data/warehouse.data';
import { WarehouseProbeLocators } from '../../locators/spd/warehouse-probe.locator'; import { WarehouseProbeLocators } from '../../locators/spd/warehouse-probe.locator';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
import { WhFlowSharedPage } from './wh-flow-shared.page'; import { WhFlowSharedPage } from './wh-flow-shared.page';
/** 探针绑定:动作与取数;断言在 tests/spd/warehouse-probe.spec.ts。 */ /** 探针绑定:动作与取数;断言在 tests/spd/warehouse-probe.spec.ts。 */
export class WarehouseProbePage extends WhFlowSharedPage { export class WarehouseProbePage extends WhFlowSharedPage {
override readonly locators: WarehouseProbeLocators; override readonly locators: WarehouseProbeLocators;
private readonly proTableList: SpdProTableListControls;
constructor(page: Page) { constructor(page: Page) {
super(page, { super(page, {
...@@ -17,6 +19,18 @@ export class WarehouseProbePage extends WhFlowSharedPage { ...@@ -17,6 +19,18 @@ export class WarehouseProbePage extends WhFlowSharedPage {
forbidden: ['storehouse/area'], forbidden: ['storehouse/area'],
}); });
this.locators = new WarehouseProbeLocators(page); this.locators = new WarehouseProbeLocators(page);
this.proTableList = new SpdProTableListControls({
listScope: () => this.page,
columnSettingsToggleCandidates: warehouseData.probeColumnSettingsToggleCandidates,
sortColumns: [],
clickQuery: () => this.query(),
skipWhen: (reason) => this.skipWhenDataInsufficient(reason),
click: (loc, label) => this.click(loc, label),
});
}
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await this.proTableList.executeColumnSettingsToggleRestoreTest();
} }
override async waitContentReady(): Promise<void> { override async waitContentReady(): Promise<void> {
......
...@@ -2,11 +2,13 @@ import { expect, type Locator, type Page } from '@playwright/test'; ...@@ -2,11 +2,13 @@ import { expect, type Locator, type Page } from '@playwright/test';
import { warehouseData } from '../../data/warehouse.data'; import { warehouseData } from '../../data/warehouse.data';
import { WarehouseZoneLocators } from '../../locators/spd/warehouse-zone.locator'; import { WarehouseZoneLocators } from '../../locators/spd/warehouse-zone.locator';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
import { WhFlowSharedPage } from './wh-flow-shared.page'; import { WhFlowSharedPage } from './wh-flow-shared.page';
/** 库区设置:动作与取数;断言在 tests/spd/warehouse-zone.spec.ts。 */ /** 库区设置:动作与取数;断言 in tests/spd/warehouse-zone.spec.ts。 */
export class WarehouseZonePage extends WhFlowSharedPage { export class WarehouseZonePage extends WhFlowSharedPage {
override readonly locators: WarehouseZoneLocators; override readonly locators: WarehouseZoneLocators;
private readonly proTableList: SpdProTableListControls;
constructor(page: Page) { constructor(page: Page) {
super(page, { super(page, {
...@@ -17,6 +19,18 @@ export class WarehouseZonePage extends WhFlowSharedPage { ...@@ -17,6 +19,18 @@ export class WarehouseZonePage extends WhFlowSharedPage {
forbidden: ['storehouse/probe'], forbidden: ['storehouse/probe'],
}); });
this.locators = new WarehouseZoneLocators(page); this.locators = new WarehouseZoneLocators(page);
this.proTableList = new SpdProTableListControls({
listScope: () => this.page,
columnSettingsToggleCandidates: warehouseData.zoneColumnSettingsToggleCandidates,
sortColumns: [],
clickQuery: () => this.query(),
skipWhen: (reason) => this.skipWhenDataInsufficient(reason),
click: (loc, label) => this.click(loc, label),
});
}
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await this.proTableList.executeColumnSettingsToggleRestoreTest();
} }
override async waitContentReady(): Promise<void> { override async waitContentReady(): Promise<void> {
......
...@@ -4,6 +4,11 @@ import { env } from '../../config/env.config'; ...@@ -4,6 +4,11 @@ import { env } from '../../config/env.config';
import { BasePage } from '../../core/base.page'; import { BasePage } from '../../core/base.page';
import { WhFlowSharedLocators } from '../../locators/spd/wh-flow-shared.locator'; import { WhFlowSharedLocators } from '../../locators/spd/wh-flow-shared.locator';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import {
clickPageSizeDropdownOption,
pageSizeLabelsEqual,
parsePageSizeFromLabel,
} from './helpers/pagination-pagesize.helper';
import { skipIfInsufficientForPageSizeChange } from './helpers/pagination-pagesize.helper'; import { skipIfInsufficientForPageSizeChange } from './helpers/pagination-pagesize.helper';
export type WhFlowMeta = { export type WhFlowMeta = {
...@@ -370,22 +375,20 @@ export class WhFlowSharedPage extends BasePage { ...@@ -370,22 +375,20 @@ export class WhFlowSharedPage extends BasePage {
if (!(await changer.isVisible().catch(() => false))) { if (!(await changer.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('当前页面无每页条数切换'); await this.skipWhenDataInsufficient('当前页面无每页条数切换');
} }
const sizeNum = (toLabel.match(/\d+/) ?? ['20'])[0]; const targetSize = Number((toLabel.match(/\d+/) ?? ['20'])[0]);
const current = await this.pageSizeChangerText(); const current = await this.pageSizeChangerText();
if (current.includes(sizeNum) && current.includes('条')) { if (pageSizeLabelsEqual(current, targetSize)) {
return; return;
} }
await this.assertBeforeBusinessAction('切换每页条数'); await this.assertBeforeBusinessAction('切换每页条数');
await this.click(changer, '打开每页条数'); await this.click(changer, '打开每页条数');
const option = this.page await clickPageSizeDropdownOption(this.page, targetSize, (loc, step) => this.click(loc, step));
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: new RegExp(`${sizeNum}\\s*条`) })
.first();
await this.click(option, `选择 ${toLabel}`);
await this.waitForTableReady(); await this.waitForTableReady();
const after = await this.pageSizeChangerText(); const after = await this.pageSizeChangerText();
if (!after.includes(sizeNum)) { if (!pageSizeLabelsEqual(after, targetSize)) {
throw new Error(`切换每页条数未生效:期望含 ${sizeNum},当前「${after || '(空)'}」`); throw new Error(
`切换每页条数未生效:期望 ${targetSize} 条/页,当前「${after || '(空)'}」(解析为 ${parsePageSizeFromLabel(after) ?? '未知'})`,
);
} }
} }
...@@ -411,6 +414,12 @@ export class WhFlowSharedPage extends BasePage { ...@@ -411,6 +414,12 @@ export class WhFlowSharedPage extends BasePage {
return { total, rowsBefore, rowsAfter, pageSizeLabel }; return { total, rowsBefore, rowsAfter, pageSizeLabel };
} }
/** ui-test-point-rules §3.7:默认档位 → 20 条/页 */
async switchToPageSizeTwenty(): Promise<{ total: number; rowsBefore: number; rowsAfter: number }> {
const r = await this.switchToPageSize('20 条/页');
return { total: r.total, rowsBefore: r.rowsBefore, rowsAfter: r.rowsAfter };
}
async visibleSelectOptions(label: string): Promise<string[]> { async visibleSelectOptions(label: string): Promise<string[]> {
const select = this.formSelect(label); const select = this.formSelect(label);
if (!(await select.isVisible().catch(() => false))) return []; if (!(await select.isVisible().catch(() => false))) return [];
......
#!/usr/bin/env python3
"""将 spec 中用例编号按 data/basic-modules-id-remap.json 从旧 ID 替换为新 ID(长 ID 优先)。"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
REMAP = json.loads((ROOT / "data/basic-modules-id-remap.json").read_text(encoding="utf-8"))
CSV_TO_SPEC = {
"基础管理-库房管理-库区设置-UI自动化测试用例-01.csv": "tests/spd/warehouse-zone.spec.ts",
"基础管理-库房管理-探针绑定-UI自动化测试用例-01.csv": "tests/spd/warehouse-probe.spec.ts",
"基础管理-流程设置-审批流程设置-UI自动化测试用例-01.csv": "tests/spd/flow-def.spec.ts",
"基础管理-流程设置-审批流实例-UI自动化测试用例-01.csv": "tests/spd/flow-inst.spec.ts",
"基础管理-手术套包设置-基础包维护-UI自动化测试用例-01.csv": "tests/spd/surgery-base-pkg.spec.ts",
"基础管理-手术套包设置-术种维护-UI自动化测试用例-01.csv": "tests/spd/surgery-oper-type.spec.ts",
"基础管理-手术套包设置-标准套包维护-UI自动化测试用例-01.csv": "tests/spd/surgery-std-pkg.spec.ts",
"基础管理-医院资料-公告管理-UI自动化测试用例-01.csv": "tests/spd/basic-notice.spec.ts",
"基础管理-医院资料-院区管理-UI自动化测试用例-01.csv": "tests/spd/basic-branch.spec.ts",
"基础管理-医院资料-财务月份设置-UI自动化测试用例-01.csv": "tests/spd/basic-finance-month.spec.ts",
"基础管理-医院资料-医院信息登记-UI自动化测试用例-01.csv": "tests/spd/basic-hospital-info.spec.ts",
"基础管理-用户管理-用户管理-UI自动化测试用例-01.csv": "tests/spd/basic-user.spec.ts",
"基础管理-用户管理-医共体用户管理-UI自动化测试用例-01.csv": "tests/spd/basic-user-union.spec.ts",
"基础管理-用户管理-角色管理-UI自动化测试用例-01.csv": "tests/spd/basic-role.spec.ts",
"基础管理-准入管理-物料准入管理-UI自动化测试用例-01.csv": "tests/spd/admit-material.spec.ts",
"基础管理-准入管理-产品准入申请-UI自动化测试用例-01.csv": "tests/spd/admit-product-apply.spec.ts",
"基础管理-准入管理-准入任务分配-UI自动化测试用例-01.csv": "tests/spd/admit-task.spec.ts",
"基础管理-准入管理-供货目录申请-UI自动化测试用例-01.csv": "tests/spd/admit-supply.spec.ts",
"基础管理-准入管理-物料准入单列表-UI自动化测试用例-01.csv": "tests/spd/admit-material-list.spec.ts",
"基础管理-准入管理-产品准入单列表-UI自动化测试用例-01.csv": "tests/spd/admit-product-list.spec.ts",
}
def apply(text: str, pairs: list[list[str]]) -> str:
todo = [(o, n) for o, n in pairs if o and o != n]
for old, new in sorted(todo, key=lambda x: len(x[0]), reverse=True):
text = re.sub(rf"test\('{re.escape(old)}\s", f"test('{new} ", text)
return text
def main() -> None:
targets = sys.argv[1:] or list(CSV_TO_SPEC.values())
for csv_name, spec_rel in CSV_TO_SPEC.items():
if targets != list(CSV_TO_SPEC.values()) and spec_rel not in targets:
continue
pairs = REMAP.get(csv_name, [])
if not pairs:
continue
path = ROOT / spec_rel
if not path.exists():
continue
out = apply(path.read_text(encoding="utf-8"), pairs)
path.write_text(out, encoding="utf-8")
print(f"remap {spec_rel}")
if __name__ == "__main__":
main()
#!/usr/bin/env node
/** 科室管理 CSV 用例编号+标题+功能测试点+功能模块 必须出现在对应 spec 中。 */
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PAIRS = [
['testcase/基础管理-科室管理-科室登记-UI自动化测试用例-01.csv', 'tests/spd/deptm-register.spec.ts'],
['testcase/基础管理-科室管理-科室分类维护-UI自动化测试用例-01.csv', 'tests/spd/deptm-category.spec.ts'],
['testcase/基础管理-科室管理-科室产品分配-UI自动化测试用例-01.csv', 'tests/spd/deptm-product-alloc.spec.ts'],
['testcase/基础管理-科室管理-科室库区产品设置-UI自动化测试用例-01.csv', 'tests/spd/deptm-zone-product.spec.ts'],
['testcase/基础管理-科室管理-科室跟台限额任务-UI自动化测试用例-01.csv', 'tests/spd/deptm-follow-limit.spec.ts'],
['testcase/基础管理-科室管理-请领模板维护-UI自动化测试用例-01.csv', 'tests/spd/deptm-req-template.spec.ts'],
['testcase/基础管理-科室管理-自动请领排程-UI自动化测试用例-01.csv', 'tests/spd/deptm-auto-req.spec.ts'],
['testcase/基础管理-科室管理-科室人员查询-UI自动化测试用例-01.csv', 'tests/spd/deptm-staff.spec.ts'],
['testcase/基础管理-科室管理-科室上下限建议-UI自动化测试用例-01.csv', 'tests/spd/deptm-minmax.spec.ts'],
['testcase/基础管理-科室管理-科室预算管理-UI自动化测试用例-01.csv', 'tests/spd/deptm-budget.spec.ts'],
['testcase/基础管理-科室管理-检验机器管理-UI自动化测试用例-01.csv', 'tests/spd/deptm-lab-machine.spec.ts'],
['testcase/基础管理-科室管理-科室送货计划-UI自动化测试用例-01.csv', 'tests/spd/deptm-delivery.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
/**
* 登录态下采集各页「带 sorter 的表头列名」,与 data/spd-admin-sort-columns.json 对照。
*
* Usage:
* node scripts/audit-sort-columns-live.mjs
* node scripts/audit-sort-columns-live.mjs deptm-req-template iface-vendor-code
*/
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const SORT_JSON = path.join(ROOT, 'data/spd-admin-sort-columns.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
/** pageKey → { path, prep?: (page)=>Promise<void> } */
const PAGES = {
'deptm-category': {
path: '/spd/basic/dept/platform/dept/calatog',
async prep(page) {
await selectLeftTreeFirst(page);
},
},
'deptm-minmax': {
path: '/spd/basic/dept/platform/dept/deptLimitOld',
async prep(page) {
await selectFormOptionIfVisible(page, '院区');
await selectFormOptionIfVisible(page, '科室');
await selectFormOptionIfVisible(page, '库区');
await clickQuery(page);
},
},
'deptm-req-template': {
path: '/spd/basic/dept/platform/dept/apply',
async prep(page) {
await selectLeftListRow(page, 1);
await clickQuery(page);
},
},
'iface-dept-req-ok': {
path: '/spd/basic/interface/replace-dept-receive',
async prep(page) {
await clickQuery(page);
},
},
'iface-low-billing': {
path: '/spd/basic/interface/interface/lowBillingSearch',
async prep(page) {
await clickQuery(page);
},
},
'iface-vendor-code': {
path: '/spd/basic/interface/interface/companyCode',
async prep(page) {
await clickQuery(page);
},
},
'admit-task': {
path: '/spd/basic/dataSync/dataSync/accessKind/list',
async prep(page) {
await clickQuery(page);
},
},
'admit-material': {
path: '/spd/basic/dataSync/dataSync/productApply/ZRlist',
async prep(page) {
await clickQuery(page);
},
},
'admit-product-apply': {
path: '/spd/basic/dataSync/dataSync/productApply/list',
async prep(page) {
await clickQuery(page);
},
},
};
async function selectLeftTreeFirst(page) {
const tree = page.locator('.ant-tree').first();
await tree.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
const title = tree.locator('.ant-tree-treenode .ant-tree-title').first();
if (await title.isVisible().catch(() => false)) {
await title.click();
await page.waitForTimeout(800);
}
}
async function selectLeftListRow(page, index) {
const rows = page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
const count = await rows.count();
if (count > index) {
await rows.nth(index).click();
await page.waitForTimeout(800);
}
}
async function selectFormOptionIfVisible(page, label) {
const item = page.locator('.ant-form-item').filter({ hasText: label }).first();
if (!(await item.isVisible().catch(() => false))) return;
const select = item.locator('.ant-select').first();
if (!(await select.isVisible().catch(() => false))) return;
await select.click();
const opt = page.locator('.ant-select-item-option').first();
if (await opt.isVisible().catch(() => false)) {
await opt.click();
await page.waitForTimeout(400);
}
}
async function clickQuery(page) {
const btn = page.getByRole('button', { name: /^查\s*询$/ }).first();
if (await btn.isVisible().catch(() => false)) {
await btn.click();
await page.waitForTimeout(1200);
}
}
async function scrollTableBody(page) {
const body = page.locator('.ant-table-body').first();
if (!(await body.isVisible().catch(() => false))) return;
const max = await body.evaluate((el) => el.scrollWidth);
for (let x = 0; x <= max; x += 400) {
await body.evaluate((el, left) => {
el.scrollLeft = left;
}, x);
await page.waitForTimeout(150);
}
await body.evaluate((el) => {
el.scrollLeft = 0;
});
}
async function extractSortableColumnLabels(page) {
await scrollTableBody(page);
return page.evaluate(() => {
const skip = new Set(['操作', '序号', '#', '']);
const seen = [];
const add = (name) => {
const n = name.replace(/\s+/g, ' ').trim();
if (!n || skip.has(n) || n.startsWith('操作')) return;
if (!seen.includes(n)) seen.push(n);
};
for (const th of document.querySelectorAll('.ant-table-thead th')) {
if (!th.querySelector('.ant-table-column-sorter')) continue;
const title = th.querySelector('.ant-table-column-title');
if (title?.textContent?.trim()) {
add(title.textContent);
continue;
}
const clone = th.cloneNode(true);
for (const s of clone.querySelectorAll('.ant-table-column-sorter, .anticon')) {
s.remove();
}
add(clone.textContent || '');
}
return seen;
});
}
/** 多表页取「sort 列最多」的主表(请领模板:右侧明细表) */
async function extractSortableFromMainTable(page) {
await scrollTableBody(page);
return page.evaluate(() => {
const skip = new Set(['操作', '序号', '#', '']);
const addFromWrapper = (w) => {
const seen = [];
const add = (name) => {
const n = name.replace(/\s+/g, ' ').trim();
if (!n || skip.has(n) || n.startsWith('操作')) return;
if (!seen.includes(n)) seen.push(n);
};
for (const th of w.querySelectorAll('.ant-table-thead th')) {
if (!th.querySelector('.ant-table-column-sorter')) continue;
const title = th.querySelector('.ant-table-column-title');
if (title?.textContent?.trim()) {
add(title.textContent);
continue;
}
const clone = th.cloneNode(true);
for (const s of clone.querySelectorAll('.ant-table-column-sorter, .anticon')) {
s.remove();
}
add(clone.textContent || '');
}
return seen;
};
const wrappers = [...document.querySelectorAll('.ant-table-wrapper')];
let best = [];
for (const w of wrappers) {
const cols = addFromWrapper(w);
if (cols.length > best.length) best = cols;
}
return best;
});
}
function diff(expected, live) {
const e = new Set(expected);
const l = new Set(live);
return {
onlyExpected: [...e].filter((x) => !l.has(x)),
onlyLive: [...l].filter((x) => !e.has(x)),
match: expected.length === live.length && [...e].every((x) => l.has(x)),
};
}
async function main() {
const configured = JSON.parse(readFileSync(SORT_JSON, 'utf8'));
const keys = process.argv.slice(2).length
? process.argv.slice(2)
: Object.keys(PAGES);
if (!existsSync(AUTH_STATE)) {
console.error('缺少 auth/user.json,请先 npx playwright test --project=setup');
process.exit(1);
}
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ storageState: AUTH_STATE, baseURL: BASE });
const page = await context.newPage();
const report = {};
let exitCode = 0;
for (const pageKey of keys) {
const meta = PAGES[pageKey];
if (!meta) {
console.warn(`skip unknown pageKey: ${pageKey}`);
continue;
}
const expected = configured[pageKey] ?? [];
process.stdout.write(`\n=== ${pageKey} ===\n`);
try {
await page.goto(meta.path, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.locator('.ant-table, .ant-tree, .ant-form').first().waitFor({ timeout: 30_000 });
if (meta.prep) await meta.prep(page);
await page.locator('.ant-table-thead').first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
const live =
pageKey === 'deptm-req-template'
? await extractSortableFromMainTable(page)
: await extractSortableColumnLabels(page);
const d = diff(expected, live);
report[pageKey] = { expected, live, ...d };
console.log(` live (${live.length}): ${JSON.stringify(live)}`);
console.log(` json (${expected.length}): ${JSON.stringify(expected)}`);
if (d.match) {
console.log(' OK 与线上一致');
} else {
exitCode = 1;
if (d.onlyExpected.length) console.log(` JSON 多/错: ${JSON.stringify(d.onlyExpected)}`);
if (d.onlyLive.length) console.log(` 线上有、JSON 缺: ${JSON.stringify(d.onlyLive)}`);
}
} catch (err) {
exitCode = 1;
console.error(` FAIL: ${err.message}`);
report[pageKey] = { error: String(err) };
}
}
await browser.close();
console.log('\n--- summary ---');
console.log(JSON.stringify(report, null, 2));
process.exit(exitCode);
}
main();
...@@ -214,11 +214,11 @@ def main() -> int: ...@@ -214,11 +214,11 @@ def main() -> int:
delete = { delete = {
x.strip() x.strip()
for x in """ for x in """
ADMIT-MLS-012 ADMIT-PAP-011 ADMIT-PAP-012 ADMIT-PLS-012 ADMIT-SUP-004 ADMIT-SUP-009 ADMIT-SUP-010 ADMIT-SUP-017 ADMIT-TSK-011 ADMIT-MLS-011 ADMIT-PAP-011 ADMIT-PAP-012 ADMIT-PLS-012 ADMIT-SUP-004 ADMIT-SUP-008 ADMIT-SUP-009 ADMIT-SUP-017 ADMIT-TSK-010
BASIC-BRN-004 BASIC-BRN-009 BASIC-BRN-013 BASIC-FMN-010 BASIC-FMN-012 BASIC-FMN-013 BASIC-FMN-014 BASIC-NTC-013 BASIC-NTC-014 BASIC-NTC-015 BASIC-BRN-004 BASIC-BRN-009 BASIC-BRN-013 BASIC-FMN-010 BASIC-FMN-012 BASIC-FMN-013 BASIC-FMN-014 BASIC-NTC-013 BASIC-NTC-014 BASIC-NTC-015
FLOW-DEF-004 FLOW-DEF-006 FLOW-DEF-007 FLOW-DEF-013 FLOW-INS-001 FLOW-INS-005 FLOW-INS-009 FLOW-DEF-004 FLOW-DEF-006 FLOW-DEF-007 FLOW-DEF-013 FLOW-INS-001 FLOW-INS-005 FLOW-INS-009
SURG-BASE-001 SURG-BASE-009 SURG-BASE-016 SURG-BASE-019 SURG-BASE-022 SURG-STD-004 SURG-STD-005 SURG-STD-005 SURG-STD-009 SURG-STD-010 SURG-STD-020 SURG-TYPE-011 SURG-BASE-001 SURG-BASE-009 SURG-BASE-016 SURG-BASE-019 SURG-BASE-022 SURG-STD-003 SURG-STD-004 SURG-STD-004 SURG-STD-008 SURG-STD-009 SURG-STD-020 SURG-TYPE-010
USER-ROL-011 USER-UNI-002 USER-UNI-004 USER-UNI-011 USER-UNI-015 USER-UNI-016 USER-USR-001 USER-USR-003 USER-USR-017 USER-USR-018 USER-ROL-010 USER-UNI-002 USER-UNI-004 USER-UNI-011 USER-UNI-015 USER-UNI-016 USER-USR-001 USER-USR-003 USER-USR-017 USER-USR-018
VENDOR-AF1-007 VENDOR-AF2-007 VENDOR-CF1-007 VENDOR-CF2-007 VENDOR-EVI-008 VENDOR-AF1-007 VENDOR-AF2-007 VENDOR-CF1-007 VENDOR-CF2-007 VENDOR-EVI-008
VENDOR-EVS-002 VENDOR-EVS-003 VENDOR-EVS-004 VENDOR-EVS-005 VENDOR-EVS-006 VENDOR-EVS-007 VENDOR-EVS-008 VENDOR-EVS-009 VENDOR-EVS-010 VENDOR-EVS-002 VENDOR-EVS-003 VENDOR-EVS-004 VENDOR-EVS-005 VENDOR-EVS-006 VENDOR-EVS-007 VENDOR-EVS-008 VENDOR-EVS-009 VENDOR-EVS-010
WH-PRB-006 WH-ZON-003 WH-ZON-005 WH-ZON-019 WH-ZON-020 WH-PRB-006 WH-ZON-003 WH-ZON-005 WH-ZON-019 WH-ZON-020
...@@ -267,14 +267,19 @@ WH-PRB-006 WH-ZON-003 WH-ZON-005 WH-ZON-019 WH-ZON-020 ...@@ -267,14 +267,19 @@ WH-PRB-006 WH-ZON-003 WH-ZON-005 WH-ZON-019 WH-ZON-020
md.write_text(text, encoding="utf-8") md.write_text(text, encoding="utf-8")
print(f"md {md.name}") print(f"md {md.name}")
# 跳过已在上文处理过的 CSV/spec/MD,避免二次 remap 错号
already = set(csv_touched)
already.update((ROOT / "tests" / "spd").glob("*.spec.ts"))
already.update(TESTCASES_MD.glob("*.md"))
for fp in files_to_scan(): for fp in files_to_scan():
if fp.suffix == ".csv": if fp.suffix == ".csv" or fp in already:
continue continue
text = fp.read_text(encoding="utf-8", errors="replace") text = fp.read_text(encoding="utf-8", errors="replace")
if not any(d in text for d in delete) and not any(o in text for o, n in global_remap.items() if o != n): if not any(d in text for d in delete) and not any(o in text for o, n in global_remap.items() if o != n):
continue continue
updated = remove_md_sections(text, delete) if fp.suffix == ".md" else text # 全库扫描只做编号替换,不再删 MD 段落(避免已重编号的新 005 被当旧号删掉)
updated = apply_remap(updated, global_remap) updated = apply_remap(text, global_remap)
if updated != text: if updated != text:
fp.write_text(updated, encoding="utf-8") fp.write_text(updated, encoding="utf-8")
......
#!/usr/bin/env node
/**
* 从 Playwright/Jenkins junit.xml 提取失败/错误用例编号(唯一)。
* 用法: node scripts/extract-failed-case-ids-from-junit.mjs path/to/junit.xml [out.txt]
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const CASE = /\b([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+-\d{3,})\b/;
const junitPath = process.argv[2];
const outPath = process.argv[3];
if (!junitPath) {
console.error('用法: node scripts/extract-failed-case-ids-from-junit.mjs <junit.xml> [out.txt]');
process.exit(1);
}
const xml = readFileSync(junitPath, 'utf8');
const failed = new Set();
const testcaseRe = /<testcase\b([^>]*)>([\s\S]*?)<\/testcase>/g;
for (const m of xml.matchAll(testcaseRe)) {
const attrs = m[1];
const body = m[2];
if (!/<(?:failure|error)\b/.test(body)) continue;
const name = (attrs.match(/\bname="([^"]*)"/) || [])[1] || '';
const cls = (attrs.match(/\bclassname="([^"]*)"/) || [])[1] || '';
const hit = CASE.exec(name) || CASE.exec(cls);
if (hit) failed.add(hit[1]);
}
const ids = [...failed].sort();
console.log(`[extract] ${path.basename(junitPath)}: ${ids.length} 个唯一失败编号`);
if (outPath) {
writeFileSync(outPath, `${ids.join('\n')}\n`, 'utf8');
console.log(`[extract] 已写入 ${outPath}`);
}
#!/usr/bin/env node
/**
* 从 Jenkins 任务 SPD 拉取指定构建的 reports/latest 产物到 reports/jenkins-SPD-<n>/
*
* 环境变量:JENKINS_URL(默认 http://10.17.65.19:8080)
* JENKINS_USER + JENKINS_TOKEN(或 JENKINS_API_TOKEN / JENKINS_PASSWORD)
*
* 用法: node scripts/fetch-jenkins-spd-artifacts.mjs 35
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadDotEnv() {
const envPath = path.join(ROOT, '.env');
if (!existsSync(envPath)) return;
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const i = t.indexOf('=');
if (i < 0) continue;
const k = t.slice(0, i).trim();
if (process.env[k]) continue;
process.env[k] = t.slice(i + 1).trim();
}
}
loadDotEnv();
const build = Number(process.argv[2]);
if (!Number.isFinite(build) || build < 1) {
console.error('用法: node scripts/fetch-jenkins-spd-artifacts.mjs <构建号>');
process.exit(1);
}
const base = (process.env.JENKINS_URL || 'http://10.17.65.19:8080').replace(/\/$/, '');
const user = process.env.JENKINS_USER || process.env.JENKINS_USERNAME || '';
const token =
process.env.JENKINS_TOKEN || process.env.JENKINS_API_TOKEN || process.env.JENKINS_PASSWORD || '';
if (!user || !token) {
console.error('[fetch] 请设置 JENKINS_USER 与 JENKINS_TOKEN(或 JENKINS_API_TOKEN)');
process.exit(1);
}
const auth = Buffer.from(`${user}:${token}`, 'utf8').toString('base64');
const outDir = path.join(ROOT, 'reports', `jenkins-SPD-${build}`);
mkdirSync(outDir, { recursive: true });
const files = [
'reports/latest/summary.json',
'reports/latest/meta.txt',
'reports/latest/junit.xml',
'reports/latest/email-summary.txt',
'reports/latest/flake/run.json',
];
async function get(rel) {
const url = `${base}/job/SPD/${build}/artifact/${rel}`;
const res = await fetch(url, { headers: { Authorization: `Basic ${auth}` } });
if (!res.ok) {
throw new Error(`${rel}: HTTP ${res.status}`);
}
return Buffer.from(await res.arrayBuffer());
}
console.log(`[fetch] SPD #${build} -> ${outDir}`);
for (const rel of files) {
const safe = rel.replace(/\//g, '_');
try {
const buf = await get(rel);
const dest = path.join(outDir, safe);
writeFileSync(dest, buf);
console.log(`[fetch] OK ${rel} (${buf.length} bytes)`);
} catch (e) {
console.warn(`[fetch] SKIP ${rel}: ${e.message}`);
}
}
console.log('[fetch] 完成');
#!/usr/bin/env python3
"""修正 refactor 追加行中错误的「基础设置 · 模块 · 分页/列表」功能测试点。"""
from __future__ import annotations
import csv
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TESTCASE = ROOT / "testcase"
WRONG_FP = re.compile(r"^基础设置 · [^·]+ · (分页|列表)$")
GLOB = [
"基础管理-库房管理-*-UI自动化测试用例-01.csv",
"基础管理-流程设置-*-UI自动化测试用例-01.csv",
"基础管理-手术套包设置-*-UI自动化测试用例-01.csv",
"基础管理-医院资料-*-UI自动化测试用例-01.csv",
"基础管理-用户管理-*-UI自动化测试用例-01.csv",
"基础管理-准入管理-*-UI自动化测试用例-01.csv",
]
def page_short_name(csv_path: Path) -> str:
# 基础管理-库房管理-库区设置-UI自动化测试用例-01.csv
parts = csv_path.name.split("-")
return parts[2] if len(parts) > 2 else parts[1]
def infer_fp_prefix(rows: list[dict]) -> str:
for row in rows:
fp = row.get("功能测试点", "")
if WRONG_FP.match(fp):
continue
if " · " in fp:
return fp.split(" · ", 1)[0]
return page_short_name(Path(rows[0]["_path"])) if rows else ""
def fix_file(path: Path) -> int:
with path.open(encoding="utf-8", newline="") as f:
rows = list(csv.DictReader(f))
if not rows:
return 0
for r in rows:
r["_path"] = str(path.name)
prefix = infer_fp_prefix(rows)
n = 0
for row in rows:
fp = row.get("功能测试点", "")
m = WRONG_FP.match(fp)
if not m:
continue
row["功能测试点"] = f"{prefix} · {m.group(1)}"
n += 1
if n:
fieldnames = [k for k in rows[0].keys() if k != "_path"]
with path.open("w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=fieldnames, lineterminator="\n")
w.writeheader()
for row in rows:
w.writerow({k: row[k] for k in fieldnames})
return n
def main() -> None:
total = 0
for pattern in GLOB:
for path in sorted(TESTCASE.glob(pattern)):
c = fix_file(path)
if c:
print(f"{path.name}: fixed {c} rows")
total += c
print(f"done, {total} rows fixed")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""从基础管理 UI 自动化 CSV 生成 testcases/*.md(与 CSV 同源,H2/H3 对齐功能测试点列)。
用法:
python3 scripts/generate-basic-modules-md-from-csv.py
python3 scripts/generate-basic-modules-md-from-csv.py --only 供货目录申请
python3 scripts/generate-basic-modules-md-from-csv.py --only 物料准入管理 产品准入申请
"""
from __future__ import annotations
import argparse
import csv
import re
import sys
from collections import Counter
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CSV_DIR = ROOT / "testcase"
MD_DIR = ROOT / "testcases"
TODAY = date.today().isoformat()
# csv 文件名 -> (spec 相对路径, 页面 path 片段)
META: dict[str, tuple[str, str]] = {
"基础管理-库房管理-库区设置-UI自动化测试用例-01.csv": ("tests/spd/warehouse-zone.spec.ts", "/spd/basic/storehouse/platform/storehouse/area"),
"基础管理-库房管理-探针绑定-UI自动化测试用例-01.csv": ("tests/spd/warehouse-probe.spec.ts", "/spd/basic/storehouse/storehouse/probe"),
"基础管理-流程设置-审批流程设置-UI自动化测试用例-01.csv": ("tests/spd/flow-def.spec.ts", "/spd/basic/flowSet/workflow/flowSet"),
"基础管理-流程设置-审批流实例-UI自动化测试用例-01.csv": ("tests/spd/flow-inst.spec.ts", "/spd/basic/flowSet/workflow/flowCase"),
"基础管理-手术套包设置-基础包维护-UI自动化测试用例-01.csv": ("tests/spd/surgery-base-pkg.spec.ts", "/spd/basic/operationSetting/baseMaintenance"),
"基础管理-手术套包设置-术种维护-UI自动化测试用例-01.csv": ("tests/spd/surgery-oper-type.spec.ts", "/spd/basic/operationSetting/typeMaintenance"),
"基础管理-手术套包设置-标准套包维护-UI自动化测试用例-01.csv": ("tests/spd/surgery-std-pkg.spec.ts", "/spd/basic/operationSetting/standardMaintenance"),
"基础管理-医院资料-公告管理-UI自动化测试用例-01.csv": ("tests/spd/basic-notice.spec.ts", "/spd/basic/hospital/paltform/notice"),
"基础管理-医院资料-院区管理-UI自动化测试用例-01.csv": ("tests/spd/basic-branch.spec.ts", "/spd/basic/hospital/paltform/area"),
"基础管理-医院资料-财务月份设置-UI自动化测试用例-01.csv": ("tests/spd/basic-finance-month.spec.ts", "/spd/basic/hospital/balance/month"),
"基础管理-医院资料-医院信息登记-UI自动化测试用例-01.csv": ("tests/spd/basic-hospital-info.spec.ts", "/spd/basic/hospital/paltform/info"),
"基础管理-用户管理-用户管理-UI自动化测试用例-01.csv": ("tests/spd/basic-user.spec.ts", "/spd/basic/user/platform/person"),
"基础管理-用户管理-医共体用户管理-UI自动化测试用例-01.csv": ("tests/spd/basic-user-union.spec.ts", "/spd/basic/user/platform/personCom"),
"基础管理-用户管理-角色管理-UI自动化测试用例-01.csv": ("tests/spd/basic-role.spec.ts", "/spd/basic/user/platform/role"),
"基础管理-准入管理-物料准入管理-UI自动化测试用例-01.csv": ("tests/spd/admit-material.spec.ts", "/spd/basic/dataSync/dataSync/productApply/ZRlist"),
"基础管理-准入管理-产品准入申请-UI自动化测试用例-01.csv": ("tests/spd/admit-product-apply.spec.ts", "/spd/basic/dataSync/dataSync/productApply/list"),
"基础管理-准入管理-准入任务分配-UI自动化测试用例-01.csv": ("tests/spd/admit-task.spec.ts", "/spd/basic/dataSync/dataSync/accessKind/list"),
"基础管理-准入管理-供货目录申请-UI自动化测试用例-01.csv": ("tests/spd/admit-supply.spec.ts", "/spd/basic/dataSync/dataSync/supplyApply/list"),
"基础管理-准入管理-物料准入单列表-UI自动化测试用例-01.csv": ("tests/spd/admit-material-list.spec.ts", "/spd/basic/dataSync/dataSync/productOrder/zrlist"),
"基础管理-准入管理-产品准入单列表-UI自动化测试用例-01.csv": ("tests/spd/admit-product-list.spec.ts", "/spd/basic/dataSync/dataSync/productOrder/list"),
}
PRIO_MAP = {"高": "P0", "中": "P1", "低": "P2"}
def h4_for(title: str) -> str:
if re.search(r"mock反例|拦截|未登录|空态|不可用", title):
return "异常验证点"
if re.search(r"边界|升序降序|切换20条", title):
return "边界验证点"
return "功能验证点"
def parse_steps(raw: str) -> list[str]:
lines = [ln.strip() for ln in (raw or "").splitlines() if ln.strip()]
out: list[str] = []
for ln in lines:
ln = re.sub(r"^\d+\.\s*", "", ln)
out.append(ln)
return out or ["(见 CSV 测试步骤)"]
def render_md(csv_name: str, rows: list[dict], spec: str, url: str) -> str:
stem = csv_name.replace(".csv", "")
title = stem.replace("-UI自动化测试用例-01", "")
module = rows[0]["功能模块"] if rows else ""
prios = Counter(PRIO_MAP.get(r["优先级"], "P1") for r in rows)
prio_txt = " / ".join(f"{k}={prios[k]}" for k in ("P0", "P1", "P2") if prios[k])
lines = [
f"# {stem.replace('基础管理-', '基础管理-')} UI 自动化测试用例",
"",
f"**执行 CSV**:`testcase/{csv_name}` ",
f"**页面 URL**:`{url}` ",
f"**自动化 Spec**:`{spec}`(依赖 `auth.setup` 登录态) ",
f"**版本**:{TODAY} ",
"",
"**通用前置**:正例账号 `test_1` 经 `auth.setup` 写入 `storageState`(密码仅存 `.env` 的 `TEST_PASS`)。 ",
"**页面约定**:整页渲染,**无报表 iframe**;每用例独立 BrowserContext(见 playwright-storage-state-isolation)。 ",
"",
"---",
"",
f"## {module}",
"",
]
current_h3 = ""
current_h4 = ""
for r in rows:
h3 = r["功能测试点"]
h4 = h4_for(r["用例标题"])
cid = r["用例编号"]
p = PRIO_MAP.get(r["优先级"], "P1")
if h3 != current_h3:
current_h3 = h3
current_h4 = ""
lines += [f"### {h3}", ""]
if h4 != current_h4:
current_h4 = h4
lines += [f"#### {h4}", ""]
tc = f"TC-UI-{cid.replace('-', '-')}"
lines += [
f"##### 【{p}】【{tc}】【{cid}】{r['用例标题']}",
"",
f"- 前置条件:{r['前置条件'] or '无'}",
"- 操作步骤:",
]
for i, step in enumerate(parse_steps(r["测试步骤"]), 1):
lines.append(f" {i}. {step}")
lines += [
f"- 自动化断言:与 CSV `{cid}` 及 `{spec}` 一致",
"",
"###### 预期结果",
"",
r["预期结果"] or "(见 CSV)",
"",
"---",
"",
]
while lines and lines[-1] in ("", "---"):
lines.pop()
lines += [
"",
"## 质量与覆盖说明",
"",
"| 项 | 说明 |",
"|----|------|",
f"| 用例数 | **{len(rows)}** 条({prio_txt}) |",
"| 来源 | 与 `testcase/` CSV 同源;CSV 变更后请重新运行本生成脚本 |",
"| 单一职责 | 每条只断言一个核心业务结果 |",
"",
]
return "\n".join(lines)
def normalize_only_token(token: str) -> str:
"""去掉路径与扩展名,便于用短名或完整文件名匹配。"""
name = Path(token).name
if name.endswith(".md"):
name = name[: -len(".md")] + ".csv"
return name
def select_meta(only: list[str] | None) -> dict[str, tuple[str, str]]:
if not only:
return dict(META)
tokens = [normalize_only_token(t.strip()) for t in only if t.strip()]
if not tokens:
return dict(META)
selected: dict[str, tuple[str, str]] = {}
unmatched: list[str] = []
for token in tokens:
hits = {
name: meta
for name, meta in META.items()
if name == token or token in name or Path(name).stem == Path(token).stem
}
if not hits:
unmatched.append(token)
continue
selected.update(hits)
if unmatched:
available = "\n".join(f" - {name}" for name in META)
print(
"未匹配到 META 条目:\n"
+ "\n".join(f" - {t}" for t in unmatched)
+ "\n可用 CSV:\n"
+ available,
file=sys.stderr,
)
raise SystemExit(2)
return selected
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="从 testcase/*.csv 生成 testcases/*.md(仅处理 META 中登记的基础管理模块)",
)
parser.add_argument(
"--only",
nargs="+",
metavar="NAME",
help=(
"只生成匹配的模块:可传完整 CSV/MD 文件名,或子串(如「供货目录申请」)。"
"可一次传多个。未指定则生成 META 全部。"
),
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> None:
args = parse_args(argv)
selected = select_meta(args.only)
MD_DIR.mkdir(parents=True, exist_ok=True)
total = 0
written = 0
for csv_name, (spec, url) in selected.items():
path = CSV_DIR / csv_name
if not path.exists():
print(f"skip missing {csv_name}")
continue
with path.open(encoding="utf-8", newline="") as f:
rows = list(csv.DictReader(f))
rows = [r for r in rows if r.get("用例编号")]
md_path = MD_DIR / csv_name.replace(".csv", ".md")
md_path.write_text(render_md(csv_name, rows, spec, url), encoding="utf-8")
total += len(rows)
written += 1
print(f"wrote {md_path.name} ({len(rows)} cases)")
print(f"done: {total} cases across {written} files (selected {len(selected)}/{len(META)})")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""生成基础管理-科室管理 12 页结构化 MD(testcases/)与同源 CSV(testcase/)。
测试点 ID 必须从 testpoint/*.md 按标题解析,禁止写死序号(有树/工具栏的页序号会偏移)。
约束:H3/用例标题/绑定的 TP 名须符合 `.cursor/rules/ui-test-point-rules.mdc` 第三节;禁止自造 TP 或简化自动化(组合查询对齐金标准 STAT-IOD-004)。
"""
from __future__ import annotations
import csv
import re
import sys
from collections import Counter, defaultdict
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOCATORS_DIR = ROOT / "locators" / "spd"
sys.path.insert(0, str(ROOT / "scripts"))
from spd_list_table_caps import list_table_caps, sortable_column_labels # noqa: E402
TP_DIR = ROOT / "testpoint"
MD_DIR = ROOT / "testcases"
CSV_DIR = ROOT / "testcase"
TODAY = date.today().isoformat()
POSITIVE_USER = "test_1"
MOCK_DEPT = "__NOT_EXIST_DEPT__99999"
CSV_FIELDS = [
"用例编号",
"功能模块",
"功能测试点",
"用例标题",
"优先级",
"前置条件",
"测试步骤",
"预期结果",
"实际结果",
]
TP_LINE_RE = re.compile(
r"\*\*(TP_DEPTM_[A-Z]+_\d{3})\*\*((高|中|低))|([^|]+)|验证要点:"
)
H3_RE = re.compile(r"^### (.+)$")
H4_RE = re.compile(r"^#### (.+)$")
PRIO_MD = {"高": "P0", "中": "P1", "低": "P2"}
def page(**kwargs) -> dict:
return kwargs
def case(
seq: int,
h3: str,
h4: str,
prio: str,
title: str,
tp_title: str,
steps: list[str],
expect: str,
pre: str = "",
tp_hint: str = "",
) -> dict:
"""prio 仅作占位,渲染前按测试点高/中/低覆盖为 P0/P1/P2。"""
return {
"seq": seq,
"h3": h3,
"h4": h4,
"prio": prio,
"title": title,
"tp_title": tp_title,
"tp_hint": tp_hint,
"steps": steps,
"expect": expect,
"pre": pre,
"tp_id": "",
}
def load_tp_index(name: str) -> dict[str, list[dict]]:
path = TP_DIR / f"基础管理-科室管理-{name}-测试点分析-01.md"
text = path.read_text(encoding="utf-8")
index: dict[str, list[dict]] = defaultdict(list)
h3 = ""
h4 = ""
for line in text.splitlines():
m3 = H3_RE.match(line)
if m3:
h3 = m3.group(1).strip()
continue
m4 = H4_RE.match(line)
if m4:
h4 = m4.group(1).strip()
continue
mt = TP_LINE_RE.search(line)
if mt:
rec = {
"id": mt.group(1),
"prio": mt.group(2),
"title": mt.group(3).strip(),
"h3": h3,
"h4": h4,
}
index[rec["title"]].append(rec)
return index
def resolve_tp(index: dict[str, list[dict]], c: dict, page_name: str) -> dict:
title = c["tp_title"]
hits = index.get(title) or []
hint = c.get("tp_hint") or ""
if hint:
filtered = [h for h in hits if hint in h["h3"] or hint in h["title"]]
if filtered:
hits = filtered
if len(hits) == 1:
return hits[0]
available = ", ".join(sorted(index))
raise SystemExit(
f"{page_name}: 无法唯一匹配测试点「{title}」hint={hint!r} hits={[h['id'] for h in hits]}\n"
f"可用标题: {available}"
)
def query_block(mock: str = MOCK_DEPT) -> list[dict]:
return [
case(
3,
"快捷筛选查询",
"功能验证点",
"P0",
"默认条件查询",
"默认条件查询",
[
"点击「查 询」或「查询」",
"断言列表有数据或明确空态,「共 N 条」与当前结果一致",
],
"默认查询无报错;总数与列表一致。",
),
]
def combo_query_case(*, advanced: bool = False) -> dict:
if advanced:
return case(
4,
"高级筛选抽屉",
"功能验证点",
"P0",
"全量查询条件组合查询",
"全量查询条件组合查询",
[
"默认查询 → 从首行提取全量样本(不写死业务数据,见 rules 3.4)",
"快捷区填入样本中有值的可见字段",
"高级筛选抽屉填入样本中所有非空字段(文本直接填、下拉选同值;禁用字段记录默认)",
"「查询/筛选」后抽样首行与汇总(若有);首行同时满足样本中全部已填字段",
],
"查询正常无报错;查询条件写值正常;查询结果与查询条件一致。",
pre="默认查询至少 1 条,否则 skip",
)
return case(
4,
"快捷筛选查询",
"功能验证点",
"P0",
"全量可见条件组合查询",
"全量可见条件组合查询",
[
"默认查询 → 从首行提取全量样本(只填当前结果里有值的条件,见 rules 3.2)",
"快捷区可见非空条件全部填入后查询",
"抽样首行同时满足全部已填条件;无未捕获异常",
],
"查询正常无报错;查询结果与查询条件一致。",
pre="默认查询至少 1 条,否则 skip",
)
def page_has_advanced_drawer(page_key: str) -> bool:
path = LOCATORS_DIR / f"{page_key}.locator.yaml"
if not path.exists():
return False
return "高级筛选" in path.read_text(encoding="utf-8")
def query_with_combo(*, mock: str = MOCK_DEPT, page_key: str = "", advanced: bool | None = None) -> list[dict]:
adv = page_has_advanced_drawer(page_key) if advanced is None else advanced
return query_block(mock) + [combo_query_case(advanced=adv), empty_list_case(mock)]
def empty_list_case(mock: str = MOCK_DEPT, *, field_hint: str = "科室名称/编号或任务名称等关键字") -> dict:
return case(
4,
"列表",
"边界验证点",
"P1",
"无匹配空态(mock反例)",
"无匹配空列表",
[
f"{field_hint}填入 `{mock}` 后查询",
"断言表头仍在,0 行或空态,分页总数为 0",
],
"无匹配不报错;不展示过期数据为成功态。",
)
def list_table_feature_cases(page_key: str) -> list[dict]:
caps = list_table_caps(page_key)
out: list[dict] = []
if caps["column_settings"]:
out.append(
case(
0,
"列表",
"功能验证点",
"P2",
"列设置隐藏后再恢复",
"列设置隐藏后再恢复",
[
"默认查询后点击表格工具栏列设置(齿轮,非 column-height 密度)",
"取消勾选一列非核心字段并关闭面板,断言表头隐藏",
"再次打开列设置勾选恢复并关闭,断言表头恢复",
],
"勾选状态与表头一致。",
pre="列表表头已加载",
)
)
for col in sortable_column_labels(page_key):
out.append(
case(
0,
"列表",
"功能验证点",
"P1",
f"{col}列升序降序切换",
"各可排序列升序降序",
[
"默认查询后列表有数据",
f"横向滚动使「{col}」列表头可见(宽表时)",
"第一次点击该列排序控件并校验升序指示器",
"第二次点击并校验降序指示器",
],
"排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。",
pre="至少 2 条数据,否则 skip",
)
)
return out
def list_block(cols: str, row_ops: str, *, include_row_action: bool = True) -> list[dict]:
items = [
case(
5,
"列表",
"功能验证点",
"P0",
"列表核心列完整",
"列表核心列完整",
[
f"断言表头含 {cols}",
f"若有数据行,断言操作列可见 {row_ops}",
],
"核心列完整;行内操作入口可见。",
),
]
if include_row_action:
items.append(
case(
7,
"列表",
"集成验证点",
"P0",
"行内操作可达(只读冒烟)",
"行内操作可达",
[
"首行存在数据时,点击行内首个操作(如编辑/详情/配置)",
"断言进入预期弹窗/抽屉/详情,可见取消或关闭",
"关闭弹层,列表仍可见",
],
"行内操作可达;关闭后回到列表。",
pre="列表至少 1 条",
)
)
return items
def pagination_block() -> list[dict]:
return [
case(
6,
"分页",
"功能验证点",
"P1",
"分页翻至第 2 页",
"分页翻页",
[
"记录第 1 页首行关键字段",
"点击页码「2」或「下一页」",
"断言页码高亮正确,首行数据与第 1 页不同",
],
"翻页成功;数据更换。",
pre="总条数大于一页,否则 skip",
),
]
def list_and_pagination_block(
cols: str,
row_ops: str,
*,
page_key: str = "",
include_row_action: bool = True,
) -> list[dict]:
items = list_block(cols, row_ops, include_row_action=include_row_action)
if page_key:
for i, extra in enumerate(list_table_feature_cases(page_key)):
items.insert(1 + i, extra)
return items + pagination_block()
def add_dialog_block(action: str = "新增") -> list[dict]:
return [
case(
8,
f"{action}弹窗",
"功能验证点",
"P0",
f"打开{action}弹窗/抽屉",
f"打开{action}层",
[
f"点击「{action}」或「新 增」",
"断言弹窗/抽屉可见,含「取 消」「确 定」或关闭",
],
f"{action}层打开;关键按钮可见。",
tp_hint=action,
),
case(
9,
f"{action}弹窗",
"异常验证点",
"P0",
"必填为空拦截",
"必填为空拦截",
[
f"打开{action},必填项留空后点击「确 定」",
"断言出现必填提示,弹窗仍打开,列表不刷新为成功态",
],
"确定被拦截;不落库。",
tp_hint=action,
),
case(
10,
f"{action}弹窗",
"功能验证点",
"P1",
f"{action}取消不落库",
"取消不落库",
[
f"打开{action},任意填写非空后点击「取 消」",
"断言弹窗关闭,列表行数与打开前一致(抽样)",
],
"取消后列表无新增/无修改。",
tp_hint=action,
),
]
def tree_block(tree_label: str = "科室") -> list[dict]:
return [
case(
3,
f"左侧{tree_label}树",
"功能验证点",
"P0",
f"{tree_label}树默认选中且右侧有范围",
"树默认有选中或可展开节点",
[
f"断言左侧{tree_label}树可见且有选中或可展开节点",
"读取选中节点名称 `${sampleDeptNode}`",
"断言右侧列表/表单与当前节点范围一致或为空态",
],
"树有选中;右侧不展示其它节点数据为成功态。",
),
case(
4,
f"左侧{tree_label}树",
"功能验证点",
"P0",
"切换树节点刷新右侧",
"切换树节点刷新右侧",
[
"记录当前节点与右侧首行(若有)",
"点选另一树节点(仅 1 个则 skip)",
"断言右侧数据范围切换,不残留上一节点首行",
],
"切换节点后右侧刷新。",
),
]
def export_case() -> dict:
return case(
8,
"导出",
"功能验证点",
"P1",
"导出入口可触发",
"导出入口可触发",
["点击「导 出」", "断言无未捕获异常(不校验文件)"],
"导出流程可启动。",
)
def print_case() -> dict:
return case(
9,
"打印",
"功能验证点",
"P1",
"打印入口可触发",
"打印入口可触发",
["点击「打 印」", "断言无未捕获异常"],
"打印流程可启动。",
)
PAGES = [
page(
code="DEPTM-REG",
tp_prefix="TP_DEPTM_REG",
group="科室管理",
name="科室登记",
path="/spd/basic/dept/platform/dept/list",
scan="deptm-register",
count=231,
spec="UI-SPEC-SPD-DEPTM-001",
spec_file="deptm-register.spec.ts",
tp_total=49,
nav_note=(
"菜单「基础设置 → 科室管理 → 科室登记」;建议 "
"`openPath('/spd/basic/dept/platform/dept/list')` 并断言 URL,勿误入分类维护/产品分配。"
),
write_note="新增/编辑只打开后取消或做必填拦截,不提交成功保存;配置页只读浏览或改一项后取消/回滚,不落脏数据。",
samples=(
"查询后从首行读取 `${sampleDeptCode}`、`${sampleDeptName}`、`${sampleCampus}`;"
f"mock:`{MOCK_DEPT}`。"
),
skip="真实保存新增/编辑、参数全字段、接口失败注入、会话过期、无菜单权限、导出文件内容",
cases=query_with_combo(page_key="deptm-register")
+ list_and_pagination_block(
"序号、院区、科室编码、科室名称、科室类型、操作",
"编辑、配置",
page_key="deptm-register",
)
+ add_dialog_block("新增")
+ [
case(
11,
"科室参数配置",
"功能验证点",
"P0",
"配置入口跳转科室参数页",
"配置入口跳转",
[
"记录首行 `${sampleDeptCode}`、`${sampleDeptName}`",
"点击该行「配置」",
"断言 URL 含 dept-param 或页面标题/面包屑为科室参数,且科室编码/名称与来源行一致",
],
"进入参数配置页;科室与登记行一致。",
pre="列表至少 1 条且含配置入口",
),
case(
12,
"科室参数配置",
"功能验证点",
"P1",
"参数页返回登记列表",
"无修改直接返回",
[
"点击首行「配置」打开参数页",
"不修改,返回或关闭",
"断言回到科室登记列表,该行无变化",
],
"返回后仍在登记页;该行无变化。",
pre="列表至少 1 条且含配置入口",
),
],
),
page(
code="DEPTM-CAT",
tp_prefix="TP_DEPTM_CAT",
group="科室管理",
name="科室分类维护",
path="/spd/basic/dept/platform/dept/calatog",
scan="deptm-category",
count=198,
spec="UI-SPEC-SPD-DEPTM-002",
spec_file="deptm-category.spec.ts",
tp_total=33,
nav_note="`openPath('/spd/basic/dept/platform/dept/calatog')`;左右分栏,勿与科室登记 list 混淆。",
write_note="添加科室只打开弹窗后取消;导出不断言文件;分类树新增/改名不提交持久化。",
samples="右侧首行 `${sampleDeptCode}`;左侧分类名 `${sampleCatName}`;mock 同登记。",
skip="Excel 导出内容、添加弹窗全字段、日志详情字段、接口失败",
cases=[
case(
3,
"左侧分类树",
"功能验证点",
"P0",
"分类树搜索过滤",
"分类树搜索过滤",
[
"读取当前分类节点名取关键字",
"在左侧分类名称搜索框输入关键字",
"断言可见节点均匹配",
],
"树过滤与关键字一致。",
),
]
+ query_with_combo(page_key="deptm-category")
+ list_and_pagination_block("科室编码、科室名称、科室分类名称、操作", "编辑、日志", page_key="deptm-category")
+ [export_case()],
),
page(
code="DEPTM-PRA",
tp_prefix="TP_DEPTM_PRA",
group="科室管理",
name="科室产品分配",
path="/spd/basic/dept/platform/dept/product",
scan="deptm-product-alloc",
count=100,
spec="UI-SPEC-SPD-DEPTM-003",
spec_file="deptm-product-alloc.spec.ts",
tp_total=44,
nav_note="`openPath('.../product')`;左侧科室树 + 右侧物料列表。",
write_note="新增/编辑/批量修改/Excel 导入仅入口与取消;打印/导出不校验文件。",
samples="树节点 `${sampleDeptNode}`;首行物料 `${sampleMaterialNo}`。",
skip="批量修改全字段、导入 Excel 字段、真实分配保存",
cases=tree_block("科室")
+ query_with_combo(page_key="deptm-product-alloc")
+ list_and_pagination_block(
"规格型号、物料编号、操作",
"详情、编辑",
page_key="deptm-product-alloc",
)
+ [export_case(), print_case()]
+ add_dialog_block("新增")[:1],
),
page(
code="DEPTM-ZON",
tp_prefix="TP_DEPTM_ZON",
group="科室管理",
name="科室库区产品设置",
path="/spd/basic/dept/platform/dept/areaProduct",
scan="deptm-zone-product",
count=79,
spec="UI-SPEC-SPD-DEPTM-004",
spec_file="deptm-zone-product.spec.ts",
tp_total=39,
nav_note="`openPath('.../areaProduct')`;科室树 + 库区筛选。",
write_note="新增/批量修改/导入仅冒烟;导出不校验文件。",
samples="`${sampleZoneName}`、`${sampleMaterialNo}`。",
skip="库存上下限真实保存、导入模板字段",
cases=tree_block("科室")
+ query_with_combo(page_key="deptm-zone-product")
+ list_and_pagination_block("物料编号、默认上架货位、操作", "编辑", page_key="deptm-zone-product")
+ [export_case()],
),
page(
code="DEPTM-FLM",
tp_prefix="TP_DEPTM_FLM",
group="科室管理",
name="科室跟台限额任务",
path="/spd/basic/dept/platform/dept/limit",
scan="deptm-follow-limit",
count=56,
spec="UI-SPEC-SPD-DEPTM-005",
spec_file="deptm-follow-limit.spec.ts",
tp_total=35,
nav_note="`openPath('.../limit')`;含状态 Tab。",
write_note="新增只打开取消;上传/导出不传真实文件。",
samples="`${sampleTaskName}`、`${sampleDeptName}`。",
skip="周期性任务全生命周期、上传文件内容",
cases=query_with_combo(mock="__NOT_EXIST_FLM_TASK__", page_key="deptm-follow-limit")
+ [
case(
5,
"状态 Tab",
"功能验证点",
"P0",
"状态 Tab 与列表一致",
"状态 Tab 与列表一致",
[
"切换「全部」与某一状态 Tab",
"断言列表状态列与 Tab 语义一致(抽样)",
],
"Tab 切换后列表范围变化合理。",
),
]
+ list_and_pagination_block("科室名称、限额任务名称、操作", "详情、上传、导出", page_key="deptm-follow-limit")
+ add_dialog_block("新增")[:2],
),
page(
code="DEPTM-TPL",
tp_prefix="TP_DEPTM_TPL",
group="科室管理",
name="请领模板维护",
path="/spd/basic/dept/platform/dept/apply",
scan="deptm-req-template",
count=195,
spec="UI-SPEC-SPD-DEPTM-006",
spec_file="deptm-req-template.spec.ts",
tp_total=33,
nav_note="`openPath('.../apply')`;左模板目录 + 右明细。",
write_note="新增模板/添加产品/导入 excel 仅入口与取消;移除不确认。",
samples="`${sampleTemplateName}`、明细首行规格型号。",
skip="模板全字段、导入 Excel 字段级",
cases=[
case(
3,
"左侧模板目录",
"功能验证点",
"P0",
"切换模板刷新右侧明细",
"切换模板刷新右侧",
[
"点选左侧另一模板(仅 1 个则 skip)",
"断言右侧明细列表刷新,不串上一模板数据",
],
"模板切换后明细对应。",
),
]
+ query_with_combo(page_key="deptm-req-template")
+ list_and_pagination_block("规格型号、耗材类型、操作", "编辑、移除", page_key="deptm-req-template")
+ [
case(
8,
"Excel 导入与批量写操作",
"功能验证点",
"P1",
"添加产品与导入入口",
"工具栏写操作入口可见",
[
"点击「添加产品」或「导入excel」",
"断言打开选择/导入流程或提示,无 error",
],
"入口可达。",
),
],
),
page(
code="DEPTM-AUR",
tp_prefix="TP_DEPTM_AUR",
group="科室管理",
name="自动请领排程",
path="/spd/basic/dept/platform/dept/schedule",
scan="deptm-auto-req",
count=96,
spec="UI-SPEC-SPD-DEPTM-007",
spec_file="deptm-auto-req.spec.ts",
tp_total=35,
nav_note="`openPath('.../schedule')`;排程类型星期/天数列。",
write_note="新增/编辑只打开取消或必填拦截;删除不确认。",
samples="`${sampleDeptCode}`、排程类型列文本。",
skip="真实删除排程、星期全组合保存",
cases=query_with_combo(page_key="deptm-auto-req")
+ list_and_pagination_block("科室编码、排程类型、是否启用、操作", "编辑、删除", page_key="deptm-auto-req")
+ [
case(
8,
"排程类型展示",
"功能验证点",
"P0",
"星期型排程行内展示可读",
"星期型排程展示",
[
"查找排程类型为「星期」的行",
"断言行内展示周日~周六可选状态且可读",
],
"星期型排程 UI 可读。",
pre="存在星期型排程行,否则 skip",
),
]
+ add_dialog_block("新增")[:2],
),
page(
code="DEPTM-STF",
tp_prefix="TP_DEPTM_STF",
group="科室管理",
name="科室人员查询",
path="/spd/basic/dept/platform/dept/person",
scan="deptm-staff",
count=102,
spec="UI-SPEC-SPD-DEPTM-008",
spec_file="deptm-staff.spec.ts",
tp_total=30,
nav_note="`openPath('.../person')`;左科室目录 + 右人员列表。",
write_note="重置密码只打开确认框后取消;科室负责人勾选不持久化验证。",
samples="`${sampleLogin}`、`${samplePersonName}`。",
skip="真实改密、负责人权限落库",
cases=tree_block("科室目录")
+ [
case(
5,
"人员筛选与行操作",
"功能验证点",
"P0",
"人员名称搜索",
"人员名称搜索",
[
"读取首行 `${samplePersonName}` 取关键字",
"在人员名称搜索框输入并搜索",
"断言列表账号/姓名匹配",
],
"人员搜索命中。",
pre="当前科室至少 1 人",
),
case(
6,
"人员筛选与行操作",
"功能验证点",
"P0",
"重置密码确认框(取消)",
"重置密码入口",
[
"点击首行「重置密码」",
"断言出现确认框",
"点击取消,列表无异常变化",
],
"确认框可见;取消不改密。",
pre="列表至少 1 条",
),
]
+ list_and_pagination_block(
"账号、人员名称、联系电话、操作",
"重置密码",
include_row_action=False,
page_key="deptm-staff",
),
),
page(
code="DEPTM-MNM",
tp_prefix="TP_DEPTM_MNM",
group="科室管理",
name="科室上下限建议",
path="/spd/basic/dept/platform/dept/deptLimitOld",
scan="deptm-minmax",
count=52,
spec="UI-SPEC-SPD-DEPTM-009",
spec_file="deptm-minmax.spec.ts",
tp_total=36,
nav_note="`openPath('.../deptLimitOld')`;含全部调整/导出/打印。",
write_note="行调整/编辑只打开后取消;全部调整不提交。",
samples="`${sampleMaterialNo}`、建议上下限列。",
skip="调整算法正确性、导出/打印文件",
cases=query_with_combo(page_key="deptm-minmax")
+ list_and_pagination_block("物料编号、建议上下限、状态、操作", "调整、编辑", page_key="deptm-minmax")
+ [export_case(), print_case()],
),
page(
code="DEPTM-BUD",
tp_prefix="TP_DEPTM_BUD",
group="科室管理",
name="科室预算管理",
path="/spd/basic/dept/platform/dept/budget",
scan="deptm-budget",
count=94,
spec="UI-SPEC-SPD-DEPTM-010",
spec_file="deptm-budget.spec.ts",
tp_total=36,
nav_note="`openPath('.../budget')`;状态 Tab + 预算进度列。",
write_note="新增/编辑/关闭/删除仅入口与取消;关闭不确认。",
samples="`${sampleTaskNo}`、预算金额列。",
skip="预算金额落库、关闭任务持久化",
cases=query_with_combo(mock="__NOT_EXIST_BUDGET__", page_key="deptm-budget")
+ [
case(
5,
"状态 Tab",
"功能验证点",
"P0",
"状态 Tab 与列表一致",
"状态 Tab 与列表一致",
["切换状态 Tab", "断言列表状态列与 Tab 一致(抽样)"],
"Tab 与列表一致。",
),
]
+ list_and_pagination_block("预算任务名称、预算进度、操作", "详情、关闭、编辑、删除", page_key="deptm-budget")
+ add_dialog_block("新增")[:2],
),
page(
code="DEPTM-LAB",
tp_prefix="TP_DEPTM_LAB",
group="科室管理",
name="检验机器管理",
path="/spd/basic/dept/platform/dept/machine",
scan="deptm-lab-machine",
count=133,
spec="UI-SPEC-SPD-DEPTM-011",
spec_file="deptm-lab-machine.spec.ts",
tp_total=42,
nav_note="`openPath('.../machine')`;科室树 + 筛选可收起。",
write_note="新增/编辑/删除仅冒烟;删除不确认。",
samples="`${sampleMachineNo}`、`${sampleMachineName}`。",
skip="机器与 LIS 对接、真实删除",
cases=tree_block("科室")
+ query_with_combo(mock="__NOT_EXIST_MACHINE__", page_key="deptm-lab-machine")
+ [
case(
5,
"查询条件展开收起",
"功能验证点",
"P1",
"展开后更多条件可见",
"展开后更多条件可见",
[
"点击「收起」折叠筛选区",
"再展开,断言已填条件保留(以现网为准)",
],
"收起展开正常。",
),
]
+ list_and_pagination_block("机器编号、机器名称、操作", "编辑、删除", page_key="deptm-lab-machine")
+ add_dialog_block("新增")[:2],
),
page(
code="DEPTM-DEL",
tp_prefix="TP_DEPTM_DEL",
group="科室管理",
name="科室送货计划",
path="/spd/basic/dept/platform/dept/deptPlan",
scan="deptm-delivery",
count=60,
spec="UI-SPEC-SPD-DEPTM-012",
spec_file="deptm-delivery.spec.ts",
tp_total=35,
nav_note="`openPath('.../deptPlan')`;左计划列表 + 右排程详情,勿与自动请领 schedule 混淆。",
write_note="新增计划/添加科室库区只打开后取消;删除计划不确认。",
samples="`${samplePlanName}`、子表库区名称。",
skip="排程执行链路、库区保存",
cases=[
case(
3,
"左侧送货计划列表",
"功能验证点",
"P0",
"切换计划刷新右侧详情",
"切换计划刷新右侧",
[
"点选左侧另一计划(仅 1 个则 skip)",
"断言右侧排程信息与计划名称一致,不串上一计划",
],
"计划切换后详情对应。",
),
case(
4,
"右侧排程计划详情",
"功能验证点",
"P0",
"排程信息与耗材类型展示",
"排程信息只读展示",
[
"选中有计划的数据",
"断言展示排程类型、间隔、耗材类型勾选等只读信息",
],
"详情字段可读。",
),
case(
5,
"右侧排程计划详情",
"功能验证点",
"P0",
"添加科室库区入口",
"添加科室库区入口",
[
"点击「添加科室库区」",
"断言打开选择弹窗/面板,取消关闭",
],
"添加入口可达。",
),
case(
6,
"右侧排程计划详情",
"功能验证点",
"P1",
"子表空态",
"子表空态",
[
"选中无科室库区的计划(无则 skip)",
"断言子表「暂无数据」等空态",
],
"空态不报错。",
),
]
+ add_dialog_block("新增计划")[:2],
),
]
def tp_id(p: dict, c: dict) -> str:
return c["tp_id"]
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['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['name']}」权限。反例筛选用 mock。 ",
"**页面约定**:整页渲染,**无报表 iframe**;定位均在 `page` 上;每用例独立 context(见 playwright-storage-state-isolation)。 ",
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}"
if is_unlogin(c):
pre_txt = "独立 context,无登录态(不加载 storageState)"
else:
pre_txt = c["pre"] or f"已进入{p['name']}页"
lines += [
f"##### 【{c['prio']}】【{tc}】【{tp_id(p, c)}】{c['title']}",
"",
f"- 前置条件:{pre_txt}",
"- 操作步骤:",
]
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/openFromMenu,不复用 BrowserContext |",
"",
]
return "\n".join(lines)
def renumber_cases(cases: list[dict]) -> None:
for i, c in enumerate(cases, start=1):
c["seq"] = i
def bind_tps(p: dict) -> None:
index = load_tp_index(p["name"])
p["tp_total"] = sum(len(v) for v in index.values())
for c in p["cases"]:
rec = resolve_tp(index, c, p["code"])
c["tp_id"] = rec["id"]
c["prio"] = PRIO_MD[rec["prio"]]
c["h4"] = rec["h4"]
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:
bind_tps(p)
renumber_cases(p["cases"])
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}")
sort_tp = "各可排序列升序降序"
ids = [c["tp_id"] for c in p["cases"] if c.get("tp_title") != sort_tp]
dup = [i for i in ids if ids.count(i) > 1]
if dup:
raise SystemExit(f"{p['code']} 多条用例共用同一 TP: {sorted(set(dup))}")
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}")
print(f"done pages={len(PAGES)} cases={total}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""从 testcase CSV 生成 tests/spd/deptm-*.spec.ts,与 CSV 用例编号+标题+功能测试点严格一致。"""
from __future__ import annotations
import csv
import re
from collections import OrderedDict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CSV_DIR = ROOT / "testcase"
SPEC_DIR = ROOT / "tests" / "spd"
SORT_COL_TITLE_RE = re.compile(r"^(.+)列升序降序切换$")
# pageKey -> fixture import name
PAGES = [
("register", "DeptmRegisterPage", "deptmRegisterPage", "deptm-register.spec.ts", "基础管理-科室管理-科室登记-UI自动化测试用例-01.csv"),
("category", "DeptmCategoryPage", "deptmCategoryPage", "deptm-category.spec.ts", "基础管理-科室管理-科室分类维护-UI自动化测试用例-01.csv"),
("productAlloc", "DeptmProductAllocPage", "deptmProductAllocPage", "deptm-product-alloc.spec.ts", "基础管理-科室管理-科室产品分配-UI自动化测试用例-01.csv"),
("zoneProduct", "DeptmZoneProductPage", "deptmZoneProductPage", "deptm-zone-product.spec.ts", "基础管理-科室管理-科室库区产品设置-UI自动化测试用例-01.csv"),
("followLimit", "DeptmFollowLimitPage", "deptmFollowLimitPage", "deptm-follow-limit.spec.ts", "基础管理-科室管理-科室跟台限额任务-UI自动化测试用例-01.csv"),
("reqTemplate", "DeptmReqTemplatePage", "deptmReqTemplatePage", "deptm-req-template.spec.ts", "基础管理-科室管理-请领模板维护-UI自动化测试用例-01.csv"),
("autoReq", "DeptmAutoReqPage", "deptmAutoReqPage", "deptm-auto-req.spec.ts", "基础管理-科室管理-自动请领排程-UI自动化测试用例-01.csv"),
("staff", "DeptmStaffPage", "deptmStaffPage", "deptm-staff.spec.ts", "基础管理-科室管理-科室人员查询-UI自动化测试用例-01.csv"),
("minmax", "DeptmMinmaxPage", "deptmMinmaxPage", "deptm-minmax.spec.ts", "基础管理-科室管理-科室上下限建议-UI自动化测试用例-01.csv"),
("budget", "DeptmBudgetPage", "deptmBudgetPage", "deptm-budget.spec.ts", "基础管理-科室管理-科室预算管理-UI自动化测试用例-01.csv"),
("labMachine", "DeptmLabMachinePage", "deptmLabMachinePage", "deptm-lab-machine.spec.ts", "基础管理-科室管理-检验机器管理-UI自动化测试用例-01.csv"),
("delivery", "DeptmDeliveryPage", "deptmDeliveryPage", "deptm-delivery.spec.ts", "基础管理-科室管理-科室送货计划-UI自动化测试用例-01.csv"),
]
def parse_csv(path: Path) -> list[dict]:
text = path.read_text(encoding="utf-8-sig")
rows = list(csv.DictReader(text.splitlines()))
return [r for r in rows if r.get("用例编号")]
def body_for(title: str, fixture: str) -> tuple[str, bool]:
"""Return (typescript body, is_unauth)."""
f = fixture
if title.startswith("菜单进入"):
return (
f""" await {f}.openFromMenu();
await {f}.expectOnPage();
await {f}.expectEnterMarkers();""",
False,
)
if title == "未登录访问拦截":
return (
f""" const probe = await {f}.probeUnauthAccess();
expect(probe.onLogin || !probe.hasBusinessRow, '未登录不可见业务数据').toBeTruthy();""",
True,
)
if "科室树默认选中" in title or "科室目录树默认选中" in title:
return (
f""" await {f}.open();
await {f}.expectTreeDefaultScope();""",
False,
)
if title == "切换树节点刷新右侧":
return (
f""" await {f}.open();
const switched = await {f}.switchOtherTreeNode();
expect(switched, '应能切换树节点').toBeTruthy();""",
False,
)
if title in ("筛选区收起展开", "展开第二行筛选", "展开后更多条件可见"):
return (
f""" await {f}.open();
await {f}.toggleFilterCollapse();""",
False,
)
if title == "默认条件查询":
return (
f""" await {f}.open();
await {f}.clickQuery();
const snap = await {f}.snapshotList();
expect(snap.rows >= 0 && (snap.total >= 0 || snap.emptyVisible), '查询应有结果或空态').toBeTruthy();""",
False,
)
if title in ("全量可见条件组合查询", "全量查询条件组合查询"):
return (
""" test.skip(
true,
'组合查询须按页面配置 quickFields/drawerFields 实现 searchWithQuickAndDrawerCombo(对齐 center-report-sheet STAT-IOD-004),禁止临时启发式填表',
);""",
False,
)
if "无匹配空态" in title:
return (
f""" await {f}.open();
await {f}.searchByMockKeyword();
const snap = await {f}.snapshotList();
expect(snap.rows === 0 || snap.emptyVisible, '无匹配应空态').toBeTruthy();""",
False,
)
if title == "列表核心列完整":
return (
f""" await {f}.open();
await {f}.assertConfiguredHeaders();""",
False,
)
if title == "列设置隐藏后再恢复":
return (
f""" await {f}.open();
await {f}.clickQuery();
await {f}.executeColumnSettingsToggleRestoreTest();""",
False,
)
sort_m = SORT_COL_TITLE_RE.match(title)
if sort_m:
col = sort_m.group(1)
return (
f""" await {f}.open();
await {f}.expectColumnSortAscDesc({col!r});""",
False,
)
if title == "分页翻至第 2 页":
return (
f""" await {f}.open();
await {f}.goToPage2ForDeptm();""",
False,
)
if "行内操作可达" in title:
return (
f""" await {f}.open();
await {f}.rowActionSmoke();""",
False,
)
if title.startswith("打开新增"):
return (
f""" await {f}.open();
await {f}.openAddOverlay();
await {f}.closeOverlay();""",
False,
)
if title == "必填为空拦截":
return (
f""" await {f}.open();
await {f}.assertRequiredIntercept();""",
False,
)
if title == "新增取消不落库":
return (
f""" await {f}.open();
await {f}.cancelAddWithoutCommit();""",
False,
)
if title == "配置入口跳转科室参数页":
return (
f""" await {f}.open();
await {f}.openConfigFromFirstRow();""",
False,
)
if title == "参数页返回登记列表":
return (
f""" await {f}.open();
await {f}.openConfigFromFirstRow();
await {f}.returnFromConfigToRegister();""",
False,
)
if title == "分类树搜索过滤":
return (
f""" await {f}.open();
await {f}.filterCategoryTree();""",
False,
)
if title == "院区与科室筛选":
return (
f""" await {f}.open();
await {f}.filterCampusDept();""",
False,
)
if title == "导出入口可触发":
return (
f""" await {f}.open();
await {f}.clickExportIfVisible();""",
False,
)
if title == "打印入口可触发":
return (
f""" await {f}.open();
await {f}.clickPrintIfVisible();""",
False,
)
if title == "打印与导出入口可触发" or title == "打印导出入口":
return (
f""" await {f}.open();
await {f}.clickPrintAndExport();""",
False,
)
if title == "库区下拉筛选":
return (
f""" await {f}.open();
await {f}.filterZoneSelect();""",
False,
)
if title == "状态 Tab 与列表一致" or title == "状态 Tab 切换":
return (
f""" await {f}.open();
await {f}.clickFirstStatusTab();""",
False,
)
if title == "切换模板刷新右侧明细":
return (
f""" await {f}.open();
await {f}.switchLeftListRow(1);""",
False,
)
if title == "右侧物料筛选":
return (
f""" await {f}.open();
await {f}.searchTemplateMaterial();""",
False,
)
if title == "添加产品与导入入口":
return (
f""" await {f}.open();
await {f}.templateToolbarEntry();""",
False,
)
if title == "星期型排程行内展示可读":
return (
f""" await {f}.open();
await {f}.expectWeekScheduleRow();""",
False,
)
if title == "科室编码或名称筛选":
return (
f""" await {f}.open();
await {f}.searchDeptCodeOrNameFromSample();""",
False,
)
if title == "人员名称搜索":
return (
f""" await {f}.open();
await {f}.staffNameSearch();""",
False,
)
if title == "重置密码确认框(取消)":
return (
f""" await {f}.open();
await {f}.staffResetPasswordCancel();""",
False,
)
if title == "切换计划刷新右侧详情":
return (
f""" await {f}.open();
await {f}.switchLeftListRow(1);""",
False,
)
if title == "排程信息与耗材类型展示":
return (
f""" await {f}.open();
await {f}.deliveryScheduleVisible();""",
False,
)
if title == "添加科室库区入口":
return (
f""" await {f}.open();
await {f}.deliveryAddZoneEntry();""",
False,
)
if title == "子表空态":
return (
f""" await {f}.open();
await {f}.deliverySubTableEmpty();""",
False,
)
raise ValueError(f"未映射用例标题: {title}")
def render_spec(fixture: str, csv_name: str, spec_file: str, cases: list[dict]) -> str:
grouped: OrderedDict[str, list[dict]] = OrderedDict()
for c in cases:
point = c["功能测试点"].strip()
grouped.setdefault(point, []).append(c)
npm_script = spec_file.replace(".spec.ts", "")
lines = [
"import { expect, test } from '../../fixtures';",
"",
"/**",
f" * 全量用例:与 testcase/{csv_name} 严格一致",
f" * 运行:npm run test:{npm_script}:all",
" */",
"test.describe('基础设置 · 科室管理', () => {",
]
for point, items in grouped.items():
lines.append(f" test.describe({json_str(point)}, () => {{")
unauth_pending: list[tuple[str, str, str]] = []
for c in items:
cid = c["用例编号"].strip()
title = c["用例标题"].strip()
test_title = f"{cid} {title}"
body, is_unauth = body_for(title, fixture)
smoke = " { tag: '@smoke' }," if cid.endswith("-001") else ""
if is_unauth:
unauth_pending.append((test_title, smoke, body))
continue
lines.append(f" test({json_str(test_title)},{smoke} async ({{ {fixture} }}) => {{")
lines.append(body)
lines.append(" });")
lines.append("")
if unauth_pending:
lines.append(" test.describe('未登录', () => {")
lines.append(" test.use({ storageState: { cookies: [], origins: [] } });")
for test_title, smoke, body in unauth_pending:
lines.append(f" test({json_str(test_title)},{smoke} async ({{ {fixture} }}) => {{")
for line in body.splitlines():
lines.append(f" {line.strip()}")
lines.append(" });")
lines.append(" });")
while lines and lines[-1] == "":
lines.pop()
lines.append(" });")
lines.append("")
lines.append("});")
lines.append("")
return "\n".join(lines)
def json_str(s: str) -> str:
return repr(s)
def main() -> None:
SPEC_DIR.mkdir(parents=True, exist_ok=True)
for _key, _cls, fixture, spec_file, csv_name in PAGES:
cases = parse_csv(CSV_DIR / csv_name)
for c in cases:
body_for(c["用例标题"].strip(), fixture)
spec = render_spec(fixture, csv_name, spec_file, cases)
out = SPEC_DIR / spec_file
out.write_text(spec, encoding="utf-8")
print(f"OK {spec_file}\t{len(cases)} tests")
print("done")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Generate 科室管理 (DEPTM-001~012) testpoint MD and patch UI-SPEC / INDEX.
约束:测试点 H3/TP 名称与验证要点须严格遵循 `.cursor/rules/ui-test-point-rules.mdc` 第三节 3.2 共用目录原文,禁止自造名称或单字段筛选 TP(3.1 由组合查询覆盖)。
"""
from __future__ import annotations
import json
import re
import sys
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCAN_DIR = ROOT / "data" / "scan"
LOCATORS_DIR = ROOT / "locators" / "spd"
sys.path.insert(0, str(ROOT / "scripts"))
from spd_list_table_caps import list_table_caps # noqa: E402
OUT_DIR = ROOT / "testpoint"
KB = ROOT / "knowledge-base" / "08-UI专项规范库"
INDEX = KB / "INDEX.md"
SCOPE = "testpoint/scope/基础管理-科室管理-UI测试点范围.md"
TODAY = date.today().isoformat()
PEERS = (
"科室登记、科室分类维护、科室产品分配、科室库区产品设置、科室跟台限额任务、"
"请领模板维护、自动请领排程、科室人员查询、科室上下限建议、科室预算管理、"
"检验机器管理、科室送货计划"
)
PAGES = [
{
"pageKey": "deptm-register",
"specId": "DEPTM-001",
"name": "科室登记",
"prefix": "TP_DEPTM_REG",
"file": "基础管理-科室管理-科室登记-测试点分析-01.md",
"path": "/spd/basic/dept/platform/dept/list",
"ready": "整页单表:院区/科室名称/科室类型筛选、高级筛选、新增、列表核心列与分页可见,无 iframe",
"cover": "导航、筛选与高级筛选、列表分页、新增/编辑/配置入口",
},
{
"pageKey": "deptm-category",
"specId": "DEPTM-002",
"name": "科室分类维护",
"prefix": "TP_DEPTM_CAT",
"file": "基础管理-科室管理-科室分类维护-测试点分析-01.md",
"path": "/spd/basic/dept/platform/dept/calatog",
"ready": "左右分栏:左侧分类树与新增分类、右侧院区/科室筛选与列表、导出可见",
"cover": "导航、左侧分类树、右侧筛选列表、添加/编辑/日志、导出",
},
{
"pageKey": "deptm-product-alloc",
"specId": "DEPTM-003",
"name": "科室产品分配",
"prefix": "TP_DEPTM_PRA",
"file": "基础管理-科室管理-科室产品分配-测试点分析-01.md",
"path": "/spd/basic/dept/platform/dept/product",
"ready": "左右分栏:左侧科室树、右侧物料筛选与工具栏(新增/打印/导出/批量修改/Excel导入)、列表与分页",
"cover": "导航、科室树切换、筛选与高级筛选、工具栏、列表分页、详情/编辑",
},
{
"pageKey": "deptm-zone-product",
"specId": "DEPTM-004",
"name": "科室库区产品设置",
"prefix": "TP_DEPTM_ZON",
"file": "基础管理-科室管理-科室库区产品设置-测试点分析-01.md",
"path": "/spd/basic/dept/platform/dept/areaProduct",
"ready": "左右分栏:左侧科室树、右侧库区/物料筛选、新增/批量修改/导出/Excel导入、列表与分页",
"cover": "导航、科室树与库区筛选、工具栏、列表分页、编辑",
},
{
"pageKey": "deptm-follow-limit",
"specId": "DEPTM-005",
"name": "科室跟台限额任务",
"prefix": "TP_DEPTM_FLM",
"file": "基础管理-科室管理-科室跟台限额任务-测试点分析-01.md",
"path": "/spd/basic/dept/platform/dept/limit",
"ready": "整页单表:制单日期/科室/任务名称筛选、状态 Tab、新增、列表与分页",
"cover": "导航、筛选、状态 Tab、列表分页、详情/上传/导出、新增",
},
{
"pageKey": "deptm-req-template",
"specId": "DEPTM-006",
"name": "请领模板维护",
"prefix": "TP_DEPTM_TPL",
"file": "基础管理-科室管理-请领模板维护-测试点分析-01.md",
"path": "/spd/basic/dept/platform/dept/apply",
"ready": "主从分栏:左侧请领模板目录、右侧物料筛选与添加产品/导入、明细列表与分页",
"cover": "导航、模板目录、右侧筛选与明细、添加产品/导入、编辑/移除",
},
{
"pageKey": "deptm-auto-req",
"specId": "DEPTM-007",
"name": "自动请领排程",
"prefix": "TP_DEPTM_AUR",
"file": "基础管理-科室管理-自动请领排程-测试点分析-01.md",
"path": "/spd/basic/dept/platform/dept/schedule",
"ready": "整页单表:院区/科室筛选、新增、排程类型(星期/天数)列、编辑/删除、分页",
"cover": "导航、筛选、列表与排程维度展示、新增/编辑/删除",
},
{
"pageKey": "deptm-staff",
"specId": "DEPTM-008",
"name": "科室人员查询",
"prefix": "TP_DEPTM_STF",
"file": "基础管理-科室管理-科室人员查询-测试点分析-01.md",
"path": "/spd/basic/dept/platform/dept/person",
"ready": "左右分栏:左侧科室目录树、右侧人员名称搜索、列表(含科室负责人勾选、重置密码)与分页",
"cover": "导航、科室树、人员列表、科室负责人勾选、重置密码",
},
{
"pageKey": "deptm-minmax",
"specId": "DEPTM-009",
"name": "科室上下限建议",
"prefix": "TP_DEPTM_MNM",
"file": "基础管理-科室管理-科室上下限建议-测试点分析-01.md",
"path": "/spd/basic/dept/platform/dept/deptLimitOld",
"ready": "整页宽表:科室/库区/制单日期等筛选、全部调整/导出/打印、建议上下限列与状态、分页",
"cover": "导航、组合筛选、工具栏调整/导出/打印、列表与状态、行调整/编辑",
},
{
"pageKey": "deptm-budget",
"specId": "DEPTM-010",
"name": "科室预算管理",
"prefix": "TP_DEPTM_BUD",
"file": "基础管理-科室管理-科室预算管理-测试点分析-01.md",
"path": "/spd/basic/dept/platform/dept/budget",
"ready": "整页单表:制单日期/任务单号/任务名称/院区/科室筛选、状态 Tab、新增、列表与分页",
"cover": "导航、筛选、状态 Tab、列表分页、详情/关闭/编辑/删除、新增",
},
{
"pageKey": "deptm-lab-machine",
"specId": "DEPTM-011",
"name": "检验机器管理",
"prefix": "TP_DEPTM_LAB",
"file": "基础管理-科室管理-检验机器管理-测试点分析-01.md",
"path": "/spd/basic/dept/platform/dept/machine",
"ready": "左右分栏:左侧科室树、右侧机器多条件筛选(可收起)、新增、列表编辑/删除、分页",
"cover": "导航、科室树、筛选收起、列表分页、新增/编辑/删除",
},
{
"pageKey": "deptm-delivery",
"specId": "DEPTM-012",
"name": "科室送货计划",
"prefix": "TP_DEPTM_DEL",
"file": "基础管理-科室管理-科室送货计划-测试点分析-01.md",
"path": "/spd/basic/dept/platform/dept/deptPlan",
"ready": "主从分栏:左侧送货计划列表与新增计划、右侧排程计划详情与添加科室库区",
"cover": "导航、计划列表、排程详情、添加科室库区",
},
]
def load_scan(page_key: str) -> dict:
data = json.loads((SCAN_DIR / f"{page_key}-elements.json").read_text(encoding="utf-8"))
return {"url": data["url"].split("?")[0], "elementCount": data["elementCount"]}
def tp(prefix: str, n: int, prio: str, title: str, check: str, spec: str) -> str:
return f"- **{prefix}_{n:03d}**({prio})|{title}|验证要点:{check}|关联需求:{spec}"
def none_merged(reason: str) -> list[str]:
"""本功能点无独立该类场景(验证类型仍在范围内)。"""
return [f"- 无(已并入:{reason})"]
def scoped_empty(reason: str) -> list[str]:
"""用户范围明确排除时才用。"""
return [f"- 无(范围排除:{reason})"]
def section(title: str, blocks: list[tuple[str, list[str]]]) -> str:
parts = [f"### {title}", ""]
for h4, lines in blocks:
parts.append(f"#### {h4}")
parts.append("")
parts.extend(
lines
or none_merged("本功能点无独立该类场景,相关规则已归入同页其它功能点")
)
parts.append("")
return "\n".join(parts)
def page_has_advanced_drawer(page_key: str) -> bool:
path = LOCATORS_DIR / f"{page_key}.locator.yaml"
if not path.exists():
return False
return "高级筛选" in path.read_text(encoding="utf-8")
def std_filter_section(seq: Seq, page_key: str) -> str:
return filter_section(seq, advanced=page_has_advanced_drawer(page_key))
def toolbar_caps(page_key: str) -> dict[str, bool]:
path = LOCATORS_DIR / f"{page_key}.locator.yaml"
if not path.exists():
return {"export": False, "print": False, "import_excel": False}
text = path.read_text(encoding="utf-8")
return {
"export": 'name: "导 出"' in text,
"print": 'name: "打 印"' in text,
"import_excel": "Excel导入" in text or "导入excel" in text,
}
class Seq:
def __init__(self, prefix: str, spec: str):
self.prefix = prefix
self.spec = spec
self.n = 0
def add(self, prio: str, title: str, check: str) -> str:
self.n += 1
return tp(self.prefix, self.n, prio, title, check, self.spec)
def nav_section(p: dict, seq: Seq) -> str:
name = p["name"]
path = p["path"]
return section(
"页面访问与导航",
[
(
"功能验证点",
[
seq.add(
"高",
"页面主区加载完成",
f"携带有效登录态进入 `{path}`(菜单或直链),{p['ready']};左侧菜单与顶栏 Tab 高亮为{name},不误入同组其它页",
),
seq.add(
"中",
"同组科室管理菜单可切换",
f"在 scope 所列科室管理 12 个子菜单中切换 2~3 个后 URL 与内容对应,再返回{name}状态正确",
),
],
),
(
"边界验证点",
[
seq.add(
"低",
"已登录直接打开 URL",
f"携带有效登录态打开 `{path}`,筛选区与列表(或主从分栏)可见,无报错遮罩,地址仍为该路径",
)
],
),
(
"异常验证点",
[
"- 无(未登录/会话过期/无菜单权限引用用户管理 `TP_USER_USR_005`~`007`;本模块自动化不单独写「菜单进入」「未登录访问拦截」用例,见 ui-test-point-rules 3.5)",
],
),
(
"集成验证点",
[
seq.add(
"中",
"顶栏院区与筛选默认一致",
"顶栏当前院区与页面院区名称/科室树默认范围不矛盾(允许按权限裁剪)",
),
],
),
],
)
def quick_filter_section(
seq: Seq,
*,
extra: list[tuple[str, str, str]] | None = None,
include_visible_combo: bool = True,
) -> str:
func: list[str] = [
seq.add("高", "默认条件查询", "点「查询」后列表有数据或空态明确,「共 N 条」与查询结果一致"),
seq.add("中", "重置恢复默认", "填写筛选后点「重置」,条件恢复默认并按产品规则刷新列表"),
]
if include_visible_combo:
func.append(
seq.add(
"高",
"全量可见条件组合查询",
"先默认查询,从首行读取有值字段;快捷区可见非空条件全部填入后查询,抽样首行同时满足全部已填条件",
)
)
if extra:
for prio, title, check in extra:
func.append(seq.add(prio, title, check))
return section(
"快捷筛选查询",
[
("功能验证点", func),
(
"边界验证点",
[
seq.add("中", "超长文本关键字", "名称/编号类字段输入较长关键字,查询被接受或截断,页面不崩溃"),
seq.add("低", "仅空格关键字", "按空查询或提示,不报错"),
],
),
(
"异常验证点",
[seq.add("中", "查询接口失败", "失败有错误提示,不白屏")],
),
(
"集成验证点",
[seq.add("中", "筛选结果与分页总数一致", "查询后「共 N 条」与本次查询匹配条数一致")],
),
],
)
def advanced_filter_section(seq: Seq) -> str:
return section(
"高级筛选抽屉",
[
(
"功能验证点",
[
seq.add("高", "点击高级筛选打开抽屉", "标题为「高级筛选」;关键字段可交互"),
seq.add("中", "抽屉内重置恢复默认", "抽屉字段恢复默认,快捷区不被清空"),
seq.add(
"高",
"全量查询条件组合查询",
"快捷区 +(有则)维度勾选 + 抽屉全填样本非空字段;抽样首行同时满足全部已填条件",
),
],
),
("边界验证点", none_merged("高级筛选字段边界见快捷区同类规则")),
("异常验证点", none_merged("高级筛选接口失败归入查询接口失败")),
(
"集成验证点",
[
seq.add(
"中",
"高级筛选带入",
"快捷区同名字段带入抽屉且不手改,与快捷区一致(可选,见 rules 3.3)",
),
],
),
],
)
def filter_section(seq: Seq, *, advanced: bool = True, extra: list[tuple[str, str, str]] | None = None) -> str:
parts = [quick_filter_section(seq, extra=extra, include_visible_combo=not advanced)]
if advanced:
parts.append(advanced_filter_section(seq))
return "\n---\n\n".join(parts)
def filter_collapse_section(seq: Seq) -> str:
return section(
"查询条件展开收起",
[
(
"功能验证点",
[
seq.add("中", "展开后更多条件可见", "额外字段进入可视区且可填写、可参与查询;按钮变为「收起」"),
seq.add(
"中",
"收起后额外条件隐藏且已填值保留",
"折叠行隐藏;已填值不丢失;再展开后值仍在。产品未定则标待评审",
),
],
),
(
"边界验证点",
[seq.add("低", "展开后无额外字段", "若产品无第二行筛选,展开为空或禁用,页面不报错")],
),
(
"异常验证点",
[seq.add("低", "展开态查询失败", "展开后查询失败有提示,不误展示其它页缓存列表")],
),
(
"集成验证点",
[seq.add("中", "展开字段与扫描一致", "展开后可见字段与 `data/scan` 记录一致,不以邻页筛选字段代替")],
),
],
)
def status_tab_section(seq: Seq, tab_labels: str | None = None) -> str:
labels = tab_labels or "全部/待提交/执行中等"
return section(
"状态 Tab",
[
(
"功能验证点",
[
seq.add(
"高",
"状态 Tab 与列表一致",
f"切换 {labels} Tab/分段后,列表状态列与 Tab 语义一致,Tab 计数与列表条数规则一致(以现网为准)",
),
],
),
("边界验证点", none_merged("Tab 边界见列表空态")),
("异常验证点", none_merged("Tab 切换失败归入列表加载失败")),
("集成验证点", none_merged("Tab 与筛选组合见快捷筛选集成点")),
],
)
def list_table_section(
seq: Seq,
columns: str,
row_ops: str,
*,
page_key: str = "",
include_row_action: bool = True,
) -> str:
func: list[str] = [seq.add("高", "列表核心列完整", f"主表展示 {columns}")]
if page_key:
caps = list_table_caps(page_key)
if caps["sortable"]:
func.append(
seq.add(
"中",
"各可排序列升序降序",
"每个带排序器的列均可升序、再降序;指示器(▲/▼ 或 aria-sort)与方向一致;列表仍有数据",
)
)
if caps["column_settings"]:
func.append(
seq.add(
"低",
"列设置隐藏后再恢复",
"打开列设置 → 取消勾选非核心列 → 表头隐藏且勾选同步 → 再勾选后表头恢复",
)
)
boundary_spec = [
("中", "无匹配空列表", "不存在的关键字查询后表头仍在,0 行或空态,有汇总时不展示过期汇总"),
]
integration_spec: list[tuple[str, str, str]] = []
if include_row_action:
integration_spec.append(
(
"高",
"行内操作可达",
f"首行存在数据时,行操作 {row_ops} 可点击并进入预期弹窗/详情(只读冒烟)。一对多:每个行内操作可拆一条用例",
)
)
def add_all(specs: list[tuple[str, str, str]]) -> list[str]:
return [seq.add(*item) for item in specs]
return section(
"列表",
[
("功能验证点", func),
("边界验证点", add_all(boundary_spec)),
("异常验证点", none_merged("列表加载失败见查询接口失败")),
("集成验证点", add_all(integration_spec) if integration_spec else none_merged("行内操作见集成点")),
],
)
def pagination_section(seq: Seq) -> str:
func_spec = [
("中", "分页翻页", "总条数大于一页时可翻至第 2 页,首行数据更换且页码高亮正确"),
("中", "每页条数切换", "由默认档位(常见50条/页)切换至20条/页;共N条不变;当前页行数=min(N,20)且与切换前不同(见 ui-test-point-rules 3.7)"),
("中", "跳至指定页", "输入合法页码后跳到对应页"),
]
boundary_spec = [
("中", "首页上一页不可用", "第 1 页时「上一页」禁用或无效"),
("中", "末页下一页不可用", "最后一页时「下一页」禁用或无效"),
("低", "跳至超出总页数", "超出范围有校验或纠正"),
]
exception_spec = [("低", "跳至非数字页码", "非法输入不跳转异常页")]
integration_spec = [("中", "翻页不丢失筛选", "带筛选翻页后条件保持,新页仍满足原筛选")]
def add_all(specs: list[tuple[str, str, str]]) -> list[str]:
return [seq.add(*item) for item in specs]
return section(
"分页",
[
("功能验证点", add_all(func_spec)),
("边界验证点", add_all(boundary_spec)),
("异常验证点", add_all(exception_spec)),
("集成验证点", add_all(integration_spec)),
],
)
def list_section(
seq: Seq,
columns: str,
row_ops: str,
*,
page_key: str = "",
has_tabs: bool = False,
tab_labels: str | None = None,
include_row_action: bool = True,
) -> str:
parts: list[str] = []
if has_tabs:
parts.append(status_tab_section(seq, tab_labels))
parts.append(
list_table_section(
seq, columns, row_ops, page_key=page_key, include_row_action=include_row_action
)
)
parts.append(pagination_section(seq))
return "\n---\n\n".join(parts)
def tree_section(seq: Seq, tree_label: str = "科室") -> str:
return section(
f"左侧{tree_label}树",
[
(
"功能验证点",
[
seq.add("高", "树默认有选中或可展开节点", f"进入后左侧{tree_label}树可见,选中或展开后右侧数据范围与节点一致"),
seq.add("高", "树搜索过滤", f"在树顶搜索框输入编号/名称关键字,节点列表收敛为匹配项"),
seq.add("高", "切换树节点刷新右侧", "点选另一节点后,右侧列表/表单数据范围切换,不残留上一节点数据"),
],
),
(
"边界验证点",
[seq.add("低", "搜索无匹配", "无匹配节点时空态或提示,右侧不展示过期数据")],
),
(
"异常验证点",
[seq.add("中", "树加载失败", "失败有提示,不把其它节点数据展示为当前节点成功态")],
),
(
"集成验证点",
[seq.add("中", "树节点与院区主数据一致", "节点名称/编码与科室登记等主数据一致(允许权限裁剪)")],
),
],
)
def export_section(seq: Seq, *, page_key: str) -> str | None:
if not toolbar_caps(page_key)["export"]:
return None
def add_all(specs: list[tuple[str, str, str]]) -> list[str]:
return [seq.add(*item) for item in specs]
return section(
"导出",
[
("功能验证点", [seq.add("中", "导出入口可触发", "点「导出」后无未捕获异常(默认不校验文件内容,见 scope)")]),
("边界验证点", add_all([("低", "无数据时导出", "空列表导出不报错或有明确提示")])),
("异常验证点", none_merged("导出失败见写操作失败提示")),
("集成验证点", none_merged("导入模板与导出关系见 Excel 导入点")),
],
)
def print_section(seq: Seq, *, page_key: str) -> str | None:
if not toolbar_caps(page_key)["print"]:
return None
return section(
"打印",
[
("功能验证点", [seq.add("中", "打印入口可触发", "点「打印」可拉起打印/预览/toast,无 error")]),
("边界验证点", none_merged("无数据打印以现网为准")),
("异常验证点", none_merged("打印失败见写操作失败提示")),
("集成验证点", none_merged("批量打印见打印 H3")),
],
)
def import_batch_toolbar_section(seq: Seq, tools: str, *, page_key: str) -> str | None:
caps = toolbar_caps(page_key)
if not caps["import_excel"] and "批量" not in tools and "全部调整" not in tools:
return None
func_spec: list[tuple[str, str, str]] = [
("高", "工具栏写操作入口可见", f"工具栏展示 {tools}(以现网按钮文案为准,不含导出/打印)"),
]
if caps["import_excel"]:
func_spec.append(("中", "Excel 导入入口可触发", "点「Excel导入」/「导入excel」可打开导入流程,无未捕获异常"))
integration_spec: list[tuple[str, str, str]] = []
if caps["import_excel"]:
integration_spec.append(("低", "导入模板说明可读", "导入入口旁提示或模板说明可访问(不校验 Excel 字段级)"))
def add_all(specs: list[tuple[str, str, str]]) -> list[str]:
return [seq.add(*item) for item in specs]
return section(
"Excel 导入与批量写操作",
[
("功能验证点", add_all(func_spec)),
("边界验证点", none_merged("未勾选批量见批量页")),
(
"异常验证点",
[seq.add("中", "写操作失败提示", "新增/导入/批量修改等失败时有明确错误信息,列表不误增")],
),
("集成验证点", add_all(integration_spec) if integration_spec else none_merged("无导入集成点")),
],
)
def export_print_import_chunks(seq: Seq, tools: str, *, page_key: str) -> list[str]:
chunks: list[str] = []
exp = export_section(seq, page_key=page_key)
if exp:
chunks.append(exp)
prt = print_section(seq, page_key=page_key)
if prt:
chunks.append(prt)
imp = import_batch_toolbar_section(seq, tools, page_key=page_key)
if imp:
chunks.append(imp)
return chunks
def config_section(seq: Seq) -> str:
return section(
"科室参数配置",
[
(
"功能验证点",
[
seq.add(
"高",
"配置入口跳转",
"列表行点「配置」进入科室参数页(如 operation/dept-param),面包屑/标题表明参数配置而非登记列表",
),
seq.add("高", "参数页与科室关联", "参数页展示的科室编码/名称与来源登记行一致"),
seq.add("中", "参数修改保存冒烟", "修改一项可保存参数或返回登记列表后再次进入配置仍为最新值(以现网字段为准)"),
],
),
(
"边界验证点",
[seq.add("低", "无修改直接返回", "打开配置未改直接返回,登记列表该行无变化")],
),
(
"异常验证点",
[seq.add("中", "参数保存失败", "保存失败有提示,参数页不展示成功态")],
),
(
"集成验证点",
[
seq.add(
"中",
"参数与业务页一致",
"参数页关键开关/阈值在相关科室业务页行为一致(允许异步生效)",
)
],
),
],
)
def dialog_section(seq: Seq, action: str) -> str:
return section(
f"{action}弹窗/抽屉",
[
(
"功能验证点",
[
seq.add("高", f"打开{action}层", f"触发{action}后弹窗/抽屉可见,含取消/确定或关闭"),
seq.add("中", "取消不落库", "填写后取消或关闭,列表不新增/不修改对应记录"),
seq.add("中", "必填为空拦截", "必填项为空时确定被拦截并提示,列表不刷新为成功态"),
],
),
(
"边界验证点",
[seq.add("低", "重复打开", "连续两次打开不叠加多层遮罩")],
),
(
"异常验证点",
[seq.add("中", "保存失败", "接口失败时弹窗不自动关闭且列表保持原状")],
),
(
"集成验证点",
[seq.add("低", "弹窗字段与列表列一致", "弹窗内关键字段与列表展示字段命名一致")],
),
],
)
def body_for(p: dict, seq: Seq) -> str:
k = p["pageKey"]
chunks: list[str] = []
if k == "deptm-register":
chunks += [
std_filter_section(seq, k),
list_section(
seq,
"序号、院区、科室编码、科室名称、科室类型、特殊科室类别、是否打包科室、科室地址、HIS映射科室、操作(编辑/配置)",
"编辑、配置",
page_key=k,
),
dialog_section(seq, "新增"),
dialog_section(seq, "编辑"),
config_section(seq),
]
elif k == "deptm-category":
chunks += [
section(
"左侧分类树",
[
(
"功能验证点",
[
seq.add("高", "分类树搜索过滤", "左侧分类名称搜索框可过滤树节点,可见节点均匹配关键字"),
seq.add("高", "新增分类入口", "「+ 新增分类」或同类入口可打开维护分类"),
seq.add("中", "修改分类名称", "行内编辑图标可修改当前分类名称"),
],
),
(
"边界验证点",
[seq.add("低", "分类搜索无匹配", "无匹配分类时空态或提示,右侧不展示过期科室")],
),
(
"异常验证点",
[seq.add("中", "分类树加载失败", "失败有提示,不把其它分类数据展示为当前成功态")],
),
(
"集成验证点",
[
seq.add(
"中",
"分类与科室登记一致",
"分类名称与科室登记「科室分类名称」列可对应(允许权限裁剪)",
)
],
),
],
),
std_filter_section(seq, k),
list_section(
seq,
"序号、院区、科室编码、科室名称、状态、科室类型、科室地址、科室分类名称、操作(编辑/日志)",
"编辑、日志",
page_key=k,
),
*export_print_import_chunks(seq, "添加", page_key=k),
]
elif k == "deptm-product-alloc":
chunks += [
tree_section(seq),
std_filter_section(seq, k),
*export_print_import_chunks(seq, "新增、批量修改、Excel导入", page_key=k),
list_section(
seq,
"规格型号、器械注册人、默认销售商、单位、字典价、耗材分类、状态等(以现网列为准)、操作(详情/编辑)",
"详情、编辑",
page_key=k,
),
dialog_section(seq, "新增"),
]
elif k == "deptm-zone-product":
chunks += [
tree_section(seq),
std_filter_section(seq, k),
*export_print_import_chunks(seq, "新增、批量修改、Excel导入", page_key=k),
list_section(
seq,
"规格型号、耗材类型、默认上架货位、库存上下限、物料编号、操作(编辑)",
"编辑",
page_key=k,
),
]
elif k == "deptm-follow-limit":
chunks += [
std_filter_section(seq, k),
list_section(
seq,
"科室编号、科室名称、是否周期性、限额任务名称、计划起止、实际执行/终止、任务数量、操作(详情/上传/导出)",
"详情、上传、导出",
has_tabs=True,
page_key=k,
),
dialog_section(seq, "新增"),
]
elif k == "deptm-req-template":
chunks += [
section(
"左侧模板目录",
[
(
"功能验证点",
[
seq.add("高", "模板列表与院区/库房筛选", "左侧展示模板名称列表,院区/中心库下拉切换后模板列表刷新"),
seq.add("高", "切换模板刷新右侧", "点选另一模板后右侧明细刷新,不串上一模板数据"),
seq.add("高", "新增模板入口可见", "「+ 新增模板」按钮可见可点;打开后的取消/必填不在本点展开"),
seq.add("中", "模板行编辑删除", "模板行编辑/删除图标可用且作用于当前模板"),
],
),
(
"边界验证点",
[seq.add("低", "模板目录无匹配", "院区/库房或名称无匹配时左侧空态,右侧不展示过期明细")],
),
(
"异常验证点",
[seq.add("中", "模板目录加载失败", "失败有提示,右侧不展示过期明细")],
),
(
"集成验证点",
[seq.add("中", "模板范围与院区库房一致", "切换院区/中心库后可见模板属于该范围")],
),
],
),
std_filter_section(seq, k),
*export_print_import_chunks(seq, "添加产品、导入excel", page_key=k),
list_section(
seq,
"规格型号、器械注册人、单元含量、耗材类型、库存数量、操作(编辑/移除)",
"编辑、移除",
page_key=k,
),
]
elif k == "deptm-auto-req":
chunks += [
std_filter_section(seq, k),
list_section(
seq,
"院区、科室编码、科室名称、优先级、排程类型(星期/天数)、排程间隔、是否启用、操作(编辑/删除)",
"编辑、删除",
page_key=k,
),
section(
"排程类型展示",
[
(
"功能验证点",
[
seq.add("高", "星期型排程展示", "排程类型为「星期」时,行内展示周日~周六可选按钮组且状态可读"),
seq.add("中", "天数型排程展示", "类型为「天数」时,间隔为数值输入或展示,与保存值一致"),
],
),
(
"边界验证点",
[
seq.add(
"低",
"未选星期/天数默认值",
"新增排程未选星期或天数时保存被拦截或展示默认值(以现网为准)",
)
],
),
(
"异常验证点",
none_merged("排程展示只读,保存失败见新增/编辑弹窗"),
),
(
"集成验证点",
[
seq.add(
"中",
"与送货计划页语义区分",
"本页 URL 为 `/schedule`,勿与「科室送货计划」`/deptPlan` 菜单/URL 混淆",
)
],
),
],
),
dialog_section(seq, "新增"),
]
elif k == "deptm-staff":
chunks += [
tree_section(seq, "科室目录"),
section(
"人员筛选与行操作",
[
(
"功能验证点",
[
seq.add("高", "人员名称搜索", "右上人员名称搜索后列表账号/姓名匹配"),
seq.add("中", "科室负责人勾选", "勾选「是否为科室负责人」后状态保存或与权限规则一致(以现网为准)"),
seq.add("高", "重置密码入口", "点「重置密码」有确认或成功提示,不误改其它行"),
],
),
(
"边界验证点",
[seq.add("低", "空职务展示", "无职务时显示 `--` 或空")],
),
(
"异常验证点",
[seq.add("中", "重置失败提示", "重置密码失败有明确提示")],
),
(
"集成验证点",
[
seq.add(
"高",
"人员与用户管理同源",
"账号/姓名与同院用户管理(`TP_USER_USR_*`)数据一致(允许权限裁剪)",
),
],
),
],
),
list_section(
seq,
"账号、人员名称、联系电话、职务、是否为科室负责人、操作(重置密码)",
"重置密码",
include_row_action=False,
page_key=k,
),
]
elif k == "deptm-minmax":
chunks += [
std_filter_section(seq, k),
*export_print_import_chunks(seq, "全部调整", page_key=k),
list_section(
seq,
"物料编号/名称、规格、建议上下限、当前上下限、状态(成功/失败)、操作(调整/编辑)",
"调整、编辑",
page_key=k,
),
]
elif k == "deptm-budget":
chunks += [
std_filter_section(seq, k),
list_section(
seq,
"预算任务名称、院区、科室、状态、制单人、预算金额、已使用金额、预算进度、操作(详情/关闭/编辑/删除)",
"详情、关闭、编辑、删除",
has_tabs=True,
page_key=k,
),
dialog_section(seq, "新增"),
]
elif k == "deptm-lab-machine":
chunks += [
tree_section(seq),
std_filter_section(seq, k),
filter_collapse_section(seq),
list_section(
seq,
"机器ID、机器编号、机器名称、备注、操作(编辑/删除)",
"编辑、删除",
page_key=k,
),
dialog_section(seq, "新增"),
]
elif k == "deptm-delivery":
chunks += [
section(
"左侧送货计划列表",
[
(
"功能验证点",
[
seq.add("高", "计划列表搜索", "按计划名称/科室/库区关键字搜索左侧列表"),
seq.add("高", "新增计划入口可见", "「+ 新增计划」按钮可见可点;打开后的取消/必填见「新增计划弹窗/抽屉」"),
seq.add("高", "切换计划刷新右侧", "点选不同计划后右侧排程详情与计划一致,不串上一计划"),
seq.add("中", "计划行编辑删除", "计划行编辑/删除可用"),
],
),
(
"边界验证点",
[seq.add("低", "搜索无匹配计划", "关键字无匹配时左侧空态,右侧不误展示其它计划详情")],
),
(
"异常验证点",
[seq.add("中", "计划列表加载失败", "失败有提示,右侧不展示过期详情")],
),
(
"集成验证点",
[seq.add("中", "计划与自动请领排程语义区分", "本页为送货计划主从维护,勿与「自动请领排程」页 URL/菜单混淆")],
),
],
),
section(
"右侧排程计划详情",
[
(
"功能验证点",
[
seq.add("高", "排程信息只读展示", "选中计划后展示排程类型、间隔、时间要求、耗材类型勾选、计划开始日期"),
seq.add("高", "耗材类型勾选展示", "高值/低值/检验等勾选状态与保存配置一致"),
seq.add("高", "添加科室库区入口", "「添加科室库区」按钮可见可点;打开后的取消/必填见「添加科室库区弹窗/抽屉」"),
seq.add("中", "科室库区子表列完整", "子表含科室编号/名称、库房名称、库区名称、库区地址、操作"),
seq.add("中", "子表空态", "未添加科室库区时展示「暂无数据」等空态,不报错"),
],
),
(
"边界验证点",
[seq.add("低", "单计划无子表数据", "仅有排程头信息、无库区行时,添加按钮仍可用")],
),
(
"异常验证点",
[seq.add("中", "添加科室库区失败", "保存失败有提示,子表不误增行")],
),
(
"集成验证点",
[
seq.add(
"高",
"库区与科室库区产品设置一致",
"子表库区名称/地址与同科室在「科室库区产品设置」可选项一致(允许权限裁剪)",
),
],
),
],
),
dialog_section(seq, "新增计划"),
dialog_section(seq, "添加科室库区"),
]
return "\n---\n\n".join(chunks)
def quality(p: dict, tp_count: int, el_count: int) -> str:
tab_note = (
"状态 Tab 计数与列表条数规则以现网为准"
if "状态 Tab" in p["cover"]
else "本页无状态 Tab"
)
return f"""## 质量自检摘要
| 项 | 结论 |
|----|------|
| 范围 | 在范围内:{p['cover']}。排除:断网/弱网、导出文件内容、Excel 字段级导入、弹窗全字段、登录/改密通用流程(见 `{SCOPE}`)。未声称 100% 全覆盖。 |
| 覆盖 | 已写功能域见正文 H3;共 **{tp_count}** 条原子测试点 |
| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 |
| 去重 | 登录态过期、无菜单权限、改密通用流程引用用户管理 `TP_USER_USR_006`/`TP_USER_USR_007`,本页不展开;顶栏院区为本页业务点。编号前缀 `{p['prefix']}` |
| 待评审项 | 弹窗内全字段以补扫为准;写操作冒烟不提交脏数据;{tab_note} |
| 报表能力 | 整页渲染,非统计报表 iframe,不适用 ui-test-point-rules 第四节 |
| 下游 | 可转 testcases/MD、CSV;扫描 `npm run discover:scan-deptm`;测试点 `npm run generate:testpoints-deptm` |
"""
def render_testpoint(p: dict, scan: dict) -> tuple[str, int]:
spec = f"UI-SPEC-SPD-{p['specId']}"
seq = Seq(p["prefix"], spec)
md = f"""# 基础管理-科室管理-{p['name']} — 测试点分析
**版本**:01
**定稿日期**:{TODAY}
**关联需求**:`knowledge-base/08-UI专项规范库/{spec}.md`
**页面 URL**:`{scan['url']}`
**元素扫描**:`data/scan/{p['pageKey']}-elements.json`({scan['elementCount']} 项)
**定位器**:`locators/spd/{p['pageKey']}.locator.yaml`
**本次生成范围**:`{SCOPE}`
---
## 基础设置 · 科室管理 · {p['name']}
{nav_section(p, seq)}
---
{body_for(p, seq)}
---
{quality(p, seq.n, scan['elementCount'])}
"""
return md, seq.n
def patch_ui_spec(p: dict, tp_file: str) -> None:
path = KB / f"UI-SPEC-SPD-{p['specId']}.md"
text = path.read_text(encoding="utf-8")
text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1)
if 'related_tc: ""' in text:
text = text.replace('related_tc: ""', f'related_tc: "testpoint/{tp_file}"', 1)
else:
text = re.sub(r'related_tc: "[^"]*"', f'related_tc: "testpoint/{tp_file}"', text, count=1)
path.write_text(text, encoding="utf-8")
def patch_index(counts: dict[str, int], scans: dict[str, dict]) -> None:
text = INDEX.read_text(encoding="utf-8")
text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1)
for p in PAGES:
sid = p["specId"]
el = scans[sid]["elementCount"]
tp_n = counts[sid]
prefix = p["prefix"].replace("TP_", "`TP_") + "`"
row = (
f"| {p['name']} | {sid} | `{p['path']}` | "
f"已扫描 · `{p['pageKey']}` · **{el}** 项 · 测试点 **{tp_n}** 条 · {prefix} |"
)
text = re.sub(
rf"\| {re.escape(p['name'])} \| {sid} \| `{re.escape(p['path'])}` \| .+ \|",
row,
text,
count=1,
)
if "generate:testpoints-deptm" not in text:
text = text.replace(
"npm run discover:yaml-from-json-deptm\n",
"npm run discover:yaml-from-json-deptm\nnpm run generate:testpoints-deptm\n",
)
INDEX.write_text(text, encoding="utf-8")
def main() -> None:
filter_keys = set(sys.argv[1:])
pages = [p for p in PAGES if not filter_keys or p["pageKey"] in filter_keys]
if not pages:
raise SystemExit("No pages matched. Available: " + ", ".join(p["pageKey"] for p in PAGES))
OUT_DIR.mkdir(parents=True, exist_ok=True)
counts: dict[str, int] = {}
scans: dict[str, dict] = {}
for p in pages:
scan = load_scan(p["pageKey"])
md, n = render_testpoint(p, scan)
out = OUT_DIR / p["file"]
out.write_text(md, encoding="utf-8")
patch_ui_spec(p, p["file"])
counts[p["specId"]] = n
scans[p["specId"]] = scan
print(f"OK {p['specId']}\t{p['name']}\t{scan['elementCount']} el\t{n} TP\t{out.name}")
for p in PAGES:
if p["specId"] not in scans:
scans[p["specId"]] = load_scan(p["pageKey"])
if p["specId"] not in counts:
tp_file = OUT_DIR / p["file"]
counts[p["specId"]] = len(
re.findall(r"\*\*TP_[A-Z0-9_]+_\d{3}\*\*", tp_file.read_text(encoding="utf-8"))
) if tp_file.exists() else 0
patch_index(counts, scans)
print("done", sum(counts.values()), "TP total")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""生成基础管理-接口管理 16 页结构化 MD(testcases/)与同源 CSV(testcase/)。
约束:H3/用例标题/绑定的 TP 名须符合 `.cursor/rules/ui-test-point-rules.mdc` 第三节;组合查询步骤对齐金标准 STAT-IOD-004。
"""
from __future__ import annotations
import csv
import importlib.util
import re
import sys
from collections import Counter, defaultdict
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOCATORS_DIR = ROOT / "locators" / "spd"
sys.path.insert(0, str(ROOT / "scripts"))
from spd_list_table_caps import list_table_caps, sortable_column_labels # noqa: E402
TP_DIR = ROOT / "testpoint"
MD_DIR = ROOT / "testcases"
CSV_DIR = ROOT / "testcase"
TODAY = date.today().isoformat()
POSITIVE_USER = "test_1"
MOCK = "__NOT_EXIST_IFACE__99999"
GROUP = "接口管理"
CSV_FIELDS = [
"用例编号",
"功能模块",
"功能测试点",
"用例标题",
"优先级",
"前置条件",
"测试步骤",
"预期结果",
"实际结果",
]
TP_LINE_RE = re.compile(
r"\*\*(TP_IFACE_[A-Z]+_\d{3})\*\*((高|中|低))|([^|]+)|验证要点:"
)
H3_RE = re.compile(r"^### (.+)$")
H4_RE = re.compile(r"^#### (.+)$")
PRIO_MD = {"高": "P0", "中": "P1", "低": "P2"}
_spec = importlib.util.spec_from_file_location(
"iface_tp", ROOT / "scripts" / "generate-iface-testpoints.py"
)
_iface = importlib.util.module_from_spec(_spec)
assert _spec.loader is not None
_spec.loader.exec_module(_iface)
IFACE_PAGES: list[dict] = _iface.PAGES
SHORT_CODE = {
"iface-his-price": "HPR",
"iface-dept-req-ok": "DRO",
"iface-subdept-req": "SDR",
"iface-low-billing": "LBL",
"iface-insurance": "INS",
"iface-vendor-code": "VNC",
"iface-peripheral": "PER",
"iface-ext-detail": "EXT",
"iface-platform-diff": "PPD",
"iface-api-log": "LOG",
"iface-push-price": "PSH",
"iface-charge-code": "CHC",
"iface-cabinet-zone": "CZN",
"iface-mat-push": "MAT",
"iface-req-query": "REQ",
"iface-campus-code": "CMP",
}
def case(
seq: int,
h3: str,
h4: str,
prio: str,
title: str,
tp_title: str,
steps: list[str],
expect: str,
pre: str = "",
tp_hint: str = "",
) -> dict:
return {
"seq": seq,
"h3": h3,
"h4": h4,
"prio": prio,
"title": title,
"tp_title": tp_title,
"tp_hint": tp_hint,
"steps": steps,
"expect": expect,
"pre": pre,
"tp_id": "",
}
def load_tp_index(name: str) -> dict[str, list[dict]]:
path = TP_DIR / f"基础管理-{GROUP}-{name}-测试点分析-01.md"
text = path.read_text(encoding="utf-8")
index: dict[str, list[dict]] = defaultdict(list)
h3 = ""
h4 = ""
for line in text.splitlines():
m3 = H3_RE.match(line)
if m3:
h3 = m3.group(1).strip()
continue
m4 = H4_RE.match(line)
if m4:
h4 = m4.group(1).strip()
continue
mt = TP_LINE_RE.search(line)
if mt:
index[mt.group(3).strip()].append(
{
"id": mt.group(1),
"prio": mt.group(2),
"title": mt.group(3).strip(),
"h3": h3,
"h4": h4,
}
)
return index
def resolve_tp(index: dict[str, list[dict]], c: dict, label: str) -> dict:
title = c["tp_title"]
hits = index.get(title) or []
hint = c.get("tp_hint") or ""
if hint:
filtered = [h for h in hits if hint in h["h3"] or hint in h["title"]]
if filtered:
hits = filtered
if len(hits) == 1:
return hits[0]
raise SystemExit(
f"{label}: 无法唯一匹配测试点「{title}」hint={hint!r} hits={[h['id'] for h in hits]}"
)
def query_smoke(*, mock_field: str = "产品名称/编号关键字") -> list[dict]:
return [
case(
3,
"快捷筛选查询",
"功能验证点",
"P0",
"默认条件查询",
"默认条件查询",
[
"点击「查 询」或「查询」",
"断言列表有数据或明确空态,「共 N 条」与当前结果一致",
],
"默认查询无报错;总数与列表一致。",
),
]
def page_has_advanced_drawer(page_key: str) -> bool:
path = LOCATORS_DIR / f"{page_key}.locator.yaml"
if not path.exists():
return False
return "高级筛选" in path.read_text(encoding="utf-8")
def combo_query_smoke(*, advanced: bool = False) -> dict:
if advanced:
return case(
4,
"高级筛选抽屉",
"功能验证点",
"P0",
"全量查询条件组合查询",
"全量查询条件组合查询",
[
"默认查询 → 从首行提取全量样本(不写死业务数据,见 rules 3.4)",
"快捷区填入样本中有值的可见字段",
"高级筛选抽屉填入样本中所有非空字段(文本直接填、下拉选同值;禁用字段记录默认)",
"「查询/筛选」后抽样首行;首行同时满足样本中全部已填字段",
],
"查询正常无报错;查询条件写值正常;查询结果与查询条件一致。",
pre="默认查询至少 1 条,否则 skip",
)
return case(
4,
"快捷筛选查询",
"功能验证点",
"P0",
"全量可见条件组合查询",
"全量可见条件组合查询",
[
"默认查询 → 从首行提取全量样本(只填当前结果里有值的条件,见 rules 3.2)",
"快捷区可见非空条件全部填入后查询",
"抽样首行同时满足全部已填条件;无未捕获异常",
],
"查询正常无报错;查询结果与查询条件一致。",
pre="默认查询至少 1 条,否则 skip",
)
def query_with_combo(
*,
mock_field: str = "产品名称/编号关键字",
page_key: str = "",
advanced: bool | None = None,
) -> list[dict]:
adv = page_has_advanced_drawer(page_key) if advanced is None else advanced
return query_smoke(mock_field=mock_field) + [
combo_query_smoke(advanced=adv),
empty_list_smoke(mock_field=mock_field),
]
def empty_list_smoke(*, mock_field: str = "产品名称/编号关键字") -> dict:
return case(
4,
"列表",
"边界验证点",
"P1",
"无匹配空态(mock反例)",
"无匹配空列表",
[
f"{mock_field}填入 `{MOCK}` 后查询",
"断言表头仍在,0 行或空态,分页总数为 0",
],
"无匹配不报错;不展示过期成功态数据。",
)
def list_smoke(cols: str, row_ops: str, *, include_row: bool = True) -> list[dict]:
items = [
case(
5,
"列表",
"功能验证点",
"P0",
"列表核心列完整",
"列表核心列完整",
[
f"断言表头含 {cols}",
f"若有数据行且存在行操作,断言操作列含 {row_ops}" if include_row else "若有数据,抽样首行字段非空",
],
"核心列完整;列表结构正确。",
),
]
if include_row and row_ops not in ("—", "-", ""):
ops = [o.strip() for o in row_ops.replace("、", ",").split(",") if o.strip()]
first_op = ops[0]
tp_title = f"行内「{first_op}」可达" if len(ops) > 1 else "行内操作可达"
items.append(
case(
7,
"列表",
"集成验证点",
"P0",
f"行内「{first_op}」可达(只读冒烟)",
tp_title,
[
f"首行有数据时点击「{first_op}」",
"断言进入预期弹窗/抽屉/详情,可见取消或关闭",
"关闭弹层,列表仍可见",
],
"行内操作可达;关闭后回到列表。",
pre="列表至少 1 条",
tp_hint="行内操作" if len(ops) > 1 else "",
)
)
return items
def pagination_smoke() -> dict:
return case(
6,
"分页",
"功能验证点",
"P1",
"分页翻至第 2 页",
"分页翻页",
[
"记录第 1 页首行关键字段",
"点击页码「2」或「下一页」",
"断言页码高亮正确,首行数据与第 1 页不同",
],
"翻页成功;数据更换。",
pre="总条数大于一页,否则 skip",
)
def list_table_feature_cases(page_key: str) -> list[dict]:
caps = list_table_caps(page_key)
out: list[dict] = []
if caps["column_settings"]:
out.append(
case(
0,
"列表",
"功能验证点",
"P2",
"列设置隐藏后再恢复",
"列设置隐藏后再恢复",
[
"默认查询后点击表格工具栏列设置(齿轮)",
"取消勾选一列非核心字段并关闭面板,断言表头隐藏",
"再次打开列设置勾选恢复并关闭,断言表头恢复",
],
"勾选状态与表头一致。",
pre="列表表头已加载",
)
)
for col in sortable_column_labels(page_key):
out.append(
case(
0,
"列表",
"功能验证点",
"P1",
f"{col}列升序降序切换",
"各可排序列升序降序",
[
"默认查询后列表有数据",
f"横向滚动使「{col}」列表头可见(宽表时)",
"第一次点击该列排序控件并校验升序指示器",
"第二次点击并校验降序指示器",
],
"排序按钮可点击;升序与降序指示器分别激活;列表仍有数据。",
pre="至少 2 条数据,否则 skip",
)
)
return out
def list_and_pagination_smoke(
cols: str, row_ops: str, *, page_key: str = "", include_row: bool = True
) -> list[dict]:
items = list_smoke(cols, row_ops, include_row=include_row)
if page_key:
for i, extra in enumerate(list_table_feature_cases(page_key)):
items.insert(1 + i, extra)
return items + [pagination_smoke()]
def export_smoke(*, title: str = "导出入口可触发", h3: str = "导出") -> dict:
return case(
8,
h3,
"功能验证点",
"P1",
title,
title,
["点击「导 出」或「导出」或「导出明细」", "断言无未捕获异常(不校验文件内容)"],
"导出流程可启动。",
)
def dialog_smoke(action: str) -> list[dict]:
return [
case(
8,
f"{action}弹窗",
"功能验证点",
"P0",
f"打开{action}弹窗/抽屉",
f"打开{action}层",
[
f"点击「{action}」或等价入口",
"断言弹窗/抽屉可见,含「取 消」「确 定」或关闭",
],
f"{action}层打开;关键按钮可见。",
tp_hint=action,
),
case(
9,
f"{action}弹窗",
"异常验证点",
"P0",
"必填为空拦截",
"必填为空拦截",
[
f"打开{action},必填项留空后点击「确 定」",
"断言出现必填提示,弹窗仍打开,列表不刷新为成功态",
],
"确定被拦截;不落库。",
tp_hint=action,
),
]
def scjs_frame_steps(extra: list[str] | None = None) -> list[str]:
base = [
"获取 scjs 业务 frame(`page.frameLocator('iframe[src*=\"scjs\"]')` 或 PO 封装)",
"在 frame 内执行后续操作,不在 SPD 壳层菜单上断言业务列表",
]
return base + (extra or [])
def build_cases(meta: dict) -> list[dict]:
path = meta["path"]
name = meta["name"]
pk = meta["pageKey"]
scjs = bool(meta.get("scjs"))
visible = meta.get("ready", "筛选与列表可见")[:80]
cases: list[dict] = []
if meta.get("layout") == "dept_tree":
cases += [
case(
3,
"左侧科室树",
"功能验证点",
"P0",
"科室树选中且右侧有范围",
"树默认有选中或可展开节点",
[
"断言左侧科室树可见且有选中或可展开节点",
"读取选中节点名称 `${sampleDeptNode}`",
"断言右侧明细/筛选与当前科室范围一致或为空态",
],
"树有选中;右侧不串其它科室数据。",
),
case(
4,
"请领生成与导入导出",
"功能验证点",
"P0",
"生成请领单入口可见",
"生成请领单",
[
"断言「生成请领单」按钮可见",
"无明细时点击应被拦截或提示(不断言成功落库)",
],
"生成入口可达;无脏数据提交。",
),
]
cases += query_with_combo(mock_field="产品编号/名称", page_key=pk)
cases += list_and_pagination_smoke(
"产品编号、名称、规格、请领数量等",
"—",
page_key=pk,
include_row=False,
)
return cases
if meta.get("layout") == "transfer":
cases += [
case(
3,
"双栏产品穿梭",
"功能验证点",
"P0",
"待选/已选栏与穿梭按钮可见",
"待选/已选列表可见",
[
"断言左右两栏列表与「加入」「移除」可见",
"若有待选行,点「加入」后该行出现在已选栏(抽样 1 行)",
"对已选行点「移除」,该行回到待选栏",
],
"穿梭交互正确;不执行「开始生成」成功落库。",
),
case(
4,
"双栏产品穿梭",
"功能验证点",
"P1",
"保存模版入口可打开",
"保存模版",
[
"点击「保存模版」",
"断言打开保存层,取消关闭,列表无异常",
],
"模版入口可达;取消不落库。",
),
]
return cases
frame_prefix = scjs_frame_steps() if scjs else []
if pk == "iface-his-price":
cases += query_with_combo(mock_field="物料编号/名称", page_key=pk)
cases += [
case(
5,
"快捷筛选查询",
"功能验证点",
"P0",
"仅查看价格差异 switch",
"仅查看价格差异明细",
[
"打开「仅查看价格差异明细」switch",
"点击查询,断言列表仅为有差异行或明确空态",
],
"switch 与列表差异过滤一致。",
),
case(
6,
"查询条件展开收起",
"功能验证点",
"P1",
"展开后更多条件可见",
"展开后更多条件可见",
[
"点击「展开」",
"断言第二行筛选字段可见",
"点击「收起」,折叠行隐藏",
],
"展开/收起交互正常。",
),
]
cases += list_and_pagination_smoke(
"物料编号、规格型号、HIS、SPD、差异等核心列",
"—",
page_key="iface-his-price",
include_row=False,
)
cases.append(export_smoke())
return cases
if pk == "iface-low-billing":
cases += query_with_combo(mock_field="科室/产品关键字", page_key=pk)
cases += [
case(
5,
"查询条件展开收起",
"功能验证点",
"P1",
"展开后更多条件可见",
"展开后更多条件可见",
[
"点击「展开」",
"断言额外筛选字段可见并可查询",
],
"展开后字段可参与查询。",
),
]
cases += list_and_pagination_smoke(
"科室、产品、计费/接口状态等",
"—",
page_key="iface-low-billing",
include_row=False,
)
cases.append(export_smoke())
return cases
if pk == "iface-insurance":
cases += [
case(
3,
"scjs 内嵌 iframe 业务区",
"功能验证点",
"P0",
"scjs 业务区加载",
"iframe 业务区加载完成",
scjs_frame_steps(["断言 frame 内查询按钮与列表可见"]),
"iframe 内业务区可交互。",
),
]
cases += query_with_combo(mock_field="iframe 内产品/医保码关键字", page_key=pk)
for c in cases[-2:]:
c["steps"] = frame_prefix + c["steps"]
cases += [
case(
5,
"状态 Tab",
"功能验证点",
"P0",
"状态 Tab 切换",
"状态 Tab 与列表一致",
frame_prefix
+ [
"依次点击「全部」「成功」「失败」「待核验」Tab(以现网为准)",
"每次切换后断言列表状态列与 Tab 语义一致或为空态",
],
"Tab 与列表一致;无报错。",
),
]
cases += list_and_pagination_smoke(
"医保码、产品、核对状态等",
"详情、通过",
page_key="iface-insurance",
)
return cases
if pk == "iface-push-price":
cases += [
case(
3,
"scjs 内嵌 iframe 业务区",
"功能验证点",
"P0",
"scjs 业务区加载",
"iframe 业务区加载完成",
scjs_frame_steps(["断言 frame 内 Tab 与查询区可见"]),
"iframe 内业务区可交互。",
),
]
cases += query_with_combo(page_key=pk)
for c in cases[-2:]:
c["steps"] = frame_prefix + c["steps"]
cases += [
case(
5,
"状态 Tab",
"功能验证点",
"P0",
"推送状态 Tab 切换",
"状态 Tab 与列表一致",
frame_prefix
+ [
"切换「全部」「待推送」「推送成功」「推送失败」「不处理」Tab",
"断言列表与 Tab 语义一致或空态",
],
"Tab 切换正常。",
),
case(
6,
"行内操作",
"功能验证点",
"P0",
"行内「推送」层打开后取消",
"行内「推送」可达",
frame_prefix
+ [
"首行点「推送」",
"断言出现确认/表单层,点击取消或关闭",
"列表行状态不误改为成功",
],
"推送入口可达;取消不落库。",
pre="列表至少 1 条",
),
]
cases += list_and_pagination_smoke(
"物价项目、推送状态等",
"推送、不处理",
page_key="iface-push-price",
include_row=False,
)
return cases
if pk == "iface-api-log":
cases += query_with_combo(mock_field="接口名称", page_key="iface-api-log")
cases += [
case(
5,
"高级筛选抽屉",
"功能验证点",
"P1",
"点击高级筛选打开抽屉",
"点击高级筛选打开抽屉",
[
"点击「高级筛选」",
"断言抽屉/面板打开,关键字段可交互",
"关闭抽屉,快捷区条件不被误清空(以现网为准)",
],
"高级筛选可打开。",
),
]
cases += list_and_pagination_smoke(
"接口名称、请求时间、状态、耗时等",
"查看",
page_key="iface-api-log",
)
cases.append(export_smoke(title="导出明细可触发"))
return cases
if pk == "iface-peripheral":
cases += query_with_combo(mock_field="产品名称", page_key=pk)
cases += [
case(
5,
"批量生成",
"功能验证点",
"P1",
"批量生成入口",
"批量生成入口",
[
"勾选首行(若有)",
"点击「批量生成」",
"断言有反馈或提示,不断言外围回写成功",
],
"批量入口可达。",
),
]
cases += list_and_pagination_smoke(
"变更类型、产品、外围字段等",
"详情",
page_key="iface-peripheral",
)
cases.append(export_smoke(title="导出变更明细"))
return cases
# default list pages
mock_field = "筛选关键字"
if meta.get("filter_extra"):
mock_field = "筛选区关键字"
cases += query_with_combo(mock_field=mock_field, page_key=pk)
cols = meta.get("columns", "主表核心列")
row_ops = meta.get("row_ops", "详情")
include_row = meta.get("row_action", True) and row_ops not in ("—", "-", "")
if meta.get("manual_add"):
cases += dialog_smoke(meta["manual_add"])[:1]
if scjs:
cases.insert(
2,
case(
3,
"scjs 内嵌 iframe 业务区",
"功能验证点",
"P0",
"scjs 业务区加载",
"iframe 业务区加载完成",
scjs_frame_steps(["断言 frame 内查询与列表可见"]),
"iframe 业务区可交互。",
tp_hint="scjs",
),
)
for c in cases[3:]:
if c["h3"] in ("快捷筛选查询", "列表"):
c["steps"] = frame_prefix + c["steps"]
if meta.get("filter_collapsed"):
cases.append(
case(
5,
"查询条件展开收起",
"功能验证点",
"P1",
"展开后更多条件可见",
"展开后更多条件可见",
(frame_prefix if scjs else [])
+ [
"点击「展开」",
"断言折叠行筛选字段可见",
"点击「收起」,折叠行隐藏",
],
"展开/收起交互正常。",
)
)
if meta.get("has_tabs") and pk not in ("iface-insurance", "iface-push-price"):
cases.append(
case(
5,
"状态 Tab",
"功能验证点",
"P0",
"状态 Tab 切换",
"状态 Tab 与列表一致",
(frame_prefix if scjs else [])
+ ["切换状态 Tab", "断言列表状态列与 Tab 语义一致(抽样)"],
"Tab 与列表一致。",
)
)
cases += list_and_pagination_smoke(cols, row_ops, page_key=pk, include_row=include_row)
caps = _iface.toolbar_caps(pk)
if caps["export"]:
cases.append(export_smoke())
if meta.get("manual_add") and pk not in ("iface-vendor-code", "iface-charge-code", "iface-campus-code", "iface-cabinet-zone"):
pass
elif meta.get("manual_add"):
cases.extend(dialog_smoke(meta["manual_add"])[1:2])
return cases
def page_doc(meta: dict) -> dict:
pk = meta["pageKey"]
short = SHORT_CODE[pk]
code = f"IFACE-{short}"
scjs = bool(meta.get("scjs"))
route = meta.get("scjs_route", "")
iframe_note = (
f"壳层 URL + scjs iframe(路由 `{route}`);业务断言在 frame 内。"
if scjs
else "整页渲染;定位在 `page` 上。"
)
write_note = (
"对码/推送/通过/生成类写操作:仅打开确认层后取消,不提交成功;导出不校验文件。"
if scjs or meta.get("manual_add") or meta.get("batch")
else "导出/查询为主;若有编辑仅打开弹窗后取消。"
)
if meta.get("layout") == "dept_tree":
write_note = "生成请领单、导入不做成功落库;仅测入口与校验。"
if meta.get("layout") == "transfer":
write_note = "「开始生成」不跑成功落库;穿梭与保存模版仅取消关闭。"
scan_path = ROOT / "data" / "scan" / f"{pk}-elements.json"
el_count = 0
if scan_path.exists():
import json
el_count = json.loads(scan_path.read_text(encoding="utf-8")).get("elementCount", 0)
return {
"code": code,
"group": GROUP,
"name": meta["name"],
"path": meta["path"],
"scan": pk,
"count": el_count,
"spec": f"UI-SPEC-SPD-{meta['specId']}",
"spec_file": f"{pk}.spec.ts",
"tp_total": 0,
"nav_note": (
f"菜单「基础设置 → {GROUP} → {meta['name']}」;"
f"`openPath('{meta['path']}')` 并断言 URL。"
),
"iframe_note": iframe_note,
"write_note": write_note,
"samples": f"查询后从首行读取 `${{sampleCode}}`/`${{sampleName}}`;mock:`{MOCK}`。",
"skip": "会话过期、无菜单权限、接口失败注入、导出文件内容、写操作成功落库、弹窗全字段",
"cases": build_cases(meta),
}
def bind_tps(p: dict) -> None:
index = load_tp_index(p["name"])
p["tp_total"] = sum(len(v) for v in index.values())
for c in p["cases"]:
rec = resolve_tp(index, c, p["code"])
c["tp_id"] = rec["id"]
c["prio"] = PRIO_MD[rec["prio"]]
c["h4"] = rec["h4"]
def renumber_cases(cases: list[dict]) -> None:
for i, c in enumerate(cases, start=1):
c["seq"] = i
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"具备「基础设置 → {GROUP} → {p['name']}」权限"
)
unlogin = "独立 context,storageState 为空"
rows: list[dict] = []
for c in p["cases"]:
case_id = f"{p['code']}-{c['seq']:03d}"
if is_unlogin(c):
pre_parts = [unlogin]
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, f"已进入{p['name']}页({p['path']})"]
if c["pre"]:
pre_parts.append(c["pre"])
steps = "\n".join(
[f"1. 确认已加载 storageState({POSITIVE_USER})"]
+ [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)]
)
if c["pre"]:
pre_parts = list(dict.fromkeys(pre_parts))
rows.append(
{
"用例编号": case_id,
"功能模块": f"基础设置 · {p['group']}",
"功能测试点": f"{p['name']} · {c['h3']}",
"用例标题": c["title"],
"优先级": {"P0": "高", "P1": "中", "P2": "低"}[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/基础管理-{GROUP}-{p['name']}-测试点分析-01.md` ",
f"**执行 CSV**:`testcase/基础管理-{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}` + `storageState`;具备「基础设置 → {GROUP} → {p['name']}」权限。 ",
f"**页面约定**:{p['iframe_note']} ",
"**隔离**:每用例独立 context(playwright-storage-state-isolation)。 ",
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}"
pre_txt = (
"独立 context,无登录态"
if is_unlogin(c)
else (c["pre"] or f"已进入{p['name']}页")
)
lines += [
f"##### 【{c['prio']}】【{tc}】【{c['tp_id']}】{c['title']}",
"",
f"- 前置条件:{pre_txt}",
"- 操作步骤:",
]
for i, step in enumerate(c["steps"], 1):
lines.append(f" {i}. {step}")
lines.append(
f"- 自动化断言:规划与 CSV `{case_id}` 及 `tests/spd/{p['spec_file']}` 一致"
)
lines += ["", "###### 预期结果", "", c["expect"], "", "---", ""]
while lines and lines[-1] in ("", "---"):
lines.pop()
lines += [
"",
"## 质量与覆盖说明",
"",
"| 项 | 说明 |",
"|----|------|",
f"| 用例数 | **{n}** 条({prio_txt}) |",
f"| 来源 | 从 {p['tp_total']} 条测试点选取可稳定自动化场景 |",
f"| 未纳入自动化 | {p['skip']} |",
"| 单一职责 | 每条只断言一个核心业务结果 |",
"",
]
return "\n".join(lines)
def main() -> None:
MD_DIR.mkdir(parents=True, exist_ok=True)
CSV_DIR.mkdir(parents=True, exist_ok=True)
total = 0
for meta in IFACE_PAGES:
p = page_doc(meta)
bind_tps(p)
renumber_cases(p["cases"])
sort_tp = "各可排序列升序降序"
ids = [c["tp_id"] for c in p["cases"] if c.get("tp_title") != sort_tp]
dup = [i for i in ids if ids.count(i) > 1]
if dup:
raise SystemExit(f"{p['code']} 多条用例共用 TP: {sorted(set(dup))}")
md_path = MD_DIR / f"基础管理-{GROUP}-{p['name']}-UI自动化测试用例-01.md"
csv_path = CSV_DIR / f"基础管理-{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}")
print(f"done pages={len(IFACE_PAGES)} cases={total}")
if __name__ == "__main__":
main()
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment