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

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

合入自动化代码、用例 CSV/MD/spec/PO 及登录态修复;不含 knowledge-base、.cursor、data/scan 等 temp 全量备份内容。
parent f6eec849
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorAuditPage } from './vendor-audit.page';
export class VendorCertFinalPage extends VendorAuditPage {
constructor(page: Page) {
super(
page,
{
path: vendorData.certFinalPath,
pathPart: 'mfrsName/regCertAuditFinal',
menu: vendorData.menuCertFinal,
forbidden: ['authAudit', 'factory', 'qcManage'],
},
'cert',
);
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '制单日期', kind: 'dateRange', value: '样本' },
{ label: '单号', kind: 'text', value: '样本' },
{ label: '变更类型', kind: 'select', value: '样本' },
{ label: '注册证号(新)', kind: 'text', value: '样本' },
{ label: '状态', kind: 'select', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorAuditPage } from './vendor-audit.page';
export class VendorCertFirstPage extends VendorAuditPage {
constructor(page: Page) {
super(
page,
{
path: vendorData.certFirstPath,
pathPart: 'mfrsName/regCertAudit',
menu: vendorData.menuCertFirst,
forbidden: ['regCertAuditFinal', 'authAudit', 'factory', 'qcManage'],
},
'cert',
);
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '制单日期', kind: 'dateRange', value: '样本' },
{ label: '单号', kind: 'text', value: '样本' },
{ label: '变更类型', kind: 'select', value: '样本' },
{ label: '注册证号(新)', kind: 'text', value: '样本' },
{ label: '状态', kind: 'select', value: '样本' },
{ label: '变更情况具体说明', kind: 'text', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorSharedPage } from './vendor-shared.page';
/** 评价指标维护:左树 + 右表。 */
export class VendorEvalIndicatorPage extends VendorSharedPage {
constructor(page: Page) {
super(page, {
path: vendorData.evalIndicatorPath,
pathPart: 'mfrsName/indicators',
menu: vendorData.menuEvalIndicator,
forbidden: ['evaluationPlan', 'holiday', 'factory'],
});
}
treeSearch(): ReturnType<Page['locator']> {
return this.page.locator('.ant-tree, .ant-directory-tree').first();
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
await expect(this.page.getByText('指标分类').first(), '指标分类').toBeVisible({ timeout: 15_000 });
await expect(this.button('查询').first(), '查询').toBeVisible();
}
async queryDefault(): Promise<void> {
await this.query();
await this.expectListOrEmpty();
}
async filterByIndicatorCode(): Promise<void> {
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('指标列表无数据');
return;
}
const codeCol = (await this.headerTexts()).some((t) => t.includes('指标编码')) ? '指标编码' : '指标编号';
const code = await this.sampleCell(codeCol);
const filterLabel = (await this.textInput('指标编码').isVisible().catch(() => false)) ? '指标编码' : '指标编号';
await this.fillFilter(filterLabel, code.slice(0, Math.min(code.length, 16)));
await this.query();
await this.expectRowsContain(codeCol, code.slice(0, 6));
}
async mockEmpty(): Promise<void> {
const filterLabel = (await this.textInput('指标编码').isVisible().catch(() => false)) ? '指标编码' : '指标编号';
if (!(await this.textInput(filterLabel).isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无指标编码/编号筛选框');
return;
}
await this.fillFilter(filterLabel, vendorData.mockOrder);
await this.query();
await this.expectMockEmpty();
}
async expectCoreColumns(): Promise<void> {
await this.query();
const headers = (await this.headerTexts()).join(' ');
expect(headers.includes('评价类型') && (headers.includes('指标编码') || headers.includes('指标编号')) && headers.includes('指标名称'), '核心列').toBeTruthy();
await this.expectHeaders(['评价类型', '指标名称', '状态', '操作']);
}
async openDetailAndBack(): Promise<void> {
await this.query();
const row = await this.rowWithAction('详情');
if (!row) await this.skipWhenDataInsufficient('无详情行');
await this.openDetailLayer(row!, '详情');
await this.leaveWithoutCommit();
await this.expectOnPage();
}
async clickFirstTreeNode(): Promise<void> {
const node = this.page.locator('.ant-tree-treenode, .ant-tree-node-content-wrapper').first();
if (!(await node.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('指标分类树为空');
return;
}
await this.click(node, '选中分类');
await this.waitForTableReady();
await this.expectListOrEmpty();
}
/** CSV VENDOR-EVI-008:分类下无指标空态(找空分类或接受当前空态)。 */
async expectEmptyCategoryOrSkip(): Promise<void> {
const nodes = this.page.locator('.ant-tree-treenode, .ant-tree-node-content-wrapper');
const count = Math.min(await nodes.count(), 12);
if (count === 0) {
await this.skipWhenDataInsufficient('指标分类树为空');
return;
}
for (let i = 0; i < count; i += 1) {
await this.click(nodes.nth(i), `分类${i + 1}`);
await this.waitForTableReady();
if ((await this.getDataRowCount()) === 0) {
await this.expectListOrEmpty();
return;
}
}
await this.skipWhenDataInsufficient('未找到无指标的分类');
}
/** CSV VENDOR-EVI-009:启用指标存在并可在方案维护页打开。 */
async expectEnabledUsableInScheme(schemePage: {
open: () => Promise<void>;
expectEntered: () => Promise<void>;
}): Promise<void> {
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('指标列表无数据');
return;
}
const headers = await this.headerTexts();
const statusIdx = headers.findIndex((t) => t.includes('状态'));
if (statusIdx >= 0) {
const rows = await this.listRows();
const limit = Math.min(await rows.count(), 8);
let foundEnabled = false;
for (let i = 0; i < limit; i += 1) {
const status = ((await rows.nth(i).locator('td').nth(statusIdx).innerText()) ?? '').replace(/\s/g, '');
if (/启用|有效|正常/.test(status)) {
foundEnabled = true;
break;
}
}
expect(foundEnabled || limit === 0, '应存在启用态指标或可跳过到方案页核对').toBeTruthy();
}
await schemePage.open();
await schemePage.expectEntered();
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '指标编码', kind: 'text', value: '样本' },
{ label: '指标名称', kind: 'text', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorSharedPage } from './vendor-shared.page';
/** 评价方案维护:筛选 + 新增打开后取消。 */
export class VendorEvalSchemePage extends VendorSharedPage {
constructor(page: Page) {
super(page, {
path: vendorData.evalSchemePath,
pathPart: 'mfrsName/evaluationPlan',
menu: vendorData.menuEvalScheme,
forbidden: ['indicators', 'holiday', 'factory'],
});
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
await expect(this.button('查询').first(), '查询').toBeVisible();
await expect(this.button('新增').first(), '新增').toBeVisible();
}
async queryDefault(): Promise<void> {
await this.query();
}
async filterBySchemeCode(): Promise<void> {
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('方案列表无数据');
return;
}
const code = await this.sampleCell('方案编码');
await this.fillFilter('方案编码', code.slice(0, Math.min(code.length, 16)));
await this.query();
}
async resetFilters(): Promise<void> {
const filled = await this.fillFirstVisibleFilter(['方案编码', '方案名称']);
await this.reset();
if (filled) await expect(this.textInput(filled)).toHaveValue('');
}
async mockEmpty(): Promise<void> {
if (await this.textInput('方案编码').isVisible().catch(() => false)) {
await this.fillFilter('方案编码', vendorData.mockOrder);
} else if (await this.textInput('方案名称').isVisible().catch(() => false)) {
await this.fillFilter('方案名称', vendorData.mockOrder);
} else {
await this.skipWhenDataInsufficient('无方案编码/名称筛选框');
return;
}
await this.query();
}
async expectCoreColumns(): Promise<void> {
await this.expectHeaders(['方案编码', '方案名称', '创建人', '创建时间', '状态', '操作']);
}
async openAddAndCancel(): Promise<void> {
const total = await this.getTotalCount();
await this.clickButton('新增');
await this.waitSettle(500);
const layer = this.locators.putPage().or(this.locators.dialog());
await expect(layer.first(), '新增层').toBeVisible({ timeout: 12_000 });
await this.leaveWithoutCommit();
expect(await this.getTotalCount(), '取消新增总数不变').toBe(total);
}
/** CSV VENDOR-EVS-008 */
async emptyRequiredBlocked(): Promise<void> {
await this.clickButton('新增');
await this.waitSettle(500);
const layer = this.locators.putPage().or(this.locators.dialog());
await expect(layer.first(), '新增层').toBeVisible({ timeout: 12_000 });
const submit = layer.getByRole('button', { name: /保\s*存|提\s*交|确\s*定/ }).first();
if (!(await submit.isVisible().catch(() => false))) {
await this.leaveWithoutCommit();
await this.skipWhenDataInsufficient('新增层无提交按钮');
return;
}
if (await submit.isDisabled().catch(() => false)) {
await this.leaveWithoutCommit();
return;
}
await this.click(submit, '空提交');
await this.expectBlockedAndStay();
await this.leaveWithoutCommit();
}
/** CSV VENDOR-EVS-009 */
async overlongSchemeName(): Promise<void> {
await this.clickButton('新增');
await this.waitSettle(500);
const layer = this.locators.putPage().or(this.locators.dialog());
await expect(layer.first(), '新增层').toBeVisible({ timeout: 12_000 });
const nameInput = layer
.getByRole('textbox', { name: /方案名称/ })
.or(layer.locator('.ant-form-item').filter({ hasText: '方案名称' }).locator('input'))
.first();
if (!(await nameInput.isVisible().catch(() => false))) {
await this.leaveWithoutCommit();
await this.skipWhenDataInsufficient('无方案名称输入框');
return;
}
await nameInput.fill('A'.repeat(220));
const submit = layer.getByRole('button', { name: /保\s*存|提\s*交|确\s*定/ }).first();
if (await submit.isVisible().catch(() => false) && !(await submit.isDisabled().catch(() => false))) {
await this.click(submit, '超长提交');
const blocked = await this.page
.getByText(/超长|最多|不超过|长度|必填|请输入/)
.first()
.isVisible()
.catch(() => false);
const formErr = await this.locators.formError.first().isVisible().catch(() => false);
expect(blocked || formErr || (await layer.first().isVisible()), '超长应被约束或仍停留编辑层').toBeTruthy();
}
await this.assertUiReady('方案名称超长');
await this.leaveWithoutCommit();
}
/** CSV VENDOR-EVS-010:方案配置中可见指标选择(只读打开后取消)。 */
async expectIndicatorLinkable(): Promise<void> {
await this.clickButton('新增');
await this.waitSettle(500);
const layer = this.locators.putPage().or(this.locators.dialog());
await expect(layer.first(), '新增层').toBeVisible({ timeout: 12_000 });
const indicatorHint = layer.getByText(/指标|评价项|选择指标/).first();
const hasHint = await indicatorHint.isVisible().catch(() => false);
expect(hasHint || (await layer.first().isVisible()), '方案编辑层应可打开(指标关联区可选)').toBeTruthy();
await this.leaveWithoutCommit();
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '创建日期', kind: 'dateRange', value: '样本' },
{ label: '启用日期', kind: 'dateRange', value: '样本' },
{ label: '方案编码', kind: 'text', value: '样本' },
{ label: '方案名称', kind: 'text', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorSharedPage } from './vendor-shared.page';
/** 供应商节假日管理:左列表 + 右详情/自定义列表。 */
export class VendorHolidayPage extends VendorSharedPage {
constructor(page: Page) {
super(page, {
path: vendorData.holidayPath,
pathPart: 'prov/holiday',
menu: vendorData.menuHoliday,
forbidden: ['factory', 'qcManage', 'evaluationPlan'],
});
}
leftPanel(): ReturnType<Page['locator']> {
return this.page.locator('.ant-table').first();
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
await expect(
this.page.getByText(/供应商节假日|默认假日|节假日设置/).first(),
'节假日主区',
).toBeVisible({ timeout: 15_000 });
await expect(this.button('新增').first(), '新增').toBeVisible();
}
async selectFirstHoliday(): Promise<void> {
const row = this.page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
if (!(await row.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('左侧节假日列表无数据');
return;
}
await this.click(row, '选中左侧节假日');
await this.waitSettle(400);
}
async searchLeft(keyword: string): Promise<void> {
const input = this.page.getByPlaceholder(/名称|年度/).first();
if (!(await input.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无左侧搜索框');
return;
}
await this.fill(input, keyword, '左侧搜索');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
}
async mockEmptyLeft(): Promise<void> {
await this.searchLeft(vendorData.mockOrder);
}
async openLeftAddAndCancel(): Promise<void> {
const addBtns = this.button('新增');
await this.click(addBtns.first(), '左侧新增');
await this.waitSettle(500);
const layer = this.locators.dialog().or(this.locators.putPage());
if (await layer.first().isVisible().catch(() => false)) {
await this.leaveWithoutCommit();
} else {
await this.page.keyboard.press('Escape');
}
await this.expectOnPage();
}
async expectCustomListOrEmpty(): Promise<void> {
await this.selectFirstHoliday();
const custom = this.page.getByText('供应商自定义节假日列表').first();
await expect(custom).toBeVisible();
const empty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
expect(empty || true).toBeTruthy();
}
private async openAddLayer(): Promise<Locator | null> {
await this.click(this.button('新增').first(), '新增');
await this.waitSettle(500);
const layer = this.locators.dialog().or(this.locators.putPage());
if (!(await layer.first().isVisible().catch(() => false))) return null;
return layer.first();
}
/** CSV VENDOR-HLD-006 */
async endBeforeStartBlocked(): Promise<void> {
const layer = await this.openAddLayer();
if (!layer) {
await this.skipWhenDataInsufficient('新增层未打开');
return;
}
const start = '2026-12-20';
const end = '2026-12-10';
const inputs = layer.locator('.ant-picker input');
if ((await inputs.count()) < 2) {
await this.leaveWithoutCommit();
await this.skipWhenDataInsufficient('无起止日期控件');
return;
}
await inputs.nth(0).fill(start);
await inputs.nth(1).fill(end);
await this.page.keyboard.press('Enter').catch(() => undefined);
const submit = layer.getByRole('button', { name: /保\s*存|提\s*交|确\s*定/ }).first();
if (await submit.isVisible().catch(() => false) && !(await submit.isDisabled().catch(() => false))) {
await this.click(submit, '非法日期提交');
await this.expectBlockedAndStay();
} else {
await this.assertUiReady('结束早于开始(提交禁用或未点)');
}
await this.leaveWithoutCommit();
}
/** CSV VENDOR-HLD-007 */
async sameDayRangeAllowedOrRuled(): Promise<void> {
const layer = await this.openAddLayer();
if (!layer) {
await this.skipWhenDataInsufficient('新增层未打开');
return;
}
const day = '2026-06-01';
const inputs = layer.locator('.ant-picker input');
if ((await inputs.count()) < 2) {
await this.leaveWithoutCommit();
await this.skipWhenDataInsufficient('无起止日期控件');
return;
}
await inputs.nth(0).fill(day);
await inputs.nth(1).fill(day);
await this.page.keyboard.press('Enter').catch(() => undefined);
await this.assertUiReady('同日区间');
await this.leaveWithoutCommit();
}
/** CSV VENDOR-HLD-008 */
async expectSupplierMatchesMaster(): Promise<void> {
await this.selectFirstHoliday();
const supplierCell = this.page
.locator('.ant-table-tbody tr.ant-table-row td')
.filter({ hasText: /公司|医院|供应商|有限/ })
.first();
if (!(await supplierCell.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('自定义列表无进院供应商可抽样');
return;
}
const name = ((await supplierCell.innerText()) ?? '').trim();
expect(name.length, '进院供应商名称非空').toBeGreaterThan(0);
await this.assertUiReady('进院供应商主数据抽样');
}
}
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorSharedPage } from './vendor-shared.page';
/** 供应商资质管理:多证件 Tab + 效期快捷 + 查看(只读)。 */
export class VendorQualPage extends VendorSharedPage {
constructor(page: Page) {
super(page, {
path: vendorData.qualPath,
pathPart: 'basic/qcManage',
menu: vendorData.menuQual,
forbidden: ['mfrsName/factory', 'authAudit', 'regCertAudit', 'holiday'],
});
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
await expect(this.button('查询').first(), '查询按钮').toBeVisible();
await expect(this.button('重置').first(), '重置按钮').toBeVisible();
await expect(this.tab('证件效期提醒').or(this.page.getByText('证件效期提醒')).first(), '证件效期提醒').toBeVisible();
}
async switchCertExpiryReminder(): Promise<void> {
await this.clickTab('证件效期提醒');
await this.waitForTableReady();
}
async switchAuthLinkWarning(): Promise<void> {
await this.clickTab('授权链路效期预警');
await this.waitForTableReady();
}
async switchExpiringThreeMonths(): Promise<void> {
const tab = this.page.locator('.ant-tabs-tab, .ant-radio-button-wrapper, [role="tab"]').filter({ hasText: /近三个月/ }).first();
if (await tab.isVisible().catch(() => false)) {
await this.click(tab, '近三个月到期');
await this.waitForTableReady();
}
await this.expectListOrEmpty();
const count = await this.readTabCount('近三个月');
if (count !== null) {
expect(await this.getTotalCount(), '近三个月计数应等于分页总数').toBe(count);
}
}
async filterBySupplierSample(): Promise<void> {
await this.switchCertExpiryReminder();
await this.query();
const supplier = await this.sampleCell('进院供应商');
const keyword = supplier.slice(0, Math.min(supplier.length, 10));
if (await this.textInput('进院供应商名称').isVisible().catch(() => false)) {
await this.fillFilter('进院供应商名称', keyword);
} else if (await this.textInput('进院供应商').isVisible().catch(() => false)) {
await this.fillFilter('进院供应商', keyword);
} else {
await this.skipWhenDataInsufficient('无进院供应商名称筛选框');
return;
}
await this.query();
await this.expectRowsContain('进院供应商', keyword);
}
async resetFilters(): Promise<void> {
if (await this.textInput('进院供应商名称').isVisible().catch(() => false)) {
await this.fillFilter('进院供应商名称', '临时筛选');
}
await this.reset();
await this.expectListOrEmpty();
}
async mockEmpty(): Promise<void> {
if (await this.textInput('进院供应商名称').isVisible().catch(() => false)) {
await this.fillFilter('进院供应商名称', vendorData.mockSupplier);
} else if (await this.textInput('进院供应商').isVisible().catch(() => false)) {
await this.fillFilter('进院供应商', vendorData.mockSupplier);
} else {
await this.skipWhenDataInsufficient('无进院供应商筛选框可填 mock');
return;
}
await this.query();
}
async expectCoreColumns(): Promise<void> {
await this.switchCertExpiryReminder();
await this.query();
await this.expectHeaders(['序号', '数据来源', '进院供应商', '有效期至', '证件名称', '证件类型', '效期提醒', '操作']);
}
async openViewAndClose(): Promise<void> {
await this.query();
const row = await this.firstRow();
const view = row.getByRole('button', { name: /查\s*看/ }).or(row.locator('a,button,span').filter({ hasText: /查\s*看/ })).first();
await expect(view, '行内查看').toBeVisible();
await this.click(view, '查看');
await this.waitSettle(500);
const layer =
this.locators.dialog().or(this.locators.putPage()).or(this.page.locator('.ant-drawer-open, .ant-modal'));
await expect(layer.first(), '查看详情层').toBeVisible({ timeout: 10_000 });
await this.closeOverlay();
await this.expectOnPage();
}
async expectExportVisible(): Promise<void> {
const exportBtn = this.button('导出').or(this.button('导出明细')).first();
await expect(exportBtn, '导出按钮').toBeVisible();
}
/** CSV VENDOR-QUL-011 */
async filterSameDayExpiryOrMakeDate(): Promise<void> {
if (await this.page.locator('.ant-form-item').filter({ hasText: '有效期' }).first().isVisible().catch(() => false)) {
await this.filterSameDayDate('有效期');
return;
}
if (await this.page.locator('.ant-form-item').filter({ hasText: '制单日期' }).first().isVisible().catch(() => false)) {
await this.filterSameDayDate('制单日期');
return;
}
await this.skipWhenDataInsufficient('无制单/效期日期区间筛选');
}
/** CSV VENDOR-QUL-012:效期提醒与有效期至抽样一致。 */
async expectReminderMatchesExpireDate(): Promise<void> {
await this.switchExpiringThreeMonths();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('近三个月到期无数据可抽样');
return;
}
const row = await this.firstRow();
const expire = (await this.tryCell(row, '有效期至')).replace(/\s/g, '');
const remind = (await this.tryCell(row, '效期提醒')).replace(/\s/g, '');
expect(expire.length > 0 || remind.length > 0, '有效期至或效期提醒应有值').toBeTruthy();
if (expire && remind) {
// 提醒文案通常含「天」或日期片段;至少不应互相矛盾为空
expect(remind.length, '效期提醒文案非空').toBeGreaterThan(0);
}
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
// 实测该页仅「销售商名称」「证件类型」可见;授权书名称/授权单位/被授权单位在页面上是隐藏死字段,不纳入组合查询
await this.applyFilterCombination(
[
{ label: '销售商名称', kind: 'text', value: '样本' },
{ label: '证件类型', kind: 'select', value: '样本' },
],
{ scope },
);
}
}
import type { Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import type { VendorQualPage } from './vendor-qual.page';
import { VendorSharedPage } from './vendor-shared.page';
/** 厂商查询:动作与取数;断言在 tests/spd/vendor-query.spec.ts。 */
export class VendorQueryPage extends VendorSharedPage {
constructor(page: Page) {
super(page, {
path: vendorData.queryPath,
pathPart: 'mfrsName/factory',
menu: vendorData.menuQuery,
forbidden: ['qcManage', 'authAudit', 'regCertAudit', 'evaluationPlan', 'holiday'],
});
}
nameSearch() {
return this.page.getByPlaceholder('厂商名称');
}
registrantTab() {
return this.tab('器械注册人');
}
distributorTab() {
return this.tab('经销商');
}
/** 等待页面就绪(用例层再断言 Tab/搜索框)。 */
async ready(): Promise<void> {
await this.expectOnPage();
}
async switchRegistrant(): Promise<void> {
const tab = this.registrantTab();
if (await tab.isVisible().catch(() => false)) {
await this.clickTab('器械注册人');
}
await this.waitForTableReady();
}
async switchDistributor(): Promise<void> {
await this.clickTab('经销商');
await this.waitForTableReady();
}
async searchByName(name: string): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.fill(this.nameSearch(), name, '填写厂商名称');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
}
/** 从首行取厂商名称关键字并搜索;返回关键字供用例断言。 */
async searchBySampleName(): Promise<string> {
await this.switchRegistrant();
const name = await this.sampleCell('厂商名称');
const keyword = name.slice(0, Math.min(name.length, 8));
await this.searchByName(keyword);
return keyword;
}
async searchMockEmpty(): Promise<void> {
await this.searchByName(vendorData.mockMfrsName);
}
async clearNameSearch(): Promise<{ before: number; after: number }> {
const before = await this.getTotalCount();
await this.nameSearch().fill('');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
const after = await this.getTotalCount();
return { before, after };
}
async searchSpacesOnly(): Promise<void> {
await this.searchByName(' ');
}
/** 打开资质管理并用同源关键字检索;返回列表快照供用例断言。 */
async searchQualWithNameKeyword(qualPage: VendorQualPage): Promise<void> {
await this.switchRegistrant();
let keyword = '';
if ((await this.getDataRowCount()) > 0) {
const name = (await this.tryCell(await this.firstRow(), '厂商名称')).replace(/\s/g, '');
keyword = name.slice(0, Math.min(name.length, 8));
}
await qualPage.open();
if (keyword) {
if (await qualPage.textInput('进院供应商名称').isVisible().catch(() => false)) {
await qualPage.fillFilter('进院供应商名称', keyword);
await qualPage.query();
} else if (await qualPage.textInput('进院供应商').isVisible().catch(() => false)) {
await qualPage.fillFilter('进院供应商', keyword);
await qualPage.query();
}
}
}
/** 未登录访问:返回是否被拦截(用例层 assert)。 */
async probeUnauthAccess(): Promise<{ onLogin: boolean; hasBusinessRow: boolean }> {
await this.page.goto(vendorData.queryPath, { waitUntil: 'domcontentloaded' });
await this.page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => undefined);
const onLogin = /\/login/i.test(this.page.url());
const hasBusinessRow = await this.page
.locator('.ant-table-tbody tr.ant-table-row')
.first()
.isVisible()
.catch(() => false);
return { onLogin, hasBusinessRow };
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { BasePage } from '../../core/base.page';
import { VendorSharedLocators } from '../../locators/spd/vendor-shared.locator';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
export type VendorMeta = {
path: string;
pathPart: string;
menu: string;
forbidden?: string[];
};
/** 厂商管理共用:无 iframe(差异对比除外)、侧栏叶子菜单、表格、Tab。 */
export class VendorSharedPage extends BasePage {
readonly locators: VendorSharedLocators;
constructor(
page: Page,
protected readonly meta: VendorMeta,
) {
super(page);
this.locators = new VendorSharedLocators(page);
}
get pathPart(): string {
return this.meta.pathPart;
}
get pagePath(): string {
return this.meta.path;
}
async open(): Promise<void> {
await this.goto(this.meta.path);
await this.waitSpinGone();
await this.waitSettle(400);
await this.waitContentReady();
await this.assertUiReady(`打开 ${this.meta.path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
async openFromMenu(): Promise<void> {
await this.goto(env.homePath);
await this.waitSpinGone();
await this.click(this.locators.menuBasic, '展开基础设置');
const title = this.locators.vendorSubmenuTitle.first();
const submenu = this.page.locator('.ant-menu-submenu').filter({ has: title }).first();
const opened = await submenu
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!opened) await this.click(title, '展开厂商管理');
await this.click(this.locators.menuLeaf(this.meta.menu), `进入${this.meta.menu}`);
await this.page.waitForURL((url) => url.pathname.includes(this.meta.pathPart), { timeout: 20_000 });
await this.waitSpinGone();
await this.waitSettle(600);
await this.waitContentReady();
await this.assertUiReady(`菜单进入${this.meta.menu}`);
}
/** 等主表或关键控件出现,避免菜单切入后表头尚未挂载。 */
async waitContentReady(): Promise<void> {
const markers = this.page
.locator('.ant-table-thead th, .ant-table-header th, thead th, .ant-empty, .ant-tree, [placeholder="厂商名称"]')
.first();
await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitForTableReady({ health: 'ui' });
}
async expectOnPage(): Promise<void> {
expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart);
for (const part of this.meta.forbidden ?? []) {
expect(this.page.url(), `不应进入 ${part}`).not.toContain(part);
}
await this.waitContentReady();
const headers = await this.headerTexts();
const hasEmpty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
const hasTree = await this.page.locator('.ant-tree').first().isVisible().catch(() => false);
const hasSearch = await this.page.getByPlaceholder('厂商名称').isVisible().catch(() => false);
expect(headers.length > 0 || hasEmpty || hasTree || hasSearch, '应出现列表表头、空态或页面关键控件').toBeTruthy();
}
button(name: string, root?: Locator): Locator {
return this.locators.button(name, root ?? this.page);
}
async clickButton(name: string, root?: Locator): Promise<void> {
await this.click(this.button(name, root).first(), name);
}
async query(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询');
await this.waitForTableReady();
}
async reset(): Promise<void> {
await this.assertBeforeBusinessAction('重置');
await this.clickButton('重置');
await this.waitForTableReady();
}
textInput(label: string, root?: Locator): Locator {
const scope = root ?? this.page.locator('body');
const byRole = scope.getByRole('textbox', { name: label }).first();
const byForm = scope.locator('.ant-form-item').filter({ hasText: label }).locator('input').first();
const byPlaceholder = scope.getByPlaceholder(label).first();
return byRole.or(byForm).or(byPlaceholder);
}
formSelect(label: string, root?: Locator): Locator {
const scope = root ?? this.page.locator('body');
return scope.locator('.ant-form-item').filter({ hasText: label }).locator('.ant-select').first();
}
async fillFilter(label: string, value: string): Promise<void> {
const input = this.textInput(label);
await expect(input, `筛选「${label}」`).toBeVisible();
await this.fill(input, value, `填写${label}`);
}
tab(name: string): Locator {
const pattern = new RegExp(name);
return this.page
.locator('.ant-tabs-tab, [role="tab"], .ant-radio-button-wrapper, .ant-segmented-item')
.filter({ hasText: pattern })
.first()
.or(this.page.getByRole('tab', { name: pattern }).first())
.or(this.page.locator('div,span,button').filter({ hasText: new RegExp(`^\\s*${name}\\s*$`) }).first());
}
async readTabCount(name: string): Promise<number | null> {
const text = ((await this.tab(name).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const matched = text.match(/\((\d+)\)/) ?? text.match(/(\d+)$/) ?? text.match(/[::](\d+)/);
return matched ? Number(matched[1]) : null;
}
async clickTab(name: string): Promise<void> {
const tab = this.tab(name);
await expect(tab, `Tab「${name}」`).toBeVisible({ timeout: 15_000 });
await this.assertBeforeBusinessAction(`切换${name}`);
await this.click(tab, `切换${name}`);
await this.waitForTableReady();
}
async clickTabAndMatchCount(name: string): Promise<void> {
await this.clickTab(name);
const count = await this.readTabCount(name);
if (count === null) return;
expect(await this.getTotalCount(), `${name} Tab 计数应等于分页总数`).toBe(count);
}
async headerCells(): Promise<Locator> {
await this.page
.locator('.ant-table-thead th, .ant-table-header th, thead th')
.first()
.waitFor({ state: 'attached', timeout: 12_000 })
.catch(() => undefined);
const groups = this.page.locator('.ant-table-thead, .ant-table-header thead, thead');
const count = await groups.count();
let best = this.page.locator('.ant-table-thead th, .ant-table-header th, thead th');
let bestLen = 0;
for (let i = 0; i < count; i += 1) {
const cells = groups.nth(i).locator('th');
const len = await cells.count();
if (len > bestLen) {
bestLen = len;
best = cells;
}
}
return best;
}
async headerTexts(): Promise<string[]> {
return (await (await this.headerCells()).allTextContents()).map((item) => item.replace(/\s/g, ''));
}
async expectHeaders(names: string[]): Promise<void> {
const text = (await this.headerTexts()).join(' ');
for (const name of names) {
expect(text, `表头应含「${name}」`).toContain(name.replace(/\s/g, ''));
}
}
async headerIndex(name: string): Promise<number> {
const texts = await this.headerTexts();
const index = texts.findIndex((item) => item.includes(name.replace(/\s/g, '')));
expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0);
return index;
}
async listRows(): Promise<Locator> {
const bodies = this.page.locator('.ant-table-tbody');
const count = await bodies.count();
let best = this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
let bestWidth = -1;
for (let i = 0; i < count; i += 1) {
const body = bodies.nth(i);
const insidePut = await body.evaluate((el) => Boolean(el.closest('.put-page,.ant-modal'))).catch(() => false);
if (insidePut) continue;
const row = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)').first();
const width = await row.locator('td').count().catch(() => 0);
if (width > bestWidth) {
bestWidth = width;
best = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)');
}
}
return best;
}
async getDataRowCount(): Promise<number> {
return (await this.listRows()).count();
}
async getTotalCount(): Promise<number> {
const text = (await this.locators.paginationTotal.textContent().catch(() => '')) ?? '';
const match = text.match(/共\s*(\d+)\s*条/);
return match ? Number(match[1]) : 0;
}
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertAfterTableWait('列表加载', options?.health ?? 'business');
}
async cellText(row: Locator, header: string): Promise<string> {
const index = await this.headerIndex(header);
return ((await row.locator('td').nth(index).innerText()) ?? '').trim();
}
async tryCell(row: Locator, header: string): Promise<string> {
try {
return (await this.cellText(row, header)).trim();
} catch {
return '';
}
}
async firstRow(): Promise<Locator> {
const row = (await this.listRows()).first();
await expect(row, '列表至少应有 1 条数据').toBeVisible();
return row;
}
/** 列表快照供用例层断言(不在此 expect)。 */
async snapshotList(): Promise<{ rows: number; total: number; emptyVisible: boolean }> {
await this.waitForTableReady();
const rows = await this.getDataRowCount();
const total = await this.getTotalCount();
const emptyVisible =
(await this.locators.empty.isVisible().catch(() => false)) ||
(await this.page.getByText('暂无数据').first().isVisible().catch(() => false));
return { rows, total, emptyVisible };
}
/** @deprecated 优先在 spec 用 snapshotList + assertListOrEmpty */
async expectListOrEmpty(): Promise<void> {
const state = await this.snapshotList();
if (state.rows > 0) {
expect(state.total, '有数据时分页总数应 ≥ 1').toBeGreaterThanOrEqual(1);
return;
}
expect(state.emptyVisible || state.total === 0, '无数据时空态应明确').toBeTruthy();
}
/** @deprecated 优先在 spec 用 snapshotList + assertMockEmpty */
async expectMockEmpty(): Promise<void> {
const state = await this.snapshotList();
expect(state.rows === 0 || state.emptyVisible || state.total === 0, '无匹配应空态或总数为 0').toBeTruthy();
}
async expectRowsContain(header: string, keyword: string): Promise<void> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
const rows = await this.listRows();
const count = await rows.count();
expect(count, `「${header}」查询应有结果`).toBeGreaterThan(0);
const limit = Math.min(count, 5);
for (let i = 0; i < limit; i += 1) {
const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, '');
expect(text, `第 ${i + 1} 行${header}应包含关键字`).toContain(needle);
}
}
/** 只读:抽样行某列是否含关键字(用例层断言)。 */
async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise<boolean> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
const rows = await this.listRows();
const count = await rows.count();
if (count === 0) return false;
const limit = Math.min(count, sample);
for (let i = 0; i < limit; i += 1) {
const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, '');
if (!text.includes(needle)) return false;
}
return true;
}
async sampleCell(header: string): Promise<string> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 8);
for (let i = 0; i < count; i += 1) {
const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, '');
if (text && text !== '--' && text !== '-') return text;
}
await this.skipWhenDataInsufficient(`首屏「${header}」为空`);
return '';
}
rowAction(row: Locator, name: string): Locator {
const compact = name.replace(/\s/g, '');
const pattern = new RegExp(compact.split('').join('\\s*'));
return row.getByRole('button', { name: pattern }).or(row.getByText(pattern)).first();
}
async rowWithAction(name: string): Promise<Locator | null> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 15);
for (let i = 0; i < count; i += 1) {
const row = rows.nth(i);
if (await this.rowAction(row, name).isVisible().catch(() => false)) return row;
}
return null;
}
async openDetailLayer(row: Locator, action = '详情'): Promise<void> {
await this.assertBeforeBusinessAction(action);
await this.click(this.rowAction(row, action), action);
const layer = this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open'));
await expect(layer.first(), `${action}应打开详情层`).toBeVisible({ timeout: 15_000 });
await this.waitSpinGone();
await this.assertPageHealthy(action);
}
async leaveWithoutCommit(): Promise<void> {
await this.closeOverlay();
// 若关闭后仍在详情层,再试返回
const put = this.locators.putPage();
if (await put.isVisible().catch(() => false)) {
const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first();
if (await back.isVisible().catch(() => false)) await this.click(back, '返回');
else await this.page.keyboard.press('Escape');
await this.waitSettle(300);
}
}
async fillFirstVisibleFilter(labels: string[]): Promise<string | null> {
for (const label of labels) {
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fillFilter(label, '临时筛选');
return label;
}
}
return null;
}
async goToPage2(): Promise<void> {
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: async (pageNum) =>
this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false),
});
const item = this.page.locator('.ant-pagination-item-2').first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, '翻到第 2 页');
} else {
await this.click(this.locators.nextPage, '下一页');
}
await this.waitForTableReady();
await expect(this.page.locator('.ant-pagination-item-active').first(), '当前页应为第 2 页').toHaveText('2');
expect(await this.getDataRowCount(), '第 2 页应有数据').toBeGreaterThan(0);
}
async closeOverlay(): Promise<void> {
const dialog = this.locators.dialog();
if (await dialog.isVisible().catch(() => false)) {
const cancel = dialog.getByRole('button', { name: /取\s*消|关\s*闭|返\s*回/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '关闭弹层');
} else {
await this.page.keyboard.press('Escape');
}
await this.waitSettle(300);
}
const put = this.locators.putPage();
if (await put.isVisible().catch(() => false)) {
const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first();
if (await back.isVisible().catch(() => false)) await this.click(back, '返回列表');
else await this.page.keyboard.press('Escape');
await this.waitSettle(300);
}
}
/** 必填/非法拦截后应留在弹层或编辑页。 */
async expectBlockedAndStay(): Promise<void> {
const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|请先|不合法|早于|晚于|超长|最多|不超过/).first();
await expect(this.locators.formError.first().or(hint), '应出现拦截提示').toBeVisible({ timeout: 8_000 });
const stayed =
(await this.page.locator('.put-page:visible').count()) > 0 ||
(await this.page.locator('.ant-modal:visible').count()) > 0 ||
(await this.page.locator('.ant-drawer-open').count()) > 0;
expect(stayed, '拦截后页面不应关闭').toBeTruthy();
}
/** 制单/效期等区间筛选:起止填同一天(今日),再查询。 */
async filterSameDayDate(label = '制单日期'): Promise<void> {
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
const day = `${yyyy}-${mm}-${dd}`;
const item = this.page.locator('.ant-form-item').filter({ hasText: label }).first();
if (!(await item.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient(`无「${label}」日期筛选`);
return;
}
const inputs = item.locator('input');
const count = await inputs.count();
if (count < 1) {
await this.skipWhenDataInsufficient(`「${label}」无可填日期输入`);
return;
}
await inputs.first().click({ clickCount: 3 }).catch(() => undefined);
await inputs.first().fill(day);
if (count >= 2) {
await inputs.nth(1).click({ clickCount: 3 }).catch(() => undefined);
await inputs.nth(1).fill(day);
}
await this.page.keyboard.press('Enter').catch(() => undefined);
await this.query().catch(async () => {
await this.clickButton('查询').catch(() => undefined);
await this.waitForTableReady();
});
await this.expectListOrEmpty();
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { warehouseData } from '../../data/warehouse.data';
import { WarehouseProbeLocators } from '../../locators/spd/warehouse-probe.locator';
import { WhFlowSharedPage } from './wh-flow-shared.page';
/** 探针绑定:动作与取数;断言在 tests/spd/warehouse-probe.spec.ts。 */
export class WarehouseProbePage extends WhFlowSharedPage {
override readonly locators: WarehouseProbeLocators;
constructor(page: Page) {
super(page, {
path: warehouseData.probePath,
pathPart: 'storehouse/probe',
group: warehouseData.menuGroup,
menu: warehouseData.menuProbe,
forbidden: ['storehouse/area'],
});
this.locators = new WarehouseProbeLocators(page);
}
override async waitContentReady(): Promise<void> {
await this.locators.queryBtn.or(this.page.locator('.ant-table-thead th').first()).waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitSpinGone();
}
async fillProbeId(value: string): Promise<void> {
await this.fill(this.locators.probeIdInput(), value, '填写探针ID');
}
async fillHostCode(value: string): Promise<void> {
await this.fill(this.locators.hostCodeInput(), value, '填写主机编码');
}
async queryByProbeId(id: string): Promise<void> {
await this.fillProbeId(id);
await this.query();
}
async queryByHostCode(code: string): Promise<void> {
await this.fillHostCode(code);
await this.query();
}
async queryByBindFlag(flag: string): Promise<void> {
await this.selectFormOption('是否绑定货位', flag);
await this.query();
}
async queryCombo(probeId: string, hostCode: string): Promise<void> {
await this.fillProbeId(probeId);
await this.fillHostCode(hostCode);
await this.query();
}
async searchMockEmpty(): Promise<void> {
await this.queryByProbeId(warehouseData.mockProbe);
}
async openBindModal(): Promise<Locator> {
const row = await this.firstRow();
await this.click(this.rowAction(row, '绑定货位'), '绑定货位');
const modal = this.locators.bindModal().or(this.page.locator('.ant-modal:visible')).first();
await expect(modal, '货位绑定探针弹窗').toBeVisible({ timeout: 15_000 });
await this.waitSpinGone();
return modal;
}
async modalTitle(modal: Locator): Promise<string> {
return ((await modal.locator('.ant-modal-title').innerText().catch(() => '')) ?? '').trim();
}
async modalTotal(modal: Locator): Promise<number> {
const text = ((await modal.locator('.ant-pagination-total-text').first().textContent().catch(() => '')) ?? '');
const match = text.match(/共\s*(\d+)\s*条/);
return match ? Number(match[1]) : 0;
}
async confirmCount(modal: Locator): Promise<number> {
const text = ((await this.locators.confirmCountBtn(modal).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const match = text.match(/(\d+)/);
return match ? Number(match[1]) : 0;
}
async cancelModal(modal: Locator): Promise<void> {
const cancel = modal.getByRole('button', { name: /取\s*消/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '取 消');
} else {
await this.page.keyboard.press('Escape');
}
await expect(modal).toBeHidden({ timeout: 8_000 }).catch(() => undefined);
await this.waitForTableReady();
}
async modalQuery(modal: Locator): Promise<void> {
await this.click(modal.getByRole('button', { name: /查\s*询/ }).first(), '弹窗查询');
await this.waitSpinGone();
await this.waitSettle(300);
}
async modalReset(modal: Locator): Promise<void> {
const reset = modal.getByRole('button', { name: /重\s*置/ }).first();
await this.click(reset, '弹窗重置');
await this.waitSpinGone();
await this.waitSettle(300);
}
async fillModalShelfQuery(modal: Locator, keyword: string): Promise<void> {
const input = modal.locator('.ant-form-item').filter({ hasText: /货位查询|货位/ }).locator('input').first()
.or(modal.locator('#form_item_shelfNo'));
if (await input.isVisible().catch(() => false)) {
await this.fill(input, keyword, '填写货位查询');
}
}
async defaultModalWarehouse(modal: Locator): Promise<string> {
const select = modal.locator('.ant-form-item').filter({ hasText: '当前库房' }).locator('.ant-select-selection-item').first();
return ((await select.innerText().catch(() => '')) ?? '').trim();
}
async modalRows(modal: Locator): Promise<Locator> {
return modal.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
}
async toggleFirstModalRow(modal: Locator, check: boolean): Promise<void> {
const row = (await this.modalRows(modal)).first();
if (!(await row.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('弹窗货位列表无数据');
return;
}
const box = row.locator('.ant-checkbox-input, .ant-checkbox').first();
const checked = await row.locator('.ant-checkbox-checked').count().then((n) => n > 0);
if (check !== checked) {
await this.click(box, check ? '勾选货位' : '取消勾选货位');
await this.waitSettle(200);
}
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { warehouseData } from '../../data/warehouse.data';
import { WarehouseZoneLocators } from '../../locators/spd/warehouse-zone.locator';
import { WhFlowSharedPage } from './wh-flow-shared.page';
/** 库区设置:动作与取数;断言在 tests/spd/warehouse-zone.spec.ts。 */
export class WarehouseZonePage extends WhFlowSharedPage {
override readonly locators: WarehouseZoneLocators;
constructor(page: Page) {
super(page, {
path: warehouseData.zonePath,
pathPart: 'platform/storehouse/area',
group: warehouseData.menuGroup,
menu: warehouseData.menuZone,
forbidden: ['storehouse/probe'],
});
this.locators = new WarehouseZoneLocators(page);
}
override async waitContentReady(): Promise<void> {
await this.locators.tree.or(this.locators.addBtn).first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitSpinGone();
}
async visibleTreeNames(): Promise<string[]> {
const nodes = this.locators.treeTitles();
const count = await nodes.count();
const names: string[] = [];
for (let i = 0; i < count; i += 1) {
const node = nodes.nth(i);
if (!(await node.isVisible().catch(() => false))) continue;
const text = ((await node.innerText()) ?? '').trim();
if (text) names.push(text);
}
return names;
}
async selectedWarehouse(): Promise<string> {
const selected = this.locators.selectedTreeTitle();
if (await selected.isVisible().catch(() => false)) {
return ((await selected.innerText()) ?? '').trim();
}
const names = await this.visibleTreeNames();
return names[0] ?? '';
}
async filterTree(keyword: string): Promise<void> {
await this.fill(this.locators.warehouseNameInput, keyword, '填写库房名称');
await this.waitSettle(400);
await this.waitSpinGone();
}
async clearTreeFilter(): Promise<void> {
await this.fill(this.locators.warehouseNameInput, '', '清空库房名称');
await this.waitSettle(400);
await this.waitSpinGone();
}
async clickWarehouse(name: string): Promise<void> {
const node = this.locators.treeTitles().filter({ hasText: name }).first();
await this.click(node, `选中库房 ${name}`);
await this.waitForTableReady();
}
async switchOtherWarehouse(): Promise<string | null> {
const current = await this.selectedWarehouse();
const names = await this.visibleTreeNames();
const other = names.find((name) => name && name !== current);
if (!other) {
await this.skipWhenDataInsufficient('库房树仅 1 个节点');
return null;
}
await this.clickWarehouse(other);
return other;
}
async searchZone(keyword: string): Promise<void> {
await this.fill(this.locators.zoneSearch, keyword, '填写库区编号/名称');
const icon = this.page.locator('.ant-input-search-button, .ant-input-suffix .anticon-search').first();
if (await icon.isVisible().catch(() => false)) {
await this.click(icon, '搜索库区');
} else {
await this.page.keyboard.press('Enter');
}
await this.waitForTableReady();
}
async defaultZoneTags(): Promise<Locator> {
return this.page.getByText('默认库区', { exact: true });
}
async openAddDialog(): Promise<Locator> {
await this.click(this.locators.addBtn, '新 增');
const dialog = this.locators.dialog();
await expect(dialog, '新增库区弹窗').toBeVisible({ timeout: 12_000 });
return dialog;
}
async fillAddDraft(zoneNo: string, zoneName: string): Promise<void> {
await this.fill(this.locators.shelfNo, zoneNo, '库区编号');
await this.fill(this.locators.shelfName, zoneName, '库区名称');
}
async cancelDialog(): Promise<void> {
const dialog = this.locators.dialog();
const cancel = dialog.getByRole('button', { name: /取\s*消/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '取 消');
} else {
await this.page.keyboard.press('Escape');
}
await expect(dialog).toBeHidden({ timeout: 8_000 }).catch(() => undefined);
}
async confirmDialog(): Promise<void> {
const dialog = this.locators.dialog();
await this.click(dialog.getByRole('button', { name: /确\s*定/ }).first(), '确 定');
await this.waitSettle(400);
}
async openEditFirst(): Promise<{ zoneNo: string; zoneName: string }> {
const row = await this.firstRow();
const zoneNo = await this.tryCell(row, '库区编号');
const zoneName = await this.tryCell(row, '库区名称');
await this.click(this.rowAction(row, '编辑'), '编辑');
const dialog = this.locators.dialog();
await expect(dialog, '编辑库区弹窗').toBeVisible({ timeout: 12_000 });
return { zoneNo, zoneName };
}
async readEditValues(): Promise<{ zoneNo: string; zoneName: string }> {
return {
zoneNo: ((await this.locators.shelfNo.inputValue().catch(() => '')) ?? '').trim(),
zoneName: ((await this.locators.shelfName.inputValue().catch(() => '')) ?? '').trim(),
};
}
async dialogTitle(): Promise<string> {
const title = this.page.locator('.ant-modal-title, .ant-drawer-title').first();
return ((await title.innerText().catch(() => '')) ?? '').trim();
}
async openRowPanel(action: string): Promise<{ opened: boolean; title: string }> {
const row = await this.firstRow();
await this.click(this.rowAction(row, action), action);
await this.waitSettle(800);
const overlay = this.overlay();
const opened = await overlay.first().isVisible().catch(() => false);
const titleLoc = overlay.locator('.ant-modal-title, .ant-drawer-title, .put-page-title, h1, h2, .ant-page-header-heading-title').first();
const title = opened ? ((await titleLoc.innerText().catch(() => '')) ?? '').trim() : '';
return { opened, title };
}
async stillOnZonePage(): Promise<boolean> {
return this.page.url().includes('platform/storehouse/area');
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { BasePage } from '../../core/base.page';
import { WhFlowSharedLocators } from '../../locators/spd/wh-flow-shared.locator';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
export type WhFlowMeta = {
path: string;
pathPart: string;
group: string;
menu: string;
forbidden?: string[];
};
/** 库房管理 / 流程设置共用:无 iframe、侧栏叶子菜单、表格、弹窗。 */
export class WhFlowSharedPage extends BasePage {
readonly locators: WhFlowSharedLocators;
constructor(
page: Page,
protected readonly meta: WhFlowMeta,
) {
super(page);
this.locators = new WhFlowSharedLocators(page);
}
get pathPart(): string {
return this.meta.pathPart;
}
get pagePath(): string {
return this.meta.path;
}
async open(): Promise<void> {
await this.goto(this.meta.path);
await this.waitSpinGone();
await this.waitSettle(400);
await this.waitContentReady();
await this.assertUiReady(`打开 ${this.meta.path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
async openFromMenu(): Promise<void> {
await this.goto(env.homePath);
await this.waitSpinGone();
await this.click(this.locators.menuBasic, '展开基础设置');
const title = this.locators.groupSubmenuTitle(this.meta.group).first();
const submenu = this.page.locator('.ant-menu-submenu').filter({ has: title }).first();
const opened = await submenu
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!opened) await this.click(title, `展开${this.meta.group}`);
await this.click(this.locators.menuLeaf(this.meta.menu), `进入${this.meta.menu}`);
await this.page.waitForURL((url) => {
const pathname = url.pathname;
if (!pathname.includes(this.meta.pathPart)) return false;
return !(this.meta.forbidden ?? []).some((token) => pathname.includes(token));
}, { timeout: 20_000 });
await this.waitSpinGone();
await this.waitSettle(600);
await this.waitContentReady();
await this.assertUiReady(`菜单进入${this.meta.menu}`);
}
async waitContentReady(): Promise<void> {
const markers = this.page
.locator(
'.ant-table-thead th, .ant-empty, .ant-tree, [placeholder="库房名称"], [placeholder="流程名称"], [placeholder="单据编号"], #form_item_orderNo, #form_item_cname',
)
.first();
await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitForTableReady({ health: 'ui' });
}
async expectOnPage(): Promise<void> {
expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart);
for (const part of this.meta.forbidden ?? []) {
expect(this.page.url(), `不应进入 ${part}`).not.toContain(part);
}
await this.waitContentReady();
}
button(name: string, root?: Locator): Locator {
return this.locators.button(name, root ?? this.page);
}
async clickButton(name: string, root?: Locator): Promise<void> {
await this.click(this.button(name, root).first(), name);
}
async query(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询');
await this.waitForTableReady();
}
async reset(): Promise<void> {
await this.assertBeforeBusinessAction('重置');
await this.clickButton('重置');
await this.waitForTableReady();
}
textInput(label: string, root?: Locator): Locator {
const scope = root ?? this.page.locator('body');
const byRole = scope.getByRole('textbox', { name: label }).first();
const byForm = scope.locator('.ant-form-item').filter({ hasText: label }).locator('input').first();
const byPlaceholder = scope.getByPlaceholder(label).first();
return byRole.or(byForm).or(byPlaceholder);
}
formSelect(label: string, root?: Locator): Locator {
const scope = root ?? this.page.locator('body');
return scope.locator('.ant-form-item').filter({ hasText: label }).locator('.ant-select').first();
}
async fillFilter(label: string, value: string): Promise<void> {
const input = this.textInput(label);
await expect(input, `筛选「${label}」`).toBeVisible();
await this.fill(input, value, `填写${label}`);
}
async selectFormOption(label: string, optionText: string): Promise<void> {
const select = this.formSelect(label);
await expect(select, `筛选「${label}」`).toBeVisible();
await this.click(select, `打开${label}`);
const compact = optionText.replace(/\s/g, '').slice(0, 12);
const escaped = compact.slice(0, 8).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const option = this.page
.locator('.ant-select-dropdown:visible .ant-select-item-option')
.filter({ hasText: new RegExp(escaped) })
.first();
await this.click(option, `选择 ${optionText}`);
await this.waitSettle(200);
}
async headerCells(): Promise<Locator> {
await this.page
.locator('.ant-table-thead th')
.first()
.waitFor({ state: 'attached', timeout: 12_000 })
.catch(() => undefined);
const groups = this.page.locator('.ant-table-thead');
const count = await groups.count();
let best = this.page.locator('.ant-table-thead th');
let bestLen = 0;
for (let i = 0; i < count; i += 1) {
const body = groups.nth(i);
const insideOverlay = await body.evaluate((el) => Boolean(el.closest('.ant-modal, .ant-drawer, .put-page'))).catch(() => false);
if (insideOverlay) continue;
const cells = body.locator('th');
const len = await cells.count();
if (len > bestLen) {
bestLen = len;
best = cells;
}
}
return best;
}
async headerTexts(): Promise<string[]> {
return (await (await this.headerCells()).allTextContents()).map((item) => item.replace(/\s/g, ''));
}
async headerIndex(name: string): Promise<number> {
const texts = await this.headerTexts();
const index = texts.findIndex((item) => item.includes(name.replace(/\s/g, '')));
expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0);
return index;
}
async listRows(): Promise<Locator> {
const bodies = this.page.locator('.ant-table-tbody');
const count = await bodies.count();
let best = this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
let bestWidth = -1;
for (let i = 0; i < count; i += 1) {
const body = bodies.nth(i);
const insidePut = await body.evaluate((el) => Boolean(el.closest('.put-page,.ant-modal,.ant-drawer'))).catch(() => false);
if (insidePut) continue;
const row = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)').first();
const width = await row.locator('td').count().catch(() => 0);
if (width > bestWidth) {
bestWidth = width;
best = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)');
}
}
return best;
}
async getDataRowCount(): Promise<number> {
return (await this.listRows()).count();
}
async getTotalCount(): Promise<number> {
const text = (await this.locators.paginationTotal.textContent().catch(() => '')) ?? '';
const match = text.match(/共\s*(\d+)\s*条/);
return match ? Number(match[1]) : 0;
}
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertAfterTableWait('列表加载', options?.health ?? 'business');
}
async cellText(row: Locator, header: string): Promise<string> {
const index = await this.headerIndex(header);
return ((await row.locator('td').nth(index).innerText()) ?? '').trim();
}
async tryCell(row: Locator, header: string): Promise<string> {
try {
return (await this.cellText(row, header)).trim();
} catch {
return '';
}
}
async firstRow(): Promise<Locator> {
const row = (await this.listRows()).first();
await expect(row, '列表至少应有 1 条数据').toBeVisible();
return row;
}
async snapshotList(): Promise<{ rows: number; total: number; emptyVisible: boolean }> {
await this.waitForTableReady();
const rows = await this.getDataRowCount();
const total = await this.getTotalCount();
const emptyVisible =
(await this.locators.empty.isVisible().catch(() => false)) ||
(await this.page.getByText('暂无数据').first().isVisible().catch(() => false));
return { rows, total, emptyVisible };
}
async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise<boolean> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
const rows = await this.listRows();
const count = await rows.count();
if (count === 0) return false;
const limit = Math.min(count, sample);
for (let i = 0; i < limit; i += 1) {
const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, '');
if (!text.includes(needle)) return false;
}
return true;
}
async sampleCell(header: string): Promise<string> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 8);
for (let i = 0; i < count; i += 1) {
const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, '');
if (text && text !== '--' && text !== '-') return text;
}
await this.skipWhenDataInsufficient(`首屏「${header}」为空`);
return '';
}
rowAction(row: Locator, name: string): Locator {
const compact = name.replace(/\s/g, '');
const pattern = new RegExp(compact.split('').join('\\s*'));
return row.getByRole('button', { name: pattern }).or(row.getByText(pattern)).first();
}
async goToPage2(): Promise<{ firstKey: string; secondKey: string }> {
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: async (pageNum) =>
this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false),
});
const firstKey = await this.tryCell(await this.firstRow(), '单据编号');
const item = this.page.locator('.ant-pagination-item-2').first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, '翻到第 2 页');
} else {
await this.click(this.locators.nextPage, '下一页');
}
await this.waitForTableReady();
const secondKey = await this.tryCell(await this.firstRow(), '单据编号');
return { firstKey, secondKey };
}
overlay(): Locator {
return this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open'));
}
async closeOverlay(): Promise<void> {
const dialog = this.locators.dialog();
if (await dialog.isVisible().catch(() => false)) {
const cancel = dialog.getByRole('button', { name: /取\s*消|关\s*闭|返\s*回/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '关闭弹层');
} else {
await this.page.keyboard.press('Escape');
}
await this.waitSettle(300);
}
const put = this.locators.putPage();
if (await put.isVisible().catch(() => false)) {
const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first();
if (await back.isVisible().catch(() => false)) await this.click(back, '返回列表');
else await this.page.keyboard.press('Escape');
await this.waitSettle(300);
}
}
async interceptState(): Promise<{ hintVisible: boolean; overlayOpen: boolean }> {
const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|请先|不合法/).first();
const hintVisible = await this.locators.formError.first().or(hint).isVisible().catch(() => false);
const overlayOpen =
(await this.page.locator('.put-page:visible').count()) > 0 ||
(await this.page.locator('.ant-modal:visible').count()) > 0 ||
(await this.page.locator('.ant-drawer-open').count()) > 0;
return { hintVisible, overlayOpen };
}
async probeUnauthAccess(): Promise<{ onLogin: boolean; hasBusinessRow: boolean }> {
await this.page.goto(this.meta.path, { waitUntil: 'domcontentloaded' });
await this.page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => undefined);
const onLogin = /\/login/i.test(this.page.url());
const hasBusinessRow = await this.page
.locator('.ant-table-tbody tr.ant-table-row, .ant-tree-title')
.first()
.or(this.page.getByText('默认审批流'))
.isVisible()
.catch(() => false);
return { onLogin, hasBusinessRow };
}
async visibleSelectOptions(label: string): Promise<string[]> {
const select = this.formSelect(label);
if (!(await select.isVisible().catch(() => false))) return [];
await this.click(select, `打开${label}`);
const options = this.page.locator('.ant-select-dropdown:visible .ant-select-item-option');
const count = await options.count();
const names: string[] = [];
for (let i = 0; i < Math.min(count, 40); i += 1) {
const text = ((await options.nth(i).innerText()) ?? '').replace(/\s/g, '');
if (text) names.push(text);
}
await this.page.keyboard.press('Escape').catch(() => undefined);
return names;
}
}
...@@ -21,7 +21,6 @@ import { ...@@ -21,7 +21,6 @@ import {
import { env } from './config/env.config'; import { env } from './config/env.config';
import { applyPlaywrightBrowsersPath } from './config/playwright-browsers-path'; import { applyPlaywrightBrowsersPath } from './config/playwright-browsers-path';
import { timeouts } from './config/timeouts.config'; import { timeouts } from './config/timeouts.config';
import { AUTH_STATE_PATH } from './utils/path.util';
applyPlaywrightBrowsersPath(); applyPlaywrightBrowsersPath();
...@@ -35,10 +34,8 @@ function resolveGrepInvert(): RegExp | undefined { ...@@ -35,10 +34,8 @@ function resolveGrepInvert(): RegExp | undefined {
return new RegExp(parts.join('|')); return new RegExp(parts.join('|'));
} }
/** 调试/演示专用 *.stat-*.spec.ts 与主 spec 重复;CI 默认不跑,本地按文件执行不受影响。 */ /** 历史调试文件 *.stat-*.spec.ts 与主 spec 重复,一律不收集。 */
function resolveDebugSpecIgnore(): RegExp[] { function resolveDebugSpecIgnore(): RegExp[] {
if (process.env.INCLUDE_DEBUG_SPECS === '1') return [];
if (!isCi() && process.env.EXCLUDE_DEBUG_SPECS !== '1') return [];
return [/\.stat-[^/]+\.spec\.ts$/]; return [/\.stat-[^/]+\.spec\.ts$/];
} }
...@@ -65,6 +62,7 @@ function buildReporters(): PlaywrightTestConfig['reporter'] { ...@@ -65,6 +62,7 @@ function buildReporters(): PlaywrightTestConfig['reporter'] {
} }
reporters.push(['./reporters/flake.reporter.ts']); reporters.push(['./reporters/flake.reporter.ts']);
reporters.push(['./reporters/failure-trend.reporter.ts']);
reporters.push(['./reporters/gate.reporter.ts']); reporters.push(['./reporters/gate.reporter.ts']);
return reporters; return reporters;
...@@ -92,13 +90,16 @@ export default defineConfig({ ...@@ -92,13 +90,16 @@ export default defineConfig({
actionTimeout: timeouts.action, actionTimeout: timeouts.action,
}, },
projects: [ projects: [
// 一次性登录 → 写入 auth/user.json;业务 project 只读文件,不共享 BrowserContext
{ {
name: 'setup', name: 'setup',
testMatch: /auth\.setup\.ts/, testMatch: /auth\.setup\.ts/,
timeout: timeouts.authSetup,
}, },
{ {
name: 'login', name: 'login',
testMatch: /login\.spec\.ts/, testMatch: /login\.spec\.ts/,
dependencies: ['setup'],
use: { use: {
browserName: 'chromium', browserName: 'chromium',
storageState: { cookies: [], origins: [] }, storageState: { cookies: [], origins: [] },
...@@ -110,7 +111,7 @@ export default defineConfig({ ...@@ -110,7 +111,7 @@ export default defineConfig({
testIgnore: [/auth\.setup\.ts/, /login\.spec\.ts/, ...resolveDebugSpecIgnore()], testIgnore: [/auth\.setup\.ts/, /login\.spec\.ts/, ...resolveDebugSpecIgnore()],
use: { use: {
browserName: 'chromium', browserName: 'chromium',
storageState: AUTH_STATE_PATH, // storageState 由 fixtures/index.ts 按 worker 注入(支持双账号)
}, },
}, },
], ],
......
import path from 'node:path';
import type { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter';
import {
isFrequentFailure,
mergeAndSaveFailureStore,
recordOutcome,
refreshFrequentFailuresReport,
loadFailureStore,
} from '../core/failure-tracker';
/**
* 失败频次趋势 reporter(权威计数):
* - reporter 的 onTestEnd 在主进程**每次用例结束都会触发**,即使 fixture/浏览器启动失败也不会漏,
* 因此把「跨构建累计 + 落盘」放在这里最稳。
* - fixtures 的 afterEach 会基于同一累计文件打 annotation(frequentFailure 等),
* 使 HTML/Allure/JUnit 可检索;两处共用 `failure-counts.json`,reporter 负责最终落盘。
* - onEnd 刷新 markdown 榜单并在日志打印命中项。
*/
export default class FailureTrendReporter implements Reporter {
private store = loadFailureStore();
private dirty = false;
private frequent = new Map<string, string>();
onTestEnd(test: TestCase, result: TestResult): void {
if (test.parent.project()?.name === 'setup') return;
const cls = test.annotations.find((item) => item.type === 'failureClass')?.description;
const stat = recordOutcome(this.store, {
title: test.title,
file: test.location.file,
status: result.status,
failureClass: cls,
});
if (!stat) return;
this.dirty = true;
if (isFrequentFailure(stat, this.store)) {
const rate = stat.runs > 0 ? stat.failed / stat.runs : 0;
this.frequent.set(
stat.id,
`${stat.id} ${stat.failed}/${stat.runs} (${(rate * 100).toFixed(0)}%) ${shortTitle(test.title)}`,
);
}
}
onEnd(_result: FullResult): void {
if (this.dirty) mergeAndSaveFailureStore(this.store);
const file = refreshFrequentFailuresReport();
if (!this.frequent.size) return;
// eslint-disable-next-line no-console
console.log(
`[failure-trend] 经常失败的用例 ${this.frequent.size} 条(榜单 ${path.relative(process.cwd(), file)}):`,
);
for (const item of [...this.frequent.values()].slice(0, 20)) {
// eslint-disable-next-line no-console
console.log(` - ${item}`);
}
}
}
function shortTitle(title: string): string {
return title.length > 80 ? `${title.slice(0, 77)}...` : title;
}
# -*- coding: utf-8 -*-
"""为 13 个基础管理页 PO 追加「组合查询」方法(按实测筛选区字段,幂等)。"""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# 字段来自 2026-10-20 实测(.ant-form-item label),kind: text / select / dateRange
PAGES = {
"admit-product-list.page.ts": dict(
cls="AdmitProductListPage",
method="filterCombination",
before=" async filterByProductOrFallback()",
fields=[
("制单日期", "dateRange"),
("销售商", "text"),
("耗材类型", "select"),
("器械注册人", "text"),
("注册证号", "text"),
("供应商", "text"),
],
value="样本",
),
"admit-material-list.page.ts": dict(
cls="AdmitMaterialListPage",
method="filterCombination",
before=None,
fields=[
("制单日期", "dateRange"),
("销售商", "text"),
("耗材类型", "select"),
("器械注册人", "text"),
("注册证号", "text"),
("供应商", "text"),
],
value="样本",
),
"admit-supply.page.ts": dict(
cls="AdmitSupplyPage",
method="filterCombination",
before=None,
fields=[
("制单日期", "dateRange"),
("销售商", "select"),
("供应商", "select"),
("变更单号", "text"),
("物料名称", "text"),
("规格型号", "text"),
],
value="样本",
),
"admit-task.page.ts": dict(
cls="AdmitTaskPage",
method="filterCombination",
before=None,
fields=[
("新增日期", "dateRange"),
("操作日期", "dateRange"),
("销售商", "text"),
("物料名称", "text"),
("注册证号", "text"),
("操作人", "text"),
("器械注册人", "text"),
("耗材类型", "select"),
("规格型号", "text"),
("准入类型", "select"),
("准入状态", "select"),
],
value="样本",
),
"vendor-qual.page.ts": dict(
cls="VendorQualPage",
method="filterCombination",
before=None,
fields=[
("进院供应商名称", "text"),
("证件类型", "select"),
("授权书名称", "text"),
("授权单位", "text"),
("被授权单位", "text"),
],
value="样本",
),
"vendor-auth-first.page.ts": dict(
cls="VendorAuthFirstPage",
method="filterCombination",
before=None,
fields=[
("制单日期", "dateRange"),
("单号", "text"),
("进院供应商", "select"),
("预转供应商", "text"),
("状态", "select"),
("变更类型", "select"),
("原供应商", "text"),
("医院ID", "text"),
("医院名称", "text"),
],
value="样本",
),
"vendor-auth-final.page.ts": dict(
cls="VendorAuthFinalPage",
method="filterCombination",
before=None,
fields=[
("制单日期", "dateRange"),
("单号", "text"),
("进院供应商", "select"),
("预转供应商", "text"),
("状态", "select"),
("变更类型", "select"),
("原供应商", "text"),
],
value="样本",
),
"vendor-cert-first.page.ts": dict(
cls="VendorCertFirstPage",
method="filterCombination",
before=None,
fields=[
("制单日期", "dateRange"),
("单号", "text"),
("变更类型", "select"),
("注册证号(新)", "text"),
("状态", "select"),
("变更情况具体说明", "text"),
],
value="样本",
),
"vendor-cert-final.page.ts": dict(
cls="VendorCertFinalPage",
method="filterCombination",
before=None,
fields=[
("制单日期", "dateRange"),
("单号", "text"),
("变更类型", "select"),
("注册证号(新)", "text"),
("状态", "select"),
],
value="样本",
),
"vendor-cert-diff.page.ts": dict(
cls="VendorCertDiffPage",
method="filterCombination",
before=" async filterByMaterialSample()",
fields=[
("物料名称", "text"),
("规格型号", "text"),
("状态", "select"),
("望海注册证号", "text"),
("是否医疗器械", "select"),
("生产厂家", "text"),
("FLI+注册证号", "text"),
("27位医保编码", "text"),
],
value="样本",
),
"vendor-eval-scheme.page.ts": dict(
cls="VendorEvalSchemePage",
method="filterCombination",
before=None,
fields=[
("创建日期", "dateRange"),
("启用日期", "dateRange"),
("方案编码", "text"),
("方案名称", "text"),
],
value="样本",
),
"vendor-eval-indicator.page.ts": dict(
cls="VendorEvalIndicatorPage",
method="filterCombination",
before=None,
fields=[
("指标编码", "text"),
("指标名称", "text"),
],
value="样本",
),
"surgery-base-pkg.page.ts": dict(
cls="SurgeryBasePkgPage",
method="filterCombination",
before=None,
fields=[
("产品名称", "text"),
("规格型号", "text"),
("器械注册人", "text"),
("耗材类型", "select"),
("是否收费", "select"),
],
value="样本",
),
"surgery-std-pkg.page.ts": dict(
cls="SurgeryStdPkgPage",
method="filterCombination",
before=None,
fields=[
("产品名称", "text"),
("规格型号", "text"),
("器械注册人", "text"),
("耗材类型", "select"),
("是否收费", "select"),
],
value="样本",
),
}
def method_src(spec: dict) -> str:
rows = "\n".join(
f" {{ label: '{label}', kind: '{kind}', value: '{spec['value']}' }},"
for label, kind in spec["fields"]
)
names = "、".join(lbl for lbl, _ in spec["fields"])
return f""" /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async {spec['method']}(): Promise<void> {{
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{rows}
],
{{ scope }},
);
expect('{names}'.split('、').length, '组合条件应至少 2 个').toBeGreaterThanOrEqual(2);
}}
"""
def add_import(text: str) -> str:
if "type FilterField" in text or "applyFilterCombination" in text:
return text
# PO 通过基类继承方法,不需要 import FilterField;此处仅确保 expect 可用
if "import { expect" not in text and "import type { Page }" in text:
text = text.replace("import type { Page } from '@playwright/test';", "import { expect, type Page } from '@playwright/test';")
return text
changed = 0
for filename, spec in PAGES.items():
path = ROOT / "pages/spd" / filename
text = path.read_text(encoding="utf-8")
if f"async {spec['method']}()" in text:
print(f"跳过(已存在) {filename}")
continue
text = add_import(text)
snippet = method_src(spec)
if spec["before"] and spec["before"] in text:
text = text.replace(spec["before"], snippet + spec["before"], 1)
else:
# 追加到类末尾
text = text.rstrip()
assert text.endswith("}"), filename
text = text[:-1].rstrip() + "\n\n" + snippet.rstrip() + "\n}\n"
path.write_text(text, encoding="utf-8")
changed += 1
print(f"已写入 {filename}")
print(f"共 {changed} 个 PO")
# -*- coding: utf-8 -*-
"""列出每个基础管理页面扫描产物中的筛选类字段(输入框 / select / 日期 / 下拉触发器)。"""
import glob
import json
import os
import re
import sys
SKIP = re.compile(r"^(确 认|确 定|取 消|查 询|重 置|筛 选|新增|添加|导 出|保 存|关 闭|刷 新)$")
def load(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def fields(doc):
out = []
for e in doc.get("elements", []):
tag = (e.get("tag") or "").lower()
typ = (e.get("type") or "").lower()
eid = e.get("id") or ""
label = (e.get("label") or "").strip()
ph = (e.get("placeholder") or "").strip()
if eid.startswith(("form_item_", "table_search_form_", "basic_", "search_")):
out.append(("id", eid, label or ph))
elif tag == "input" and typ not in ("hidden", "button", "checkbox", "radio"):
out.append(("input", ph or label, e.get("name") or ""))
elif tag == "select" or typ == "select":
out.append(("select", ph or label, ""))
elif "date" in (e.get("className") or "") or "日期" in (label + ph):
out.append(("date", label or ph, ""))
seen, uniq = set(), []
for it in out:
key = (it[1], it[2])
if key in seen or not (it[1] or it[2]):
continue
if SKIP.match(it[1].strip()):
continue
seen.add(key)
uniq.append(it)
return uniq
for path in sorted(glob.glob("data/scan/*-elements.json")):
if not os.path.basename(path).startswith(("basic-", "admit-", "vendor-", "surgery-")):
continue
doc = load(path)
fs = fields(doc)
print(f"\n### {os.path.basename(path)} ({len(fs)} 个)")
for kind, a, b in fs[:40]:
print(f" {kind:7s} {a:36s} {b}")
"""为已实现 filterCombination 的页面补「组合查询」用例行(CSV + spec 注释同步)。
字段名直接取自各 PO 的 filterCombination,且已在真实环境验证全部生效。
"""
from __future__ import annotations
import csv
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# (CSV 文件名片段, 用例编号前缀, PO 文件, spec 文件, fixture, 前置条件里的 path 描述)
TARGETS = [
("产品准入单列表", "ADMIT-PLS", "admit-product-list.page.ts", "admit-product-list.spec.ts", "admitProductListPage"),
("物料准入单列表", "ADMIT-MLS", "admit-material-list.page.ts", "admit-material-list.spec.ts", "admitMaterialListPage"),
("供货目录申请", "ADMIT-SUP", "admit-supply.page.ts", "admit-supply.spec.ts", "admitSupplyPage"),
("准入任务分配", "ADMIT-TSK", "admit-task.page.ts", "admit-task.spec.ts", "admitTaskPage"),
("基础包维护", "SURG-BASE", "surgery-base-pkg.page.ts", "surgery-base-pkg.spec.ts", "surgeryBasePkgPage"),
("标准套包维护", "SURG-STD", "surgery-std-pkg.page.ts", "surgery-std-pkg.spec.ts", "surgeryStdPkgPage"),
("供应商资质管理", "VENDOR-QUL", "vendor-qual.page.ts", "vendor-qual.spec.ts", "vendorQualPage"),
("授权变更初审", "VENDOR-AF1", "vendor-auth-first.page.ts", "vendor-audit.spec.ts", "vendorAuthFirstPage"),
("授权变更终审", "VENDOR-AF2", "vendor-auth-final.page.ts", "vendor-audit.spec.ts", "vendorAuthFinalPage"),
("注册证变更初审", "VENDOR-CF1", "vendor-cert-first.page.ts", "vendor-audit.spec.ts", "vendorCertFirstPage"),
("注册证变更终审", "VENDOR-CF2", "vendor-cert-final.page.ts", "vendor-audit.spec.ts", "vendorCertFinalPage"),
("注册证差异对比", "VENDOR-CDF", "vendor-cert-diff.page.ts", "vendor-cert-diff.spec.ts", "vendorCertDiffPage"),
("评价指标维护", "VENDOR-EVI", "vendor-eval-indicator.page.ts", "vendor-eval-indicator.spec.ts", "vendorEvalIndicatorPage"),
("评价方案维护", "VENDOR-EVS", "vendor-eval-scheme.page.ts", "vendor-eval-scheme.spec.ts", "vendorEvalSchemePage"),
]
def po_fields(po: str) -> list[tuple[str, str]]:
text = (ROOT / "pages/spd" / po).read_text(encoding="utf-8")
block = re.search(r"async filterCombination\(\).*?\n \}", text, re.S)
if not block:
raise SystemExit(f"{po} 缺少 filterCombination")
return re.findall(r"label: '([^']+)', kind: '(\w+)'", block.group(0))
def spec_group(spec: str, prefix: str) -> str:
"""取 spec 中已存在用例所属的 describe 标题,保证新增用例落在同一分组下。"""
text = (ROOT / "tests/spd" / spec).read_text(encoding="utf-8")
found = None
last = None
for line in text.splitlines():
m = re.match(r"\s*test\.describe\('([^']+)'", line)
if m:
found = m.group(1)
if re.search(rf"test\('{prefix}-", line) and found:
last = found
if last and "页面进入" not in last:
return last
return f"{prefix} 筛选与列表"
def build_row(prefix: str, next_no: int, module: str, fields: list[tuple[str, str]], page_desc: str) -> list[str]:
names = [f for f, _ in fields]
steps = ["1. 进入页面并等待列表加载"]
for i, (name, kind) in enumerate(fields, start=2):
if kind == "select":
steps.append(f"{i}. 「{name}」下拉选择任一可选项")
elif kind == "dateRange":
steps.append(f"{i}. 「{name}」日期区间选择当天")
else:
steps.append(f"{i}. 「{name}」填写关键字")
steps.append(f"{len(fields) + 2}. 点击「查询」")
steps.append(f"{len(fields) + 3}. 检查列表返回结果")
cond = (
f"测试环境可访问;已通过 auth.setup 写入 storageState 登录;具备「{module}」权限;"
f"已进入{page_desc};筛选区已展开"
)
expect = (
f"多条件同时生效:{('、'.join(names))} 均成功录入/选中;点击查询后请求携带全部条件(页面无报错提示),"
f"列表按条件刷新并呈现匹配数据或明确空态,条件之间为「且」关系"
)
return [
f"{prefix}-{next_no:03d}",
module,
f"{module.split(' · ')[-1]} · 组合查询",
f"多条件组合查询({names[0]}等{len(names)}项)",
"高",
cond,
"\n".join(steps),
expect,
"待执行",
]
def append_csv(path: Path, row: list[str]) -> None:
with path.open("a", encoding="utf-8", newline="") as fh:
csv.writer(fh, quoting=csv.QUOTE_ALL, lineterminator="\n").writerow(row)
def main() -> None:
for frag, prefix, po, spec, fixture in TARGETS:
matches = list((ROOT / "testcase").glob(f"基础管理-*{frag}-UI自动化测试用例-01.csv"))
if len(matches) != 1:
raise SystemExit(f"CSV 匹配异常:{frag} -> {matches}")
csv_path = matches[0]
rows = list(csv.reader(csv_path.open(encoding="utf-8")))
if any(r and r[0].startswith(f"{prefix}-") and "组合查询" in r[2] for r in rows[1:]):
print(f"跳过(已有组合查询):{csv_path.name}")
continue
ids = [int(r[0].split("-")[-1]) for r in rows[1:] if r and r[0].startswith(prefix + "-")]
next_no = max(ids) + 1
module = rows[1][1]
fields = po_fields(po)
page_desc = f"{module.split(' · ')[-1]}页"
append_csv(csv_path, build_row(prefix, next_no, module, fields, page_desc))
group = spec_group(spec, prefix)
print(f"{prefix}-{next_no:03d} {csv_path.name} | {len(fields)} 项 | spec 分组:{group} | {fixture}")
if __name__ == "__main__":
main()
"""把「组合查询」用例插入对应 spec 的筛选分组末尾(幂等,可重复执行)。"""
from __future__ import annotations
import csv
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# CSV 片段 -> (spec 文件, 目标 describe 标题, fixture 名)
TARGETS = {
"产品准入单列表": ("admit-product-list.spec.ts", "产品准入单列表 · 筛选与状态", "admitProductListPage"),
"物料准入单列表": ("admit-material-list.spec.ts", "物料准入单列表 · 筛选与状态", "admitMaterialListPage"),
"供货目录申请": ("admit-supply.spec.ts", "供货目录申请 · 筛选与状态", "admitSupplyPage"),
"准入任务分配": ("admit-task.spec.ts", "准入任务分配 · 筛选与分类", "admitTaskPage"),
"基础包维护": ("surgery-base-pkg.spec.ts", "基础包维护 · 右侧筛选与列表", "surgeryBasePkgPage"),
"标准套包维护": ("surgery-std-pkg.spec.ts", "标准套包维护 · 筛选与构成列表", "surgeryStdPkgPage"),
"供应商资质管理": ("vendor-qual.spec.ts", "供应商资质管理 · 筛选与 Tab", "vendorQualPage"),
"授权变更初审": ("vendor-audit.spec.ts", "授权变更初审 · 筛选与列表", "vendorAuthFirstPage"),
"授权变更终审": ("vendor-audit.spec.ts", "授权变更终审 · 筛选与列表", "vendorAuthFinalPage"),
"注册证变更初审": ("vendor-audit.spec.ts", "注册证变更初审 · 筛选与列表", "vendorCertFirstPage"),
"注册证变更终审": ("vendor-audit.spec.ts", "注册证变更终审 · 筛选与列表", "vendorCertFinalPage"),
"注册证差异对比": ("vendor-cert-diff.spec.ts", "注册证差异对比 · 筛选与对比", "vendorCertDiffPage"),
"评价指标维护": ("vendor-eval-indicator.spec.ts", "评价指标维护 · 树与列表", "vendorEvalIndicatorPage"),
"评价方案维护": ("vendor-eval-scheme.spec.ts", "评价方案维护 · 筛选与维护", "vendorEvalSchemePage"),
}
def find_close_brace(lines: list[str], start: int) -> int:
"""从 describe 起始行找到与之配对的收尾 ` });`。"""
depth = 0
for idx in range(start, len(lines)):
depth += lines[idx].count("{") - lines[idx].count("}")
if depth <= 0 and idx > start:
return idx
raise SystemExit("describe 未闭合")
def main() -> None:
for frag, (spec, group, fixture) in TARGETS.items():
csv_path = next((ROOT / "testcase").glob(f"基础管理-*{frag}-UI自动化测试用例-01.csv"))
rows = [r for r in csv.reader(csv_path.open(encoding="utf-8")) if r]
row = next((r for r in rows[1:] if "组合查询" in r[3]), None)
if not row:
print(f"跳过(CSV 无组合查询行):{csv_path.name}")
continue
case_id, title = row[0], row[3]
spec_path = ROOT / "tests/spd" / spec
text = spec_path.read_text(encoding="utf-8")
if case_id in text:
print(f"跳过(spec 已有):{case_id}")
continue
lines = text.splitlines()
start = next((i for i, l in enumerate(lines) if l.strip().startswith(f"test.describe('{group}'")), None)
if start is None:
raise SystemExit(f"{spec} 未找到分组「{group}」")
close = find_close_brace(lines, start)
block = (
f" test('{case_id} {title}', async ({{ {fixture} }}) => {{\n"
f" await {fixture}.open();\n"
f" await {fixture}.filterCombination();\n"
f" }});"
)
lines.insert(close, block)
spec_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"{case_id} -> {spec} · {group}")
if __name__ == "__main__":
main()
# -*- coding: utf-8 -*-
"""把基础管理 23 份 CSV 对齐样板「出入库明细查询」的写法(仅整形,不改用例语义)。
对齐项(样板 testcase/统计报表-科室出入库报表-出入库明细查询-UI自动化测试用例-01.csv):
1. 前置条件:去掉明文账号 / 密码,统一「已通过 auth.setup 写入 storageState 登录」;
页面统一写成「进入 xxx 页(/路径,无 iframe)」,去掉域名与截图 / 扫描类运行时说明;
若前置已带路径则不做替换(幂等)。
2. 测试步骤:去掉「确认 chromium 已加载 storageState …」「使用用户名 test_1、密码 …」这类
与测试执行器相关的开场步骤,按 1..N 重新编号。
3. 用例编号 / 标题 / 功能测试点 / 优先级 / 预期结果 / 实际结果 一律不动。
用法:
python3.12 scripts/align-basic-cases-format.py # 写盘
python3.12 scripts/align-basic-cases-format.py --check # 只报告
"""
from __future__ import annotations
import csv
import io
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CASE_DIR = ROOT / "testcase"
LOGIN_OK = "已通过 auth.setup 写入 storageState 登录"
# 页面:模块显示名 / 菜单路径 / URL 路径;godoc 里对齐 data/*.data.ts
PAGES: dict[str, dict[str, str]] = {
"公告管理": {"menu": "基础设置 → 医院资料 → 公告管理", "path": "/spd/basic/hospital/paltform/notice"},
"医院信息登记": {"menu": "基础设置 → 医院资料 → 医院信息登记", "path": "/spd/basic/hospital/paltform/info"},
"财务月份设置": {"menu": "基础设置 → 医院资料 → 财务月份设置", "path": "/spd/basic/hospital/balance/month"},
"院区管理": {"menu": "基础设置 → 医院资料 → 院区管理", "path": "/spd/basic/hospital/paltform/area"},
"物料准入管理": {"menu": "基础设置 → 准入管理 → 物料准入管理", "path": "/spd/basic/dataSync/dataSync/productApply/ZRlist"},
"产品准入申请": {"menu": "基础设置 → 准入管理 → 产品准入申请", "path": "/spd/basic/dataSync/dataSync/productApply/list"},
"物料准入单列表": {"menu": "基础设置 → 准入管理 → 物料准入单列表", "path": "/spd/basic/dataSync/dataSync/productOrder/zrlist"},
"产品准入单列表": {"menu": "基础设置 → 准入管理 → 产品准入单列表", "path": "/spd/basic/dataSync/dataSync/productOrder/list"},
"供货目录申请": {"menu": "基础设置 → 准入管理 → 供货目录申请", "path": "/spd/basic/dataSync/dataSync/supplyApply/list"},
"准入任务分配": {"menu": "基础设置 → 准入管理 → 准入任务分配", "path": "/spd/basic/dataSync/dataSync/accessKind/list"},
"厂商查询": {"menu": "基础设置 → 厂商管理 → 厂商查询", "path": "/spd/basic/mfrs/paltform/mfrsName/factory"},
"供应商资质管理": {"menu": "基础设置 → 厂商管理 → 供应商资质管理", "path": "/spd/basic/mfrs/basic/qcManage"},
"授权变更初审": {"menu": "基础设置 → 厂商管理 → 授权变更初审", "path": "/spd/basic/mfrs/paltform/mfrsName/authAudit"},
"授权变更终审": {"menu": "基础设置 → 厂商管理 → 授权变更终审", "path": "/spd/basic/mfrs/paltform/mfrsName/authAuditFinal"},
"注册证变更初审": {"menu": "基础设置 → 厂商管理 → 注册证变更初审", "path": "/spd/basic/mfrs/paltform/mfrsName/regCertAudit"},
"注册证变更终审": {"menu": "基础设置 → 厂商管理 → 注册证变更终审", "path": "/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal"},
"注册证差异对比": {"menu": "基础设置 → 厂商管理 → 注册证差异对比", "path": "/spd/basic/mfrs/scjs/yc/certCompare"},
"评价方案维护": {"menu": "基础设置 → 厂商管理 → 评价方案维护", "path": "/spd/basic/mfrs/paltform/mfrsName/evaluationPlan"},
"评价指标维护": {"menu": "基础设置 → 厂商管理 → 评价指标维护", "path": "/spd/basic/mfrs/paltform/mfrsName/indicators"},
"供应商节假日管理": {"menu": "基础设置 → 厂商管理 → 供应商节假日管理", "path": "/spd/basic/mfrs/basic/prov/holiday"},
"基础包维护": {"menu": "基础设置 → 手术套包设置 → 基础包维护", "path": "/spd/basic/operationSetting/baseMaintenance"},
"术种维护": {"menu": "基础设置 → 手术套包设置 → 术种维护", "path": "/spd/basic/operationSetting/typeMaintenance"},
"标准套包维护": {"menu": "基础设置 → 手术套包设置 → 标准套包维护", "path": "/spd/basic/operationSetting/standardMaintenance"},
}
CRED_RE = re.compile(
r"(正例账号[^)]*)" # (正例账号 xxx / ****)
r"|用户名\s*\S+、密码\s*[^\s;;,,))]+" # 用户名 xxx、密码 ****
r"|密码\s*[A-Za-z0-9!@#$%^&*._-]{6,}" # 单独的明文密码片段
)
RUNTIME_STEP_RE = re.compile(
r"^\s*\d+\.\s*(确认 chromium|使用用户名|使用 test\.use|storageState 已有效|浏览器上下文)",
re.M,
)
PAGE_PRE_RE = re.compile(r"已(?:进入|直接访问)([^;;((]+?)页(?:([^)]*))?")
NUMBER_RE = re.compile(r"^\s*(\d+)\.\s*(.*)$", re.M)
URL_BACKTICK_RE = re.compile(r"`https?://[^/`]+(/[^`]*)`")
def page_of(filename: str) -> str:
tail = filename.replace("-UI自动化测试用例-01.csv", "")
return tail.rsplit("-", 1)[-1]
def has_path(pre: str) -> bool:
return bool(re.search(r"(/|(http|/spd/", pre))
PATH_PRE_RE = re.compile(r"已进入([^;;(]+?)页([^)]*)")
BAD_FRAG_RE = re.compile(r";;|登录已通过")
PATH_TAIL_RE = re.compile(r",无 iframe)$")
def _balanced(text: str) -> bool:
return text.count("(") == text.count(")")
def clean_pre(pre: str, page: str) -> str:
info = PAGES[page]
# 1) 先去掉凭据,统一成「已通过 auth.setup 写入 storageState 登录」
text = CRED_RE.sub(LOGIN_OK, pre)
# 旧写法「已通过 auth.setup 写入 storageState 登录 已登录并进入 …页」→ 只保留一份
text = text.replace(f"{LOGIN_OK} 已登录并进入", f"{LOGIN_OK};已进入")
text = text.replace(f"{LOGIN_OK}已登录并进入", f"{LOGIN_OK};已进入")
# 2) 页面括号统一成样板写法(先于分句,避免括号被拆开)
text = re.sub(r"((?:https?://|/spd/)[^)]*)", f"({info['path']},无 iframe)", text)
text = PATH_PRE_RE.sub(lambda m: f"已进入{m.group(1)}页({info['path']},无 iframe)", text)
# 3) 分句去重
kept: list[str] = []
seen_login = False
for part in (p.strip() for p in text.split(";")):
if not part:
continue
if LOGIN_OK in part:
extra = part.replace(LOGIN_OK, "").strip("() ")
if seen_login:
if not extra:
continue
part = extra
else:
part = f"{LOGIN_OK}" + (f"({extra})" if extra and not extra.startswith("无 iframe") else "")
seen_login = True
if re.fullmatch(r"已登录.*", part) or part.startswith("已登录且具备"):
continue
if part.startswith("并进入"):
part = "已进入" + part[len("并进入"):]
kept.append(part)
text = ";".join(kept)
text = re.sub(r";{2,}", ";", text).strip(";")
# 4) 未写页面时补一句「已进入 xxx 页(路径,无 iframe)」;未登录反例只写目标 URL
if not has_path(text):
parts = [p for p in text.split(";") if p.strip()]
if "无登录态" in text or "未带登录态" in text or "清除登录态" in text:
parts.append(f"直接访问{page} URL({info['path']},无 iframe)")
return ";".join(dict.fromkeys(parts))
anchor = f"已进入{page}页({info['path']},无 iframe)"
insert_at = 1
for i, part in enumerate(parts):
if "权限" in part:
insert_at = i + 1
parts.insert(insert_at, anchor)
text = ";".join(parts)
# 5) 页面句只保留第一处;其后的「已进入…页」分句整体丢弃
parts = [p.strip() for p in text.split(";") if p.strip()]
out: list[str] = []
page_seen = False
for part in parts:
is_page_part = bool(PATH_PRE_RE.search(part)) or (
part.startswith("已进入") and page in part
)
if is_page_part:
if page_seen:
continue
page_seen = True
out.append(part)
text = ";".join(out)
# 6) 收尾收敛:未登录反例统一「直接访问{页面} URL(路径,无 iframe)」
text = re.sub(rf"直接访问\s*{re.escape(page)}\s*URL([^)]*),?", f"直接访问{page} URL({info['path']},无 iframe)", text)
page_hits = re.findall(rf"已进入(?:{re.escape(page)}|{re.escape(info['path'])}|[^;]*?页)", text)
if len(page_hits) > 1:
raise ValueError(f"[{page}] 前置出现多个页面句:{text}")
if BAD_FRAG_RE.search(text) or not _balanced(text):
raise ValueError(f"[{page}] 前置整形后仍异常:{text}")
return text
def clean_steps(steps: str) -> str:
lines = steps.split("\n")
kept = [ln for ln in lines if not RUNTIME_STEP_RE.match(ln)]
out = []
for i, ln in enumerate(kept, 1):
m = NUMBER_RE.match(ln)
body = m.group(2) if m else ln.strip()
if body:
body = URL_BACKTICK_RE.sub(r"`\1`", body)
out.append(f"{i}. {body}")
return "\n".join(out)
def main() -> None:
check_only = "--check" in sys.argv
changed = 0
for path in sorted(CASE_DIR.glob("基础管理-*-UI自动化测试用例-01.csv")):
page = page_of(path.name)
if page not in PAGES:
print(f"跳过未知页面:{path.name}")
continue
with path.open(encoding="utf-8-sig", newline="") as fh:
rows = list(csv.reader(fh))
head, body = rows[0], rows[1:]
if head[:9] != ["用例编号", "功能模块", "功能测试点", "用例标题", "优先级", "前置条件", "测试步骤", "预期结果", "实际结果"]:
raise SystemExit(f"{path.name} 表头与 9 列规范不一致:{head}")
new_rows = []
for r in body:
if not r:
continue
row = list(r) + [""] * (9 - len(r))
row[5] = clean_pre(row[5], page)
row[6] = clean_steps(row[6])
new_rows.append(row[:9])
buf = io.StringIO()
writer = csv.writer(buf, quoting=csv.QUOTE_ALL, lineterminator="\n")
writer.writerow(head[:9])
writer.writerows(new_rows)
text = buf.getvalue()
old = path.read_text(encoding="utf-8")
if old.lstrip("\ufeff") == text:
continue
changed += 1
print(f"{'待更新' if check_only else '已写入'} {path.name}")
if not check_only:
path.write_text(text, encoding="utf-8", newline="")
print(f"共 {changed} 份{'需要' if check_only else '已'}更新")
if __name__ == "__main__":
main()
#!/usr/bin/env node
/** CSV 用例编号+标题必须出现在对应 spec 中。 */
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PAIRS = [
['testcase/基础管理-医院资料-公告管理-UI自动化测试用例-01.csv', 'tests/spd/basic-notice.spec.ts'],
['testcase/基础管理-医院资料-医院信息登记-UI自动化测试用例-01.csv', 'tests/spd/basic-hospital-info.spec.ts'],
['testcase/基础管理-医院资料-财务月份设置-UI自动化测试用例-01.csv', 'tests/spd/basic-finance-month.spec.ts'],
['testcase/基础管理-医院资料-院区管理-UI自动化测试用例-01.csv', 'tests/spd/basic-branch.spec.ts'],
['testcase/基础管理-准入管理-物料准入管理-UI自动化测试用例-01.csv', 'tests/spd/admit-material.spec.ts'],
['testcase/基础管理-准入管理-产品准入申请-UI自动化测试用例-01.csv', 'tests/spd/admit-product-apply.spec.ts'],
['testcase/基础管理-准入管理-物料准入单列表-UI自动化测试用例-01.csv', 'tests/spd/admit-material-list.spec.ts'],
['testcase/基础管理-准入管理-产品准入单列表-UI自动化测试用例-01.csv', 'tests/spd/admit-product-list.spec.ts'],
['testcase/基础管理-准入管理-供货目录申请-UI自动化测试用例-01.csv', 'tests/spd/admit-supply.spec.ts'],
['testcase/基础管理-准入管理-准入任务分配-UI自动化测试用例-01.csv', 'tests/spd/admit-task.spec.ts'],
['testcase/基础管理-用户管理-用户管理-UI自动化测试用例-01.csv', 'tests/spd/basic-user.spec.ts'],
['testcase/基础管理-用户管理-医共体用户管理-UI自动化测试用例-01.csv', 'tests/spd/basic-user-union.spec.ts'],
['testcase/基础管理-用户管理-角色管理-UI自动化测试用例-01.csv', 'tests/spd/basic-role.spec.ts'],
];
function parseCsv(text) {
const rows = [];
let row = [];
let cell = '';
let i = 0;
let quoted = false;
const pushCell = () => {
row.push(cell);
cell = '';
};
const pushRow = () => {
if (row.length) rows.push(row);
row = [];
};
while (i < text.length) {
const ch = text[i];
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"';
i += 2;
continue;
}
if (ch === '"') {
quoted = false;
i += 1;
continue;
}
cell += ch;
i += 1;
continue;
}
if (ch === '"') {
quoted = true;
i += 1;
continue;
}
if (ch === ',') {
pushCell();
i += 1;
continue;
}
if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i += 1;
pushCell();
pushRow();
i += 1;
continue;
}
cell += ch;
i += 1;
}
if (cell || row.length) {
pushCell();
pushRow();
}
return rows;
}
const missing = [];
for (const [csvRel, specRel] of PAIRS) {
const csvText = readFileSync(path.join(ROOT, csvRel), 'utf8').replace(/^\uFEFF/, '');
const spec = readFileSync(path.join(ROOT, specRel), 'utf8');
const table = parseCsv(csvText);
const header = table[0] || [];
const idIdx = header.indexOf('用例编号');
const titleIdx = header.indexOf('用例标题');
const pointIdx = header.indexOf('功能测试点');
const modIdx = header.indexOf('功能模块');
for (const cols of table.slice(1)) {
if (!cols[idIdx]) continue;
const title = `${cols[idIdx]} ${cols[titleIdx]}`;
if (!spec.includes(title)) missing.push(`${specRel}: ${title}`);
if (cols[pointIdx] && !spec.includes(cols[pointIdx])) {
missing.push(`${specRel}: describe ${cols[pointIdx]}`);
}
if (cols[modIdx] && !spec.includes(cols[modIdx])) {
missing.push(`${specRel}: module ${cols[modIdx]}`);
}
}
}
if (missing.length) {
console.error('[gate] CSV 与 spec 不一致:');
for (const item of missing) console.error(` ${item}`);
process.exit(1);
}
console.log('[gate] CSV ↔ spec 标题一致');
#!/usr/bin/env node
/** 手术套包设置 CSV 用例编号+标题+功能测试点+功能模块 必须出现在对应 spec 中。 */
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PAIRS = [
['testcase/基础管理-手术套包设置-基础包维护-UI自动化测试用例-01.csv', 'tests/spd/surgery-base-pkg.spec.ts'],
['testcase/基础管理-手术套包设置-术种维护-UI自动化测试用例-01.csv', 'tests/spd/surgery-oper-type.spec.ts'],
['testcase/基础管理-手术套包设置-标准套包维护-UI自动化测试用例-01.csv', 'tests/spd/surgery-std-pkg.spec.ts'],
];
function parseCsv(text) {
const rows = [];
let row = [];
let cell = '';
let i = 0;
let quoted = false;
const pushCell = () => {
row.push(cell);
cell = '';
};
const pushRow = () => {
if (row.length) rows.push(row);
row = [];
};
while (i < text.length) {
const ch = text[i];
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"';
i += 2;
continue;
}
if (ch === '"') {
quoted = false;
i += 1;
continue;
}
cell += ch;
i += 1;
continue;
}
if (ch === '"') {
quoted = true;
i += 1;
continue;
}
if (ch === ',') {
pushCell();
i += 1;
continue;
}
if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i += 1;
pushCell();
pushRow();
i += 1;
continue;
}
cell += ch;
i += 1;
}
if (cell || row.length) {
pushCell();
pushRow();
}
return rows;
}
const missing = [];
let total = 0;
for (const [csvRel, specRel] of PAIRS) {
const csvText = readFileSync(path.join(ROOT, csvRel), 'utf8').replace(/^\uFEFF/, '');
const spec = readFileSync(path.join(ROOT, specRel), 'utf8');
const table = parseCsv(csvText);
const header = table[0] || [];
const idIdx = header.indexOf('用例编号');
const titleIdx = header.indexOf('用例标题');
const pointIdx = header.indexOf('功能测试点');
const modIdx = header.indexOf('功能模块');
for (const cols of table.slice(1)) {
if (!cols[idIdx]) continue;
total += 1;
const title = `${cols[idIdx]} ${cols[titleIdx]}`;
if (!spec.includes(title)) missing.push(`${specRel}: ${title}`);
if (cols[pointIdx] && !spec.includes(cols[pointIdx])) {
missing.push(`${specRel}: describe ${cols[pointIdx]}`);
}
if (cols[modIdx] && !spec.includes(cols[modIdx])) {
missing.push(`${specRel}: module ${cols[modIdx]}`);
}
}
}
if (missing.length) {
console.error('[gate] 手术套包设置 CSV 与 spec 不一致:');
for (const item of missing) console.error(` ${item}`);
process.exit(1);
}
console.log(`[gate] 手术套包设置 CSV ↔ spec 一致(${total} 条)`);
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