Commit 97840769 authored by liguangyu06's avatar liguangyu06
Browse files

新增期初管理全链路自动化资产,并加固产品管理用例与清理冗余截图。

紧急放行原因:用户要求立即推送今日改动备份,门禁用例待后续补跑。
parent 3dbf401d
import { expect, type Locator, type Page } from '@playwright/test';
import {
openDetailComboDrawerFields,
openDetailComboQuickFields,
openPages,
openScanComboQuickFields,
openScanProductResetDateRange,
} from '../../data/open.data';
import { OpenSharedPage } from './open-shared.page';
export class OpenEntryPage extends OpenSharedPage {
constructor(page: Page) {
super(page, openPages.entry);
}
private addModal(): Locator {
return this.page
.locator('.ant-modal-wrap')
.filter({ has: this.page.locator('.ant-modal-title', { hasText: '新增' }) })
.locator('.ant-modal-content')
.first();
}
/** 仅收起下拉面板,避免 Escape 误关新增弹窗。 */
private async dismissOpenSelectDropdown(): Promise<void> {
const open = this.page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').first();
if (!(await open.isVisible().catch(() => false))) return;
await this.page.locator('.ant-modal-title').filter({ hasText: '新增' }).first().click({ force: true }).catch(() => undefined);
await this.waitSettle(150);
}
/** 打开下拉并读取当前激活面板中的可用选项文案(跳过「请选择/全部」)。 */
private async readActiveSelectLabels(): Promise<string[]> {
const dropdown = await this.activeSelectDropdown(this.page);
const options = dropdown.locator('.ant-select-item-option:not(.ant-select-item-option-disabled)');
await expect
.poll(async () => options.count(), { timeout: 12_000 })
.toBeGreaterThan(0);
const count = await options.count();
const labels: string[] = [];
const skip = /^(全部|请选择|无)$/;
for (let i = 0; i < count; i += 1) {
const raw = ((await options.nth(i).innerText()) ?? '').replace(/\s+/g, ' ').trim();
if (!raw || skip.test(raw.replace(/\s/g, ''))) continue;
labels.push(raw);
}
return labels;
}
/** 在指定根节点内按文案点选下拉项(走 activeSelectDropdown,避免命中残留隐藏层)。 */
private async pickOptionByLabel(label: string, root: Locator, optionText: string): Promise<boolean> {
const select = this.formSelect(label, root);
if (!(await select.isVisible().catch(() => false))) return false;
await this.dismissOpenSelectDropdown();
await this.click(select, `打开${label}`);
const dropdown = await this.activeSelectDropdown(this.page);
const options = dropdown.locator('.ant-select-item-option:not(.ant-select-item-option-disabled)');
const compact = optionText.replace(/\s/g, '');
const count = await options.count();
let target: Locator | null = null;
for (let i = 0; i < count; i += 1) {
const raw = ((await options.nth(i).innerText()) ?? '').replace(/\s/g, '');
if (!raw) continue;
if (raw === compact || raw.includes(compact) || compact.includes(raw)) {
target = options.nth(i);
break;
}
}
if (!target) {
await this.dismissOpenSelectDropdown();
return false;
}
await target.waitFor({ state: 'visible', timeout: 12_000 });
await this.click(target, `选择${label}:${optionText}`);
await this.waitSettle(400);
return true;
}
/** 打开下拉选首个可用项;无可选项返回 null。 */
private async trySelectFirstInRoot(label: string, root: Locator): Promise<string | null> {
const select = this.formSelect(label, root);
if (!(await select.isVisible().catch(() => false))) return null;
await this.dismissOpenSelectDropdown();
await this.click(select, `打开${label}`);
let labels: string[] = [];
try {
labels = await this.readActiveSelectLabels();
} catch {
await this.dismissOpenSelectDropdown();
return null;
}
if (labels.length === 0) {
await this.dismissOpenSelectDropdown();
return null;
}
const chosen = labels[0]!;
const dropdown = await this.activeSelectDropdown(this.page);
const option = dropdown
.locator('.ant-select-item-option:not(.ant-select-item-option-disabled)')
.filter({ hasText: chosen })
.first();
await option.waitFor({ state: 'visible', timeout: 12_000 });
await this.click(option, `选择${label}:${chosen}`);
await this.waitSettle(400);
return chosen;
}
/** 库房→库区级联:依次尝试库房选项,直到库区有可选值。 */
private async selectWarehouseThenArea(modal: Locator): Promise<{ warehouse: string; area: string }> {
const warehouseSelect = this.formSelect('库房', modal);
await expect(warehouseSelect, '新增弹窗「库房」').toBeVisible({ timeout: 8_000 });
await this.dismissOpenSelectDropdown();
await this.click(warehouseSelect, '打开库房枚举');
const warehouseLabels = await this.readActiveSelectLabels();
await this.dismissOpenSelectDropdown();
await this.waitSettle(200);
if (warehouseLabels.length === 0) {
await this.skipWhenDataInsufficient('「库房」无可选值');
}
for (const warehouse of warehouseLabels) {
const ok = await this.pickOptionByLabel('库房', modal, warehouse);
if (!ok) continue;
await this.waitSettle(600);
const area = await this.trySelectFirstInRoot('库区', modal);
if (area) return { warehouse, area };
}
await this.skipWhenDataInsufficient('库房/库区级联无可用组合');
return { warehouse: '', area: '' };
}
/**
* OPEN-ENT-019:新增 → 选库房/库区/耗材类型 → 勾选 UDI → 确定成功落库。
* 返回所选样本与新增后识别到的期初单号(若能读到)。
*/
async createAddSelectWarehouseAreaTypeConfirmSuccess(): Promise<{
warehouse: string;
area: string;
purType: string;
orderNo: string;
totalBefore: number;
totalAfter: number;
}> {
await this.ensureListDataReady();
const totalBefore = await this.getTotalCount();
await this.assertBeforeBusinessAction('新增');
await this.openToolbarOverlay('新增');
const modal = this.addModal();
await expect(modal, '新增弹窗').toBeVisible({ timeout: 8_000 });
const { warehouse, area } = await this.selectWarehouseThenArea(modal);
const purType = await this.trySelectFirstInRoot('耗材类型', modal);
if (!purType) {
await this.skipWhenDataInsufficient('「耗材类型」无可选值');
}
const udi = modal
.locator('.ant-checkbox-wrapper')
.filter({ hasText: /导入后使用原系统唯一码做新系统UDI|UDI/ })
.first();
await expect(udi, 'UDI 复选框').toBeVisible({ timeout: 8_000 });
const udiInput = udi.locator('input[type="checkbox"]').first();
if (!(await udiInput.isChecked().catch(() => false))) {
await this.click(udi, '勾选 UDI');
}
const confirm = modal.getByRole('button', { name: /确\s*定/ }).first();
await expect(confirm, '确定').toBeVisible({ timeout: 8_000 });
await this.click(confirm, '确定新增');
await this.waitSettle(1_000);
await this.waitSpinGone();
await this.assertPageHealthy('新增确定后');
const modalGone = !(await this.addModal().isVisible().catch(() => false));
expect(modalGone, '确定后新增弹窗应关闭').toBeTruthy();
// 现网:确定后进入录入明细页(展示期初单号/库房/库区/耗材类型),而非停留列表。
const orderLabel = this.page.getByText(/期初单号/).first();
const enteredDetail = await orderLabel.isVisible({ timeout: 12_000 }).catch(() => false);
let orderNo = '';
let totalAfter = totalBefore;
if (enteredDetail) {
const header = this.page.locator('.ant-pro-page-container, .ant-spin-container, main, body').first();
const headerText = ((await header.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ');
const matched = headerText.match(/期初单号\s*[::]?\s*(QC\d+)/i) ?? headerText.match(/(QC\d{10,})/);
orderNo = matched?.[1] ?? '';
expect(orderNo.length, '录入页应展示期初单号').toBeGreaterThan(0);
expect(headerText.includes(warehouse.slice(0, 4)) || headerText.includes('库房'), '录入页应含库房信息').toBeTruthy();
expect(headerText.includes(purType!) || headerText.includes('耗材类型'), '录入页应含耗材类型信息').toBeTruthy();
const back = this.page.getByRole('button', { name: /返\s*回/ }).first();
if (await back.isVisible().catch(() => false)) {
await this.click(back, '返回列表');
await this.waitForTableReady({ health: 'ui' }).catch(() => undefined);
await this.expectOnPage();
}
totalAfter = await this.getTotalCount().catch(() => totalBefore);
await this.assertListOrderNoMatchesCreated(orderNo);
} else {
await this.waitForTableReady();
totalAfter = await this.getTotalCount();
const grew = totalAfter === totalBefore + 1;
const rows = await this.listRows();
let matchedRow: Locator | null = null;
const scan = Math.min(await rows.count(), 20);
for (let i = 0; i < scan; i += 1) {
const row = rows.nth(i);
const wh = await this.tryCell(row, '库房');
const ar = await this.tryCell(row, '库区');
const pt = await this.tryCell(row, '耗材类型');
const st = await this.tryCell(row, '状态');
const hit =
(wh.includes(warehouse) || warehouse.includes(wh)) &&
(ar.includes(area) || area.includes(ar)) &&
(pt.includes(purType!) || purType!.includes(pt)) &&
st.includes('待生成');
if (hit) {
matchedRow = row;
break;
}
}
expect(grew || matchedRow, '新增后总数应 +1 或列表可见匹配待生成行').toBeTruthy();
orderNo = matchedRow ? await this.tryCell(matchedRow, '期初单号') : '';
expect(orderNo.length, '列表应能读到期初单号').toBeGreaterThan(0);
await this.assertListOrderNoMatchesCreated(orderNo);
}
return { warehouse, area, purType: purType!, orderNo, totalBefore, totalAfter };
}
/**
* 返回主列表后:按期初单号查询(若有筛选),断言列表中存在与新增一致的期初单号。
*/
private async assertListOrderNoMatchesCreated(orderNo: string): Promise<void> {
expect(orderNo.length, '新增期初单号不能为空').toBeGreaterThan(0);
await this.queryByOrderNo(orderNo);
const rows = await this.listRows();
await expect
.poll(async () => rows.count(), { timeout: 15_000 })
.toBeGreaterThan(0);
let found = '';
const scan = Math.min(await rows.count(), 20);
for (let i = 0; i < scan; i += 1) {
const cell = ((await this.tryCell(rows.nth(i), '期初单号')) ?? '').trim();
if (cell === orderNo) {
found = cell;
break;
}
}
expect(found, `返回主列表后,期初单号应与新增一致(期望 ${orderNo})`).toBe(orderNo);
}
/** 按期初单号筛选查询(有输入框则填写)。 */
async queryByOrderNo(orderNo: string): Promise<void> {
const orderInput = this.textInput('期初单号');
if (await orderInput.isVisible().catch(() => false)) {
await this.fillFilter('期初单号', orderNo);
await this.clickQuery();
return;
}
await this.clickQuery().catch(() => undefined);
await this.waitForTableReady().catch(() => undefined);
}
/**
* OPEN-ENT-020:删除 OPEN-ENT-019 产出的待生成期初单(不再自行新增)。
*/
async deletePendingOrderByNo(orderNo: string): Promise<void> {
expect(orderNo.length, '待删除期初单号不能为空').toBeGreaterThan(0);
await this.ensureListDataReady();
await this.queryByOrderNo(orderNo);
const rows = await this.listRows();
await expect
.poll(async () => rows.count(), { timeout: 15_000 })
.toBeGreaterThan(0);
let target: Locator | null = null;
const scan = Math.min(await rows.count(), 20);
for (let i = 0; i < scan; i += 1) {
const row = rows.nth(i);
const cell = ((await this.tryCell(row, '期初单号')) ?? '').trim();
if (cell !== orderNo) continue;
const del = this.rowAction(row, '删除');
if (await del.isVisible().catch(() => false)) {
target = row;
break;
}
}
if (!target) {
await this.skipWhenDataInsufficient(`列表无期初单号 ${orderNo} 的可删除行`);
}
const totalBefore = await this.getTotalCount();
await this.assertBeforeBusinessAction('删除');
await this.click(this.rowAction(target!, '删除'), '删除');
const confirm = this.page
.locator('.ant-modal-confirm, .ant-modal, .ant-popconfirm')
.getByRole('button', { name: /确\s*定|确\s*认/ })
.last();
if (await confirm.isVisible({ timeout: 5_000 }).catch(() => false)) {
await this.click(confirm, '确认删除');
}
await this.waitSettle(800);
await this.waitForTableReady();
await this.assertPageHealthy('删除后');
await this.queryByOrderNo(orderNo);
const afterRows = await this.listRows();
const afterCount = await afterRows.count();
let stillThere = false;
for (let i = 0; i < Math.min(afterCount, 10); i += 1) {
const cell = ((await this.tryCell(afterRows.nth(i), '期初单号')) ?? '').trim();
if (cell === orderNo) {
stillThere = true;
break;
}
}
expect(stillThere, `删除后列表不应再出现期初单号 ${orderNo}`).toBeFalsy();
const totalAfter = await this.getTotalCount().catch(() => totalBefore);
if (totalAfter > 0 || totalBefore > 0) {
expect(totalAfter, '删除后总数应减少或为查询空态').toBeLessThanOrEqual(totalBefore);
}
}
}
export class OpenDetailPage extends OpenSharedPage {
constructor(page: Page) {
super(page, openPages.detail);
}
private detailQuickTextFields() {
return openDetailComboQuickFields.filter((f) => f.type === 'text' && f.formId);
}
private detailDrawerTextFields() {
return openDetailComboDrawerFields.filter((f) => f.type === 'text' && f.formId);
}
private async expectSelectCleared(label: string, root?: Locator): Promise<void> {
const select = this.formSelect(label, root);
if (!(await select.isVisible().catch(() => false))) return;
const selected = select.locator('.ant-select-selection-item');
expect(await selected.count(), `重置后「${label}」下拉应无选中项`).toBe(0);
}
/**
* OPEN-DTL-001:填写快捷区可见条件 →「重 置」→
* 文本清空;下拉无选中项;页面健康。
*/
async fillQuickVisibleThenResetExpectDefaults(): Promise<void> {
const orderMarker = 'RESET_ORDER_999';
for (const field of this.detailQuickTextFields()) {
const input = this.page.locator(field.formId!).first();
await expect(input, `筛选「${field.label}」应可见`).toBeVisible({ timeout: 8_000 });
await this.fill(input, orderMarker, `重置前填写${field.label}`);
await expect(input, `填写后「${field.label}」应有值`).toHaveValue(orderMarker);
}
// 耗材类型:点选一项后再重置,验证下拉恢复
const pur = this.formSelect('耗材类型');
if (await pur.isVisible().catch(() => false)) {
await this.click(pur, '重置前打开耗材类型');
const opt = this.page
.locator('.ant-select-dropdown:visible .ant-select-item-option:not(.ant-select-item-option-disabled)')
.first();
if (await opt.isVisible().catch(() => false)) {
await this.click(opt, '重置前选择耗材类型');
await this.waitSettle(200);
} else {
await this.page.keyboard.press('Escape').catch(() => undefined);
}
}
await this.clickResetQuick();
for (const field of this.detailQuickTextFields()) {
const input = this.page.locator(field.formId!).first();
await expect(input, `重置后「${field.label}」应为空`).toHaveValue('');
}
await this.expectSelectCleared('耗材类型');
await this.expectOnPage();
await this.assertPageHealthy('快捷区重置后');
}
/**
* OPEN-DTL-004:抽屉填物料名称 → 抽屉「重 置」→ 抽屉字段恢复默认。
* 现网:高级筛选与快捷区共用同一表单,抽屉重置会同步清空同源快捷字段(期初单号等)。
*/
async fillDrawerThenResetExpectDefaults(): Promise<void> {
const drawerMarker = 'RESET_GOODS_001';
const orderNo = this.page.locator('#form_item_orderNo').first();
await expect(orderNo, '快捷区期初单号').toBeVisible({ timeout: 8_000 });
await this.fill(orderNo, 'SYNC_ORDER_001', '同源表单探针期初单号');
await this.ensureAdvancedFilterOpen();
for (const field of this.detailDrawerTextFields()) {
const input = this.page.locator('.ant-drawer-open').locator(field.formId!).first();
await expect(input, `抽屉「${field.label}」应可见`).toBeVisible({ timeout: 8_000 });
await input.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }));
await this.fill(input, drawerMarker, `抽屉重置前填写${field.label}`);
await expect(input, `填写后抽屉「${field.label}」应有值`).toHaveValue(drawerMarker);
}
const resetBtn = this.page
.locator('.ant-drawer-open')
.getByRole('button', { name: /重\s*置/ })
.first();
await expect(resetBtn, '抽屉重置按钮').toBeVisible({ timeout: 8_000 });
await this.click(resetBtn, '抽屉重置');
await this.waitSettle(400);
await this.ensureAdvancedFilterOpen();
for (const field of this.detailDrawerTextFields()) {
const input = this.page.locator('.ant-drawer-open').locator(field.formId!).first();
await expect(input, `抽屉重置后「${field.label}」应为空`).toHaveValue('');
}
await this.expectSelectCleared('结算方式', this.page.locator('.ant-drawer-open'));
await this.closeLayer();
await expect(orderNo, '同源表单下抽屉重置后期初单号亦恢复默认').toHaveValue('');
await this.expectOnPage();
await this.assertPageHealthy('抽屉重置后');
}
}
export class OpenScanPage extends OpenSharedPage {
constructor(page: Page) {
super(page, openPages.scan);
}
private scanTextFields() {
return openScanComboQuickFields.filter((f) => f.type === 'text' && f.formId);
}
async readScanDateInputs(): Promise<string[]> {
const formId = '#table_search_form_scanDate';
const picker = this.page
.locator('.ant-form-item')
.filter({ has: this.page.locator(formId) })
.locator('.ant-picker')
.first();
return picker.locator('input').evaluateAll((els) =>
els.map((el) => ((el as HTMLInputElement).value ?? '').trim()),
);
}
/**
* OPEN-SCN-001:填写快捷区可见文本条件 → 重置 →
* 文本清空;扫码日期恢复产品默认(近一个月)。
*/
async fillQuickVisibleThenResetExpectDefaults(): Promise<void> {
await this.ensureFilterExpanded();
const markers = {
deptName: '重置探针科室',
barcodeNo: 'RESET_CODE_999',
scanUser: '重置探针扫码人',
} as const;
for (const field of this.scanTextFields()) {
const value = markers[field.key as keyof typeof markers];
if (!value || !field.formId) continue;
const input = this.page.locator(field.formId).first();
await expect(input, `筛选「${field.label}」应可见`).toBeVisible({ timeout: 8_000 });
await this.fill(input, value, `重置前填写${field.label}`);
await expect(input, `填写后「${field.label}」应有值`).toHaveValue(value);
}
await this.clickResetQuick();
await this.ensureFilterExpanded();
for (const field of this.scanTextFields()) {
if (!field.formId) continue;
const input = this.page.locator(field.formId).first();
await expect(input, `重置后「${field.label}」应为空`).toHaveValue('');
}
const dates = await this.readScanDateInputs();
expect(dates.length, '扫码日期应有起止输入').toBeGreaterThanOrEqual(2);
const expected = openScanProductResetDateRange();
expect(dates[0], '重置后扫码开始日期应为近一个月起点').toBe(expected.start);
expect(dates[1], '重置后扫码结束日期应为当天').toBe(expected.end);
await this.expectOnPage();
await this.assertPageHealthy('重置后');
}
}
import { expect, test, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { timeouts } from '../../config/timeouts.config';
import { headersJoinedMatchExpected } from '../../core/column-header-match.util';
import { type OpenComboFieldDef, type OpenPageMeta, openMenuGroup } from '../../data/open.data';
import {
exportAfterQueryByFirstRowSample,
tryFillCommonSearchInput,
} from './helpers/export-after-first-row-query.helper';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
import { WhFlowSharedPage } from './wh-flow-shared.page';
export type OpenComboSample = Record<string, string>;
/** 期初管理共用 Page:顶栏「期初管理」+ 侧栏叶子;动作在 PO,断言在 spec。 */
export class OpenSharedPage extends WhFlowSharedPage {
readonly openMeta: OpenPageMeta;
private readonly proTableList: SpdProTableListControls;
constructor(page: Page, openMeta: OpenPageMeta) {
super(page, {
path: openMeta.path,
pathPart: openMeta.pathPart,
group: openMenuGroup,
menu: openMeta.name,
forbidden: openMeta.forbidden,
});
this.openMeta = openMeta;
this.proTableList = new SpdProTableListControls({
listScope: () => this.page,
columnSettingsToggleCandidates: openMeta.columnSettingsToggleCandidates ?? [],
sortColumns: openMeta.sortColumns ?? [],
clickQuery: () => this.clickQuery(),
skipWhen: (reason) => this.skipWhenDataInsufficient(reason),
click: (loc, label) => this.click(loc, label),
});
}
override async open(): Promise<void> {
await super.open();
await this.prepareDefaultQueryContext();
}
/** 顶层菜单「期初管理」,非基础设置子菜单。 */
override async openFromMenu(): Promise<void> {
await this.goto(env.homePath);
await this.waitSpinGone();
await this.click(this.page.getByRole('menuitem', { name: '期初管理' }).first(), '期初管理');
await this.waitSettle(500);
await this.click(
this.page
.locator('.ant-menu-item')
.filter({ hasText: new RegExp(`^\\s*${this.openMeta.name}\\s*$`) })
.first(),
this.openMeta.name,
);
await this.page.waitForURL((url) => {
const pathname = url.pathname;
if (!pathname.includes(this.openMeta.pathPart)) return false;
return !(this.openMeta.forbidden ?? []).some((token) => pathname.includes(token));
}, { timeout: 20_000 });
await this.waitSpinGone();
await this.waitSettle(600);
await this.waitContentReady();
await this.assertUiReady(`菜单进入${this.openMeta.name}`);
await this.prepareDefaultQueryContext();
}
/** 进页:有默认日期区间则写入并查询;否则保证列表就绪。 */
protected async prepareDefaultQueryContext(): Promise<void> {
if (this.openMeta.defaultDateRange) {
await this.applyDefaultDateRange();
await this.clickQuery();
return;
}
await this.ensureListDataReady();
}
override async expectOnPage(): Promise<void> {
expect(this.page.url(), `URL 应含 ${this.openMeta.pathPart}`).toContain(this.openMeta.pathPart);
for (const part of this.openMeta.forbidden ?? []) {
expect(this.page.url(), `不应进入 ${part}`).not.toContain(part);
}
await this.waitContentReady();
}
/** 写入 CSV 前置默认日期区间(如扫码日期),须在显式查询前完成。 */
async applyDefaultDateRange(): Promise<void> {
const range = this.openMeta.defaultDateRange;
if (!range) return;
await this.fillOpenComboDateRange(range.label, range.start, range.end, range.formId);
}
async ensureListDataReady(): Promise<void> {
if ((await this.getDataRowCount()) > 0) return;
if (this.openMeta.defaultDateRange) {
await this.applyDefaultDateRange();
}
const query = this.button('查询').or(this.button('查 询')).first();
if (await query.isVisible().catch(() => false)) {
await this.clickQuery();
}
}
async clickQuery(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
const btn = this.button('查询').or(this.button('查 询')).first();
await this.click(btn, '查询');
await this.waitForTableReady();
}
async clickResetQuick(): Promise<void> {
await this.assertBeforeBusinessAction('重置');
const btn = this.button('重置').or(this.button('重 置')).first();
await expect(btn, '重置').toBeVisible({ timeout: 12_000 });
await this.click(btn, '重置');
await this.waitForTableReady();
expect(this.page.url(), `URL 应含 ${this.openMeta.pathPart}`).toContain(this.openMeta.pathPart);
}
async searchByMockKeyword(): Promise<void> {
const expand = this.page.getByText('展开', { exact: true }).first();
if (await expand.isVisible().catch(() => false)) {
await this.click(expand, '展开筛选').catch(() => undefined);
await this.waitSettle(300);
}
const mock = this.openMeta.mock;
let filled = false;
await runWithMockEmptyListRoute(this.page, mock, async () => {
const inputs = [
this.page.getByPlaceholder(this.openMeta.mockPlaceholder).first(),
this.page.locator('.ant-pro-table-search input.ant-input:visible').first(),
this.page.locator('.ant-form input.ant-input:visible').first(),
this.page.locator('input.ant-input:visible').first(),
];
for (const input of inputs) {
if (await input.isVisible().catch(() => false)) {
await this.fill(input, mock, 'mock 关键字');
await this.clickQuery();
filled = true;
return;
}
}
});
if (!filled) {
await this.skipWhenDataInsufficient('无可见筛选输入框,无法构造 mock 空态');
}
}
async assertConfiguredHeaders(): Promise<void> {
await this.ensureListDataReady();
const headers = await this.headerTexts();
const joined = headers.join('');
for (const name of this.openMeta.headers) {
expect(headersJoinedMatchExpected(joined, name), `表头应含 ${name}`).toBeTruthy();
}
}
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await this.proTableList.executeColumnSettingsToggleRestoreTest();
}
async expectColumnSortAscDesc(columnName: string): Promise<void> {
await this.proTableList.expectColumnSortAscDesc(columnName);
}
async expectHorizontalScrollAlignment(): Promise<void> {
await this.ensureListDataReady();
const body = this.page.locator('.ant-table-body').first();
if (!(await body.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无横向滚动容器');
}
await body.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
});
await this.waitSettle(300);
const headers = await this.headerTexts();
expect(headers.length, '表头列数应 > 0').toBeGreaterThan(0);
}
async goToPage2ForOpen(): Promise<void> {
await this.ensureListDataReady();
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.rowIdentity(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.page.locator('.ant-pagination-next').first(), '下一页');
}
await this.waitForTableReady();
await expect.poll(async () => this.getDataRowCount(), { timeout: 15_000 }).toBeGreaterThan(0);
const secondKey = await this.rowIdentity(await this.firstRow());
expect(secondKey.length, '第 2 页首行应有数据').toBeGreaterThan(0);
if (firstKey && secondKey) expect(secondKey, '翻页后首行应变化').not.toBe(firstKey);
}
async assertTotalWhenHasData(): Promise<void> {
await this.ensureListDataReady();
await this.clickQuery();
const snap = await this.snapshotList();
if (snap.rows === 0) await this.skipWhenDataInsufficient('列表无数据');
expect(snap.total, '有数据时应展示总条数').toBeGreaterThan(0);
}
async rowIdentity(row: Locator): Promise<string> {
const header = this.openMeta.page2Header;
const idx = (await this.headerTexts()).findIndex((h) => h.includes(header));
if (idx >= 0) {
const cell = row.locator('td').nth(idx);
const text = ((await cell.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ').trim();
if (text) return text;
}
return ((await row.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ').trim();
}
rowAction(row: Locator, name: string): Locator {
const pattern = new RegExp(name.replace(/\s/g, '').split('').join('\\s*'));
return row.getByRole('button', { name: pattern }).or(row.getByText(pattern)).first();
}
async waitLayerOpen(timeout = 8_000): Promise<boolean> {
const layer = this.page.locator('.ant-modal, .ant-drawer-open, .put-page').first();
return layer.isVisible({ timeout }).catch(() => false);
}
async closeLayer(): Promise<void> {
const cancel = this.button('取消').or(this.button('取 消')).or(this.button('关闭')).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '关闭弹层');
await this.waitSettle(300);
return;
}
const close = this.page.locator('.ant-modal-close, .ant-drawer-close').last();
if (await close.isVisible().catch(() => false)) {
await this.click(close, '关闭');
await this.waitSettle(300);
return;
}
await this.page.keyboard.press('Escape').catch(() => undefined);
}
async rowActionSmoke(op?: string): Promise<void> {
await this.ensureListDataReady();
const rows = await this.listRows();
if ((await rows.count()) === 0) {
await this.skipWhenDataInsufficient('列表无数据');
}
await this.page
.locator('.ant-table-body')
.first()
.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
})
.catch(() => undefined);
const targets = op ? [op] : this.openMeta.rowOps;
const count = Math.min(await rows.count(), 8);
for (let i = 0; i < count; i += 1) {
const row = rows.nth(i);
for (const name of targets) {
const btn = this.rowAction(row, name);
if (!(await btn.isVisible().catch(() => false))) continue;
await this.click(btn, name);
const opened = await this.waitLayerOpen(8_000);
if (opened) {
await this.closeLayer();
await this.expectOnPage();
return;
}
const back = this.page.getByRole('button', { name: /返\s*回/ }).first();
if (await back.isVisible().catch(() => false)) {
await this.click(back, '返回');
await this.waitForTableReady({ health: 'ui' }).catch(() => undefined);
await this.expectOnPage();
return;
}
}
}
await this.skipWhenDataInsufficient(`行内无可见操作:${targets.join('/')}`);
}
async openToolbarOverlay(label: string): Promise<Locator> {
await this.ensureListDataReady();
const pattern = new RegExp(label.replace(/\s/g, '').split('').join('\\s*'));
const btn = this.page.getByRole('button', { name: pattern }).first();
if (!(await btn.isVisible({ timeout: 8_000 }).catch(() => false))) {
await this.skipWhenDataInsufficient(`工具栏无「${label}」按钮`);
}
await this.click(btn, label);
const opened = await this.waitLayerOpen();
if (!opened) {
await this.skipWhenDataInsufficient(`工具栏「${label}」点击后未出现弹层`);
}
return this.page.locator('.ant-modal, .ant-drawer-open, .put-page').first();
}
async assertRequiredInterceptFor(label: string): Promise<void> {
await this.openToolbarOverlay(label);
const confirm = this.page
.getByRole('button', { name: /确\s*定|确\s*认|保\s*存|提\s*交/ })
.last();
if (await confirm.isVisible().catch(() => false)) {
await confirm.click({ timeout: 5_000 }).catch(() => undefined);
await this.waitSettle(400);
}
const hint = this.page.locator('.ant-form-item-explain-error, .ant-message-error').first();
const stillOpen = await this.page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
const hintVisible = await hint.isVisible().catch(() => false);
expect(hintVisible || stillOpen, '必填应拦截').toBeTruthy();
await this.closeLayer();
}
async cancelToolbarWithoutCommit(label: string): Promise<void> {
const before = await this.getDataRowCount();
await this.openToolbarOverlay(label);
const input = this.page.locator('.ant-modal input, .ant-drawer input, input.ant-input').first();
if (await input.isVisible().catch(() => false)) {
await this.fill(input, `auto.open.${Date.now()}`, '临时填写');
}
await this.closeLayer();
await this.waitForTableReady();
const after = await this.getDataRowCount();
expect(after, '取消后行数不变').toBe(before);
}
async openAdvancedFilterDrawer(): Promise<void> {
const btn = this.page.getByText(/高级筛选/).first();
await expect(btn, '高级筛选').toBeVisible({ timeout: 12_000 });
await this.click(btn, '高级筛选');
await expect(this.page.locator('.ant-drawer-open').first(), '高级筛选抽屉').toBeVisible({
timeout: 8_000,
});
await this.closeLayer();
}
async toggleFilterExpand(): Promise<void> {
const expand = this.page.getByRole('button', { name: /展\s*开/ }).first();
if (!(await expand.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无展开按钮');
}
await this.click(expand, '展开');
await this.waitSettle(400);
const collapse = this.page.getByRole('button', { name: /收\s*起/ }).first();
await expect(collapse, '应收起').toBeVisible({ timeout: 8_000 });
}
async clickExportIfVisible(): Promise<void> {
const btn = this.button('导出').or(this.button('导 出')).first();
if (!(await btn.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无导出按钮');
}
await this.assertBeforeBusinessAction('导出');
await this.click(btn, '导出');
await this.waitSettle(800);
await this.assertPageHealthy('导出后');
}
/** 取首行样本查询后再导出;列表无数据则 skip。 */
async exportAfterQueryByFirstRowSample(): Promise<void> {
const headers = [this.openMeta.page2Header, ...(this.openMeta.headers ?? [])].filter(
(h, i, arr) => !!h && arr.indexOf(h) === i,
);
await exportAfterQueryByFirstRowSample({
page: this.page,
ensureReady: () => this.ensureListDataReady(),
getDataRowCount: () => this.getDataRowCount(),
listRows: () => this.listRows(),
tryCell: (row, header) => this.tryCell(row, header),
sampleHeaders: headers,
expandFilters: () => this.ensureFilterExpanded(),
tryFillSample: (value) =>
tryFillCommonSearchInput(this.page, value, {
fill: (loc, v, label) => this.fill(loc, v, label),
textInput: (label) => this.textInput(label),
labels: ['期初单号', '条码号', this.openMeta.page2Header, ...headers.slice(0, 4)],
}),
clickQuery: () => this.clickQuery(),
clickExport: () => this.clickExportIfVisible(),
skipWhenDataInsufficient: (reason) => this.skipWhenDataInsufficient(reason),
usableSampleValue: (raw) => this.usableSampleValue(raw),
});
}
/** 待生成行应可见录入/删除。 */
async assertPendingRowOps(): Promise<void> {
await this.ensureListDataReady();
const rows = await this.listRows();
const count = Math.min(await rows.count(), 20);
for (let i = 0; i < count; i += 1) {
const row = rows.nth(i);
const text = ((await row.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ');
if (!text.includes('待生成')) continue;
const enter = this.rowAction(row, '录入');
const del = this.rowAction(row, '删除');
expect(
(await enter.isVisible().catch(() => false)) || (await del.isVisible().catch(() => false)),
'待生成行应有录入或删除',
).toBeTruthy();
return;
}
await this.skipWhenDataInsufficient('当前页无「待生成」行');
}
/** 已生成行应可见查看详情。 */
async assertGeneratedRowOps(): Promise<void> {
await this.ensureListDataReady();
const rows = await this.listRows();
const count = Math.min(await rows.count(), 20);
for (let i = 0; i < count; i += 1) {
const row = rows.nth(i);
const text = ((await row.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ');
if (!text.includes('已生成')) continue;
const detail = this.rowAction(row, '查看详情');
expect(await detail.isVisible().catch(() => false), '已生成行应有查看详情').toBeTruthy();
return;
}
await this.skipWhenDataInsufficient('当前页无「已生成」行');
}
protected usableSampleValue(raw: string | undefined): string {
const text = (raw ?? '').replace(/\s+/g, ' ').trim();
if (!text || text === '--' || text === '—' || text === '-') return '';
return text;
}
protected parseDateOnly(raw: string): string {
const match = raw.match(/(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})/);
if (!match) return '';
const [, y, m, d] = match;
return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
}
/** 展开筛选区,使折叠字段(如扫码人)可填。 */
async ensureFilterExpanded(): Promise<void> {
await this.expandFiltersIfCollapsed(this.page);
}
async extractComboSample(
fields: readonly OpenComboFieldDef[],
rowIndex = 0,
): Promise<OpenComboSample> {
await this.ensureListDataReady();
const rows = await this.listRows();
const count = await rows.count();
if (count === 0) {
await this.skipWhenDataInsufficient('默认查询无数据,无法组合查询取样');
}
const row = rows.nth(Math.min(rowIndex, count - 1));
const sample: OpenComboSample = {};
for (const field of fields) {
const cell = this.usableSampleValue(await this.tryCell(row, field.column));
if (!cell) continue;
sample[field.key] = field.type === 'date' ? this.parseDateOnly(cell) || cell : cell;
}
return sample;
}
async fillOpenComboDateRange(
label: string,
start: string,
end: string = start,
formId?: string,
): Promise<void> {
const drawer = this.page.locator('.ant-drawer-open').first();
const scope = (await drawer.isVisible().catch(() => false)) ? drawer : this.page;
const item = formId
? scope.locator('.ant-form-item').filter({ has: this.page.locator(formId) }).first()
: await this.resolveFilterItem(scope, label);
expect(item, `筛选「${label}」日期区间`).toBeTruthy();
await item!.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }));
const picker = item!.locator('.ant-picker').first();
await expect(picker, `「${label}」日期控件`).toBeVisible({ timeout: 8_000 });
const startInput = item!.locator('.ant-picker input').first();
await startInput.evaluate((el) =>
(el as HTMLElement).scrollIntoView({ block: 'center', inline: 'nearest' }),
);
try {
await startInput.click({ force: true, timeout: 3_000 });
} catch {
await startInput.dispatchEvent('click');
await startInput.focus();
}
const panel = this.page.locator('.ant-picker-dropdown:visible').last();
await expect(panel, `「${label}」日期面板`).toBeVisible({ timeout: 8_000 });
const clickDay = async (day: string) => {
const cell = panel
.locator(`td[title="${day}"]`)
.or(panel.locator('.ant-picker-cell-in-view').filter({ hasText: new RegExp(`^${Number(day.slice(-2))}$`) }))
.first();
// 面板可能跨月:先尽力点 title,失败则键盘输入兜底
if (await cell.isVisible().catch(() => false)) {
await cell.click({ force: true });
return true;
}
return false;
};
const typedFallback = async () => {
const typeOne = async (index: number, value: string) => {
const input = item!.locator('.ant-picker input').nth(index);
await input.evaluate((el) => {
const node = el as HTMLInputElement;
node.removeAttribute('readonly');
node.readOnly = false;
});
await input.click({ force: true }).catch(() => input.dispatchEvent('focus'));
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await input.press('Backspace');
await input.fill(value).catch(async () => {
await this.page.keyboard.type(value, { delay: 0 });
});
await input.press('Enter').catch(() => undefined);
};
await typeOne(0, start);
if ((await item!.locator('.ant-picker input').count()) >= 2) {
await typeOne(1, end);
}
};
const pickedStart = await clickDay(start);
const pickedEnd = pickedStart ? await clickDay(end) : false;
if (!pickedStart || !pickedEnd) {
await typedFallback();
}
await this.page.keyboard.press('Escape').catch(() => undefined);
await this.page
.locator('.ant-picker-dropdown:visible')
.waitFor({ state: 'hidden', timeout: 3_000 })
.catch(() => undefined);
await this.waitSettle(300);
}
async applyOpenComboFields(
sample: OpenComboSample,
fields: readonly OpenComboFieldDef[],
options?: { onlyIfPresent?: boolean; requireDrawer?: boolean },
): Promise<string[]> {
const onlyIfPresent = options?.onlyIfPresent !== false;
const requireDrawer = options?.requireDrawer === true;
const applied: string[] = [];
for (const field of fields) {
const raw = this.usableSampleValue(sample[field.key]);
if (onlyIfPresent && !raw) continue;
if (!raw) continue;
if (requireDrawer) {
await this.ensureAdvancedFilterOpen();
}
if (field.type === 'date') {
const day = this.parseDateOnly(raw);
if (!day) continue;
await this.fillOpenComboDateRange(field.label, day, day, field.formId);
applied.push(field.key);
continue;
}
if (field.type === 'select') {
const drawer = this.page.locator('.ant-drawer-open').first();
const root = (await drawer.isVisible().catch(() => false)) ? drawer : undefined;
if (requireDrawer) {
expect(root, `选择「${field.label}」前高级筛选抽屉应打开`).toBeTruthy();
}
const select = this.formSelect(field.label, root);
if (!(await select.isVisible().catch(() => false))) continue;
await this.click(select, `组合查询打开「${field.label}」`);
const dropdown = await this.activeSelectDropdown(this.page);
const options = dropdown.locator(
'.ant-select-item-option:not(.ant-select-item-option-disabled)',
);
await expect
.poll(async () => (await options.count()) > 0, { timeout: 15_000 })
.toBeTruthy();
const compact = raw.replace(/\s/g, '');
let target: Locator | null = null;
const count = await options.count();
for (let i = 0; i < count; i += 1) {
const text = ((await options.nth(i).innerText()) ?? '').replace(/\s/g, '');
if (!text) continue;
if (text === compact || text.includes(compact) || compact.includes(text)) {
target = options.nth(i);
break;
}
}
if (!target) {
await this.page.keyboard.press('Escape').catch(() => undefined);
continue;
}
await this.click(target, `组合查询选择「${field.label}=${raw}」`);
// 禁止 Escape:会连带关闭高级筛选抽屉,后续物料名称等会写到隐藏节点
const drawerTitle = this.page.locator('.ant-drawer-open .ant-drawer-title').first();
if (await drawerTitle.isVisible().catch(() => false)) {
await drawerTitle.click({ force: true }).catch(() => undefined);
}
await this.page
.locator('.ant-select-dropdown:visible')
.waitFor({ state: 'hidden', timeout: 3_000 })
.catch(() => undefined);
await this.waitSettle(200);
applied.push(field.key);
continue;
}
const drawerRoot = this.page.locator('.ant-drawer-open').first();
const root =
(await drawerRoot.isVisible().catch(() => false)) ? drawerRoot : undefined;
if (requireDrawer) {
expect(root, `填写「${field.label}」前高级筛选抽屉应打开`).toBeTruthy();
}
const input = field.formId
? (root ?? this.page).locator(field.formId).first()
: this.textInput(field.label, root).first();
await expect(input, `「${field.label}」应可见`).toBeVisible({ timeout: 8_000 });
await input.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }));
await this.fill(input, raw, `组合查询:${field.label}`);
await expect(input, `组合查询后「${field.label}」应保留样本值`).toHaveValue(raw);
applied.push(field.key);
}
return applied;
}
/** 确保高级筛选抽屉打开(避免 Escape/遮罩误关后继续往隐藏节点写值)。 */
async ensureAdvancedFilterOpen(): Promise<void> {
const drawer = this.page.locator('.ant-drawer-open').first();
if (await drawer.isVisible().catch(() => false)) return;
const adv = this.page.locator('.advance-filter').or(this.page.getByText(/高级筛选/)).first();
await this.click(adv, '重新打开高级筛选');
await expect(drawer, '高级筛选抽屉').toBeVisible({ timeout: 8_000 });
}
/**
* 对齐 center-report-sheet STAT-IOD-004:
* 默认查询取样 → 按配置填快捷区(样本有值才写)→ 抽屉字段(本页可为空)→ 查询。
*/
async searchWithQuickAndDrawerCombo(
quickFields: readonly OpenComboFieldDef[],
drawerFields: readonly OpenComboFieldDef[] = [],
): Promise<OpenComboSample> {
test.setTimeout(timeouts.comboTest);
await this.ensureListDataReady();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('默认查询无数据,跳过组合查询');
}
const allFields = [...quickFields, ...drawerFields];
let sample = await this.extractComboSample(allFields);
const usableKeys = allFields.filter((f) => this.usableSampleValue(sample[f.key]));
if (usableKeys.length === 0) {
await this.skipWhenDataInsufficient('首行样本字段均为空,无法组合查询');
}
await this.ensureFilterExpanded();
const appliedQuick = await this.applyOpenComboFields(sample, quickFields, { onlyIfPresent: true });
expect(appliedQuick.length, '快捷区至少应写入 1 个有值条件').toBeGreaterThan(0);
if (drawerFields.length > 0) {
const adv = this.page.getByText(/高级筛选/).first();
if (await adv.isVisible().catch(() => false)) {
await this.click(adv, '高级筛选');
await expect(this.page.locator('.ant-drawer-open').first(), '高级筛选抽屉').toBeVisible({
timeout: 8_000,
});
await this.applyOpenComboFields(sample, drawerFields, {
onlyIfPresent: true,
requireDrawer: true,
});
await this.ensureAdvancedFilterOpen();
const applyBtn = this.page
.locator('.ant-drawer-open')
.getByRole('button', { name: /筛\s*选|确\s*定|查\s*询/ })
.first();
if (await applyBtn.isVisible().catch(() => false)) {
await this.click(applyBtn, '应用高级筛选');
} else {
await this.closeLayer();
}
}
}
await this.clickQuery();
await this.assertPageHealthy('组合查询后');
return sample;
}
/** 抽样首行(或当前页任一行)同时满足已填条件。 */
async expectComboResultMatchesSample(
sample: OpenComboSample,
assertKeys: readonly string[],
): Promise<void> {
const snap = await this.snapshotList();
expect(snap.rows, '组合查询后应有结果').toBeGreaterThan(0);
const rows = await this.listRows();
const count = await rows.count();
for (const key of assertKeys) {
const value = this.usableSampleValue(sample[key]);
if (!value) continue;
const compact = value.replace(/\s+/g, '');
let matched = false;
for (let i = 0; i < count; i += 1) {
const rowText = ((await rows.nth(i).innerText().catch(() => '')) ?? '').replace(/\s+/g, '');
if (rowText.includes(compact)) {
matched = true;
break;
}
}
expect(matched, `结果应含条件「${key}=${value}」`).toBeTruthy();
}
}
}
import type { Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import { getRuntimeHealthGuard } from '../../core/runtime-health.util';
import { prodmPages } from '../../data/prodm.data'; import { prodmPages } from '../../data/prodm.data';
import { ProdmSharedPage } from './prodm-shared.page'; import { ProdmSharedPage } from './prodm-shared.page';
...@@ -13,6 +14,396 @@ export class ProdmMaintainPage extends ProdmSharedPage { ...@@ -13,6 +14,396 @@ export class ProdmMaintainPage extends ProdmSharedPage {
constructor(page: Page) { constructor(page: Page) {
super(page, prodmPages.maintain); super(page, prodmPages.maintain);
} }
override async rowActionSmoke(op?: string): Promise<void> {
if (!op || op === '详情') {
await this.assertDetailMaterialCodeMatchesList();
return;
}
await super.rowActionSmoke(op);
}
/** 打开「新增分类」:弹层标题为「分类维护」,关键字段可交互。 */
async openAddCategoryOverlay(): Promise<void> {
await this.openToolbarOverlay('新增分类');
const layer = this.page.locator('.ant-modal:visible, .ant-drawer-open').last();
await expect(layer, '应打开新增分类弹层').toBeVisible({ timeout: 10_000 });
const title = layer.locator('.ant-modal-title, .ant-drawer-title').first();
await expect(title, '弹层标题应为分类维护').toContainText(/分类维护|新增分类/);
await expect(layer.getByText(/分类名称/).first(), '应可见分类名称').toBeVisible();
await expect(layer.getByText(/分类编码/).first(), '应可见分类编码').toBeVisible();
await expect(
layer.getByRole('button', { name: /取\s*消/ }).or(layer.getByRole('button', { name: /确\s*定/ })).first(),
'应可见取消/确定',
).toBeVisible();
}
/** 「新增分类」取消:树节点数不变(不落库)。 */
async cancelAddCategoryWithoutCommit(): Promise<void> {
const before = await this.visibleTreeNames();
await this.openToolbarOverlay('新增分类');
const layer = this.page.locator('.ant-modal:visible, .ant-drawer-open').last();
const nameInput = layer
.getByPlaceholder('分类名称')
.or(layer.locator('.ant-form-item').filter({ hasText: /分类名称/ }).locator('input').first())
.first();
if (await nameInput.isVisible().catch(() => false)) {
await this.fill(nameInput, `auto.cat.${Date.now()}`, '临时分类名称');
}
const cancel = layer.getByRole('button', { name: /取\s*消/ }).first();
if (await cancel.isVisible().catch(() => false)) await this.click(cancel, '取消');
else await this.closeLayer();
await this.waitSettle(500);
const after = await this.visibleTreeNames();
expect(after.length, '取消新增分类后树节点数应不变').toBe(before.length);
expect(after.join('|'), '取消后树节点集合应不变').toBe(before.join('|'));
}
/** 「新增分类」必填为空拦截。 */
async assertAddCategoryRequiredIntercept(): Promise<void> {
await this.assertRequiredInterceptFor('新增分类');
}
/** 左侧树节点上的编辑图标(现网为 anticon-form)。 */
private treeEditIcon(node: Locator): Locator {
return node.locator('.anticon-form, [aria-label="form"]').first();
}
private async firstEditableTreeNode(): Promise<{ node: Locator; title: string; name: string; code: string }> {
await expect(this.treeRoot(), '耗材分类树').toBeVisible({ timeout: 15_000 });
const nodes = this.page.locator('.ant-tree .ant-tree-treenode:visible').filter({
has: this.page.locator('.ant-tree-title'),
});
const count = await nodes.count();
for (let i = 0; i < Math.min(count, 30); i += 1) {
const node = nodes.nth(i);
const icon = node.locator('.anticon-form, [aria-label="form"]').first();
if (!(await icon.isVisible().catch(() => false))) continue;
const title = ((await node.locator('.ant-tree-title').innerText().catch(() => '')) ?? '')
.replace(/\s+/g, ' ')
.trim();
if (!title) continue;
const m = title.match(/^(.*)\((\d+)\)\s*$/);
const name = (m?.[1] ?? title).trim();
const code = (m?.[2] ?? '').trim();
return { node, title, name, code };
}
await this.skipWhenDataInsufficient('左侧树无带编辑图标的分类节点');
return { node: nodes.first(), title: '', name: '', code: '' };
}
/** 打开树节点「编辑分类」:标题为「编辑医院产品分类」,回填名称/编码。 */
async openEditCategoryOverlay(): Promise<{ name: string; code: string }> {
const { node, name, code } = await this.firstEditableTreeNode();
const icon = this.treeEditIcon(node);
await this.assertBeforeBusinessAction('编辑分类');
await this.click(icon, '编辑分类');
const opened = await this.waitLayerOpen(12_000);
expect(opened, '应打开编辑分类弹层').toBeTruthy();
const layer = this.page.locator('.ant-modal:visible, .ant-drawer-open').last();
const title = layer.locator('.ant-modal-title, .ant-drawer-title').first();
await expect(title, '弹层标题应为编辑医院产品分类').toContainText(/编辑医院产品分类|编辑.*分类/);
await expect(layer.getByText(/分类名称/).first(), '应可见分类名称').toBeVisible();
await expect(layer.getByText(/分类编码/).first(), '应可见分类编码').toBeVisible();
const nameInput = layer
.locator('.ant-form-item')
.filter({ has: this.page.locator('.ant-form-item-label', { hasText: /^分类名称/ }) })
.locator('input')
.first();
const codeInput = layer
.locator('.ant-form-item')
.filter({ has: this.page.locator('.ant-form-item-label', { hasText: /^分类编码/ }) })
.locator('input')
.first();
await expect(nameInput, '分类名称输入框').toBeVisible({ timeout: 10_000 });
if (name) {
await expect(nameInput, '编辑弹层应回填分类名称').toHaveValue(name, { timeout: 10_000 });
} else {
await expect(nameInput, '分类名称应有回填值').not.toHaveValue('');
}
if (code) {
await expect(codeInput, '编辑弹层应回填分类编码').toHaveValue(code, { timeout: 10_000 });
}
await expect(
layer.getByRole('button', { name: /取\s*消/ }).or(layer.getByRole('button', { name: /确\s*定/ })).first(),
'应可见取消/确定',
).toBeVisible();
return { name, code };
}
/** 「编辑分类」取消:修改名称后取消,树文案不变。 */
async cancelEditCategoryWithoutCommit(): Promise<void> {
const before = await this.visibleTreeNames();
await this.openEditCategoryOverlay();
const layer = this.page.locator('.ant-modal:visible, .ant-drawer-open').last();
const nameInput = layer
.locator('.ant-form-item')
.filter({ hasText: /分类名称/ })
.locator('input')
.first()
.or(layer.getByPlaceholder('分类名称'))
.first();
if (await nameInput.isVisible().catch(() => false)) {
await nameInput.fill('');
await this.fill(nameInput, `auto.edit.cat.${Date.now()}`, '临时修改分类名称');
}
const cancel = layer.getByRole('button', { name: /取\s*消/ }).first();
if (await cancel.isVisible().catch(() => false)) await this.click(cancel, '取消');
else await this.closeLayer();
await this.waitSettle(500);
const after = await this.visibleTreeNames();
expect(after.join('|'), '取消编辑后树节点文案应不变').toBe(before.join('|'));
}
/** 「编辑分类」必填为空拦截。 */
async assertEditCategoryRequiredIntercept(): Promise<void> {
await this.openEditCategoryOverlay();
const layer = this.page.locator('.ant-modal:visible, .ant-drawer-open').last();
const nameInput = layer
.locator('.ant-form-item')
.filter({ hasText: /分类名称/ })
.locator('input')
.first();
if (await nameInput.isVisible().catch(() => false)) {
await nameInput.fill('');
}
const confirm = layer.getByRole('button', { name: /确\s*定/ }).first();
if (await confirm.isVisible().catch(() => false)) {
await confirm.click({ timeout: 5_000 }).catch(() => undefined);
await this.waitSettle(400);
}
const state = await this.interceptState();
const stillOpen = await this.isLayerOpen();
expect(state.hintVisible || state.overlayOpen || stillOpen, '必填应拦截').toBeTruthy();
getRuntimeHealthGuard(this.page)?.allowExpected(/请填写|必填|请输入|请选择|不能为空|不可为空/);
await this.closeLayer();
await this.page
.locator('.ant-message-error, .ant-notification-notice-error')
.first()
.waitFor({ state: 'hidden', timeout: 8_000 })
.catch(() => undefined);
}
/** 「添加产品」弹层根(标题含添加产品)。 */
addProductDialog(): Locator {
return this.page
.locator('.ant-modal:visible')
.filter({ has: this.page.locator('.ant-modal-title', { hasText: /添加产品/ }) })
.last();
}
/** 选中可启用「添加」的树节点后打开添加产品列表。 */
async openAddProductOverlay(): Promise<Locator> {
await this.ensureListDataReady();
await this.enableAddBySelectingTreeLeaf();
const btn = this.toolbarButton('添加');
if (!(await btn.isVisible({ timeout: 8_000 }).catch(() => false))) {
await this.skipWhenDataInsufficient('工具栏无「添加」按钮');
}
if (await btn.isDisabled().catch(() => false)) {
await this.skipWhenDataInsufficient('「添加」仍禁用(需选中可添加的分类树节点)');
}
await this.assertBeforeBusinessAction('添加');
await this.click(btn, '添加');
const dialog = this.addProductDialog();
await expect(dialog, '应打开添加产品弹层').toBeVisible({ timeout: 15_000 });
await expect(dialog.locator('.ant-table-tbody tr.ant-table-row').first(), '添加产品列表应有数据').toBeVisible({
timeout: 20_000,
});
return dialog;
}
/** 展开并点选树节点,直到「添加」可点。 */
private async enableAddBySelectingTreeLeaf(): Promise<void> {
const btn = this.toolbarButton('添加');
if (!(await btn.isDisabled().catch(() => true))) return;
await this.tryEnableToolbar('添加');
if (!(await btn.isDisabled().catch(() => true))) return;
const tree = this.treeRoot();
// 优先展开常见父节点再点子节点
for (const parentName of ['基础卫生材料', '低值易耗品', '专科类材料']) {
const parent = tree.locator('.ant-tree-treenode:visible').filter({ hasText: new RegExp(`^\\s*${parentName}`) }).first();
if (!(await parent.isVisible().catch(() => false))) continue;
const switcher = parent.locator('.ant-tree-switcher').first();
if (await switcher.isVisible().catch(() => false)) {
const closed = await switcher.evaluate((el) => el.className.includes('ant-tree-switcher_close')).catch(() => false);
if (closed) {
await this.click(switcher, `展开 ${parentName}`);
await this.waitSettle(500);
}
}
}
const titles = tree.locator('.ant-tree-treenode:visible .ant-tree-title');
const count = Math.min(await titles.count(), 40);
for (let i = 0; i < count; i += 1) {
await this.click(titles.nth(i), `树节点#${i}`);
await this.waitSettle(400);
if (!(await btn.isDisabled().catch(() => true))) return;
}
}
addProductConfirmCountButton(dialog?: Locator): Locator {
const root = dialog ?? this.addProductDialog();
return root.getByRole('button').filter({ hasText: /确定\s*[((]?\s*\d*\s*[))]?/ }).first();
}
async readAddProductConfirmCount(dialog?: Locator): Promise<number> {
const text = ((await this.addProductConfirmCountButton(dialog).innerText().catch(() => '')) ?? '').replace(
/\s/g,
'',
);
const match = text.match(/[((](\d+)[))]/);
return match ? Number(match[1]) : 0;
}
private addProductRows(dialog: Locator): Locator {
return dialog.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
}
private async sampleAddProductKeyword(dialog: Locator): Promise<{ keyword: string; type?: string }> {
const row = this.addProductRows(dialog).first();
await expect(row, '添加产品列表首行').toBeVisible({ timeout: 10_000 });
const cells = row.locator('td');
// 表头:勾选 | 物料编号 | 物料名称 | 耗材类型 | ...
const name = this.usableSampleValue(((await cells.nth(2).innerText().catch(() => '')) ?? '').trim());
const type = this.usableSampleValue(((await cells.nth(3).innerText().catch(() => '')) ?? '').trim());
// 取名称短前缀,避免唯一编号把结果压成不足 2 页
const cn = name.match(/[\u4e00-\u9fa5]{2,4}/)?.[0];
const keyword = cn || name.slice(0, 4);
if (!keyword && !type) {
await this.skipWhenDataInsufficient('添加产品列表首行无可用物料名称/耗材类型');
}
return { keyword: keyword || '', type: type || undefined };
}
private async fillAddProductCombo(
dialog: Locator,
opts: { keyword?: string; type?: string },
): Promise<void> {
const nameInput = dialog
.getByPlaceholder(/物料编号|名称|简拼|俗称/)
.or(dialog.locator('.ant-form-item').filter({ hasText: /物料名称/ }).locator('input'))
.first();
if (opts.keyword && (await nameInput.isVisible().catch(() => false))) {
await this.fill(nameInput, opts.keyword, `填写物料名称「${opts.keyword}」`);
}
if (opts.type) {
const typeSelect = dialog
.locator('.ant-form-item')
.filter({ hasText: /耗材类型/ })
.locator('.ant-select')
.first();
if (await typeSelect.isVisible().catch(() => false)) {
await this.click(typeSelect, '展开耗材类型');
const opt = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: opts.type })
.first();
if (await opt.isVisible().catch(() => false)) {
await this.click(opt, `选择耗材类型「${opts.type}」`);
} else {
await this.page.keyboard.press('Escape').catch(() => undefined);
}
}
}
const queryBtn = dialog.getByRole('button', { name: /^查\s*询$/ }).first();
await this.click(queryBtn, '添加产品列表查询');
await this.waitSettle(800);
await expect
.poll(async () => this.addProductRows(dialog).count(), { timeout: 15_000 })
.toBeGreaterThan(0);
}
/** 添加产品列表:物料名称 + 耗材类型组合查询,抽样首行满足条件。取消不落库。 */
async assertAddProductComboQuery(): Promise<void> {
const dialog = await this.openAddProductOverlay();
const { keyword, type } = await this.sampleAddProductKeyword(dialog);
if (!keyword && !type) {
await this.closeAddProductWithoutCommit(dialog);
await this.skipWhenDataInsufficient('无法构造添加产品组合查询条件');
return;
}
await this.fillAddProductCombo(dialog, { keyword: keyword || undefined, type });
const firstText = ((await this.addProductRows(dialog).first().innerText().catch(() => '')) ?? '')
.replace(/\s+/g, ' ')
.trim();
if (keyword) {
expect(firstText, `组合查询后首行应包含物料关键字「${keyword}」`).toContain(keyword);
}
if (type) {
expect(firstText, `组合查询后首行应包含耗材类型「${type}」`).toContain(type);
}
await this.closeAddProductWithoutCommit(dialog);
}
/** 添加产品列表:切换至第 2 页,断言页码高亮且首行变化。取消不落库。 */
async assertAddProductGotoPage2(): Promise<void> {
const dialog = await this.openAddProductOverlay();
const page2 = dialog.locator('.ant-pagination-item-2').first();
if (!(await page2.isVisible().catch(() => false))) {
await this.closeAddProductWithoutCommit(dialog);
await this.skipWhenDataInsufficient('添加产品列表不足 2 页');
return;
}
const firstKey = ((await this.addProductRows(dialog).first().innerText().catch(() => '')) ?? '')
.replace(/\s+/g, ' ')
.trim();
await this.click(page2, '添加产品列表第 2 页');
await this.waitSettle(800);
await expect
.poll(async () => this.addProductRows(dialog).count(), { timeout: 15_000 })
.toBeGreaterThan(0);
await expect(page2, '第 2 页应高亮').toHaveClass(/ant-pagination-item-active/);
const secondKey = ((await this.addProductRows(dialog).first().innerText().catch(() => '')) ?? '')
.replace(/\s+/g, ' ')
.trim();
if (firstKey && secondKey) {
expect(secondKey, '翻到第 2 页后首行应变化').not.toBe(firstKey);
}
await this.closeAddProductWithoutCommit(dialog);
}
/** 添加产品列表:勾选明细后,「确定(N)」与勾选条数一致。取消不落库。 */
async assertAddProductSelectionMatchesConfirm(): Promise<void> {
const dialog = await this.openAddProductOverlay();
await expect
.poll(async () => this.addProductRows(dialog).count(), { timeout: 15_000 })
.toBeGreaterThan(0);
const before = await this.readAddProductConfirmCount(dialog);
expect(before, '打开时确定数量应为 0').toBe(0);
const checkCount = Math.min(2, await this.addProductRows(dialog).count());
expect(checkCount, '应至少可勾选 1 行').toBeGreaterThan(0);
for (let i = 0; i < checkCount; i += 1) {
const checkbox = this.addProductRows(dialog)
.nth(i)
.locator('input.ant-checkbox-input, .ant-checkbox-input')
.first();
await checkbox.check({ force: true });
await this.waitSettle(200);
}
const confirmCount = await this.readAddProductConfirmCount(dialog);
expect(confirmCount, '确定按钮显示数量应与勾选明细数一致').toBe(checkCount);
await this.closeAddProductWithoutCommit(dialog);
}
private async closeAddProductWithoutCommit(dialog: Locator): Promise<void> {
const cancel = dialog.getByRole('button', { name: /取\s*消/ }).first();
if (await cancel.isVisible().catch(() => false)) await this.click(cancel, '取消添加产品');
else await this.closeLayer();
await this.waitSettle(400);
}
} }
export class ProdmMaterialPage extends ProdmSharedPage { export class ProdmMaterialPage extends ProdmSharedPage {
...@@ -25,18 +416,44 @@ export class ProdmFirstAuditPage extends ProdmSharedPage { ...@@ -25,18 +416,44 @@ export class ProdmFirstAuditPage extends ProdmSharedPage {
constructor(page: Page) { constructor(page: Page) {
super(page, prodmPages.firstAudit); super(page, prodmPages.firstAudit);
} }
/** 进入页并切到「全部产品」Tab(列表能力用例前置)。 */
async openAllProducts(): Promise<void> {
await this.open();
await this.clickStatusTab('全部产品');
}
} }
export class ProdmSupplyPage extends ProdmSharedPage { export class ProdmSupplyPage extends ProdmSharedPage {
constructor(page: Page) { constructor(page: Page) {
super(page, prodmPages.supply); super(page, prodmPages.supply);
} }
/** 进入页并选中默认供应商树节点(非树本身用例的前置)。 */
async openWithDefaultTree(): Promise<void> {
await this.open();
await this.selectDefaultTreeNode();
}
} }
export class ProdmPriceCheckPage extends ProdmSharedPage { export class ProdmPriceCheckPage extends ProdmSharedPage {
constructor(page: Page) { constructor(page: Page) {
super(page, prodmPages.priceCheck); super(page, prodmPages.priceCheck);
} }
/** 进入页并切到「全部产品」Tab(列表能力用例前置)。 */
async openAllProducts(): Promise<void> {
await this.open();
await this.clickStatusTab('全部产品');
}
override async rowActionSmoke(op?: string): Promise<void> {
if (!op || op === '详情') {
await this.assertDetailMaterialCodeMatchesList();
return;
}
await super.rowActionSmoke(op);
}
} }
export class ProdmCampusPage extends ProdmSharedPage { export class ProdmCampusPage extends ProdmSharedPage {
...@@ -79,12 +496,49 @@ export class ProdmPriceAdjPage extends ProdmSharedPage { ...@@ -79,12 +496,49 @@ export class ProdmPriceAdjPage extends ProdmSharedPage {
constructor(page: Page) { constructor(page: Page) {
super(page, prodmPages.priceAdj); super(page, prodmPages.priceAdj);
} }
/** 行内详情:断言详情页「单号」与主列表该行一致,再关闭返回。 */
async assertDetailOrderNoMatchesList(): Promise<void> {
await this.assertRowActionFieldMatchesList({
op: '详情',
listHeaders: ['单号'],
emptyListSkip: '目标行单号为空',
readLayer: () => this.readDetailLabeledValue(['单号', '调价单号']),
missingLayerMsg: '详情页应展示单号',
mismatchMsg: '详情页单号应与主列表一致',
});
}
/** 行内再次调价:断言详情页「销售商(自定义)」与主列表该行一致,再关闭返回。 */
async assertRepriceSellerCustomMatchesList(): Promise<void> {
await this.assertRowActionFieldMatchesList({
op: '再次调价',
listHeaders: ['销售商(自定义)', '销售商'],
emptyListSkip: '目标行销售商(自定义)为空',
readLayer: () =>
this.readDetailLabeledValue(['销售商(自定义)', '销售商(自定义)名称', '销售商']),
missingLayerMsg: '详情页应展示销售商(自定义)',
mismatchMsg: '详情页销售商(自定义)应与主列表一致',
});
}
} }
export class ProdmInfoChangePage extends ProdmSharedPage { export class ProdmInfoChangePage extends ProdmSharedPage {
constructor(page: Page) { constructor(page: Page) {
super(page, prodmPages.infoChange); super(page, prodmPages.infoChange);
} }
override async rowActionSmoke(op?: string): Promise<void> {
if (op === '详情') {
await this.assertDetailChangeTypeMatchesList();
return;
}
if (op === '编辑') {
await this.assertEditChangeTypeMatchesList();
return;
}
await super.rowActionSmoke(op);
}
} }
export class ProdmChangeDetailPage extends ProdmSharedPage { export class ProdmChangeDetailPage extends ProdmSharedPage {
...@@ -109,6 +563,14 @@ export class ProdmDiQueryPage extends ProdmSharedPage { ...@@ -109,6 +563,14 @@ export class ProdmDiQueryPage extends ProdmSharedPage {
constructor(page: Page) { constructor(page: Page) {
super(page, prodmPages.diQuery); super(page, prodmPages.diQuery);
} }
override async rowActionSmoke(op?: string): Promise<void> {
if (!op || op === '详情') {
await this.assertDetailMaterialCodeMatchesList();
return;
}
await super.rowActionSmoke(op);
}
} }
export class ProdmStockoutPage extends ProdmSharedPage { export class ProdmStockoutPage extends ProdmSharedPage {
...@@ -139,12 +601,166 @@ export class ProdmDiChangePage extends ProdmSharedPage { ...@@ -139,12 +601,166 @@ export class ProdmDiChangePage extends ProdmSharedPage {
constructor(page: Page) { constructor(page: Page) {
super(page, prodmPages.diChange); super(page, prodmPages.diChange);
} }
/** 行内详情:断言详情页「变更单号」与主列表该行一致,再关闭返回。 */
async assertDetailChangeOrderNoMatchesList(): Promise<void> {
await this.assertRowActionFieldMatchesList({
op: '详情',
listHeaders: ['变更单号'],
emptyListSkip: '目标行变更单号为空',
readLayer: () => this.readDetailLabeledValue(['变更单号', 'DI变更单号', '单号']),
missingLayerMsg: '详情页应展示变更单号',
mismatchMsg: '详情页变更单号应与主列表一致',
});
}
override async rowActionSmoke(op?: string): Promise<void> {
if (op === '详情') {
await this.assertDetailChangeOrderNoMatchesList();
return;
}
await super.rowActionSmoke(op);
}
} }
export class ProdmQualRulePage extends ProdmSharedPage { export class ProdmQualRulePage extends ProdmSharedPage {
constructor(page: Page) { constructor(page: Page) {
super(page, prodmPages.qualRule); super(page, prodmPages.qualRule);
} }
/** 新增页/列表筛选区可能同名字段;取最后一个可见表单项。 */
private async visibleFormItem(label: string) {
const items = this.page.locator('.ant-form-item').filter({ hasText: label });
const count = await items.count();
for (let i = count - 1; i >= 0; i -= 1) {
const it = items.nth(i);
if (await it.isVisible().catch(() => false)) return it;
}
return items.last();
}
private async selectVisibleFormOption(label: string, optionText?: string): Promise<void> {
await this.page.keyboard.press('Escape').catch(() => undefined);
await this.waitSettle(200);
const item = await this.visibleFormItem(label);
await expect(item, `表单项「${label}」应可见`).toBeVisible({ timeout: 8_000 });
const selector = item.locator('.ant-select').first();
await selector.click();
await expect(selector, `「${label}」应进入打开态`).toHaveClass(/ant-select-open|ant-select-focused/, {
timeout: 5_000,
});
// 只认当前未隐藏的下拉,避免产品性质/境内境外选项串台
const dropdown = this.page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').last();
await expect(dropdown, `「${label}」下拉应打开`).toBeVisible({ timeout: 8_000 });
const opts = dropdown.locator('.ant-select-item-option:not(.ant-select-item-option-disabled)');
await expect.poll(async () => opts.count(), { timeout: 8_000 }).toBeGreaterThan(0);
const preferred =
optionText
? opts.filter({ hasText: optionText }).first()
: opts.filter({ hasText: /^全部$/ }).or(opts.filter({ hasText: /境内|境外|港澳台|I类|II类|III类|一类|二类|三类/ })).first();
if (await preferred.isVisible().catch(() => false)) {
await preferred.click();
} else {
await opts.first().click({ force: true });
}
await expect
.poll(async () => {
const text = ((await selector.locator('.ant-select-selection-item').innerText().catch(() => '')) || '').trim();
return text && !/^请选择/.test(text) ? text : '';
}, { timeout: 5_000, message: `「${label}」选择后应回填` })
.not.toBe('');
await this.page.keyboard.press('Escape').catch(() => undefined);
await this.waitSettle(400);
}
/**
* 新增:选用列表已有「产品性质」+ 随机必填 → 新增规则组 → 保存,
* 断言失败提示弹框/错误提示出现(如组合已存在)。
*/
async assertAddSaveFailsWithPrompt(): Promise<void> {
await this.ensureListDataReady();
const rows = await this.listRows();
if ((await rows.count()) === 0) {
await this.skipWhenDataInsufficient('列表无数据,无法取产品性质样本');
}
const nature = this.usableSampleValue(await this.tryCell(rows.first(), '产品性质'));
if (!nature) {
await this.skipWhenDataInsufficient('首行产品性质为空');
}
const addBtn = this.toolbarButton('新增');
await expect(addBtn, '工具栏应有「新增」').toBeVisible({ timeout: 8_000 });
await this.assertBeforeBusinessAction('新增');
await this.click(addBtn, '新增');
await expect(this.page.getByText(/产品资质规则新增/), '应进入新增页').toBeVisible({
timeout: 12_000,
});
// 产品性质会级联重置境内/境外、管理类别,需先选性质并等待后再填其余必填
await this.selectVisibleFormOption('产品性质', nature);
await this.waitSettle(600);
await this.selectVisibleFormOption('境内/境外');
await this.waitSettle(300);
await this.selectVisibleFormOption('管理类别');
const nameItem = await this.visibleFormItem('规则名称');
await nameItem.locator('input').first().fill(`auto-qrl-${Date.now()}`);
for (const label of ['产品性质', '境内/境外', '管理类别'] as const) {
const item = await this.visibleFormItem(label);
const selected = (
(await item.locator('.ant-select-selection-item').first().innerText().catch(() => '')) || ''
).trim();
expect(selected && !/^请选择/.test(selected), `「${label}」应已选择`).toBeTruthy();
}
await this.click(this.page.getByRole('button', { name: /新增规则/ }).first(), '新增规则');
const modal = this.page.locator('.ant-modal-content').filter({ hasText: '新增规则' }).last();
await expect(modal, '应打开「新增规则」弹窗').toBeVisible({ timeout: 8_000 });
await modal
.locator('.ant-form-item')
.filter({ hasText: '规则组名称' })
.locator('input')
.fill(`auto-group-${Date.now()}`);
await modal.locator('.ant-form-item').filter({ hasText: '规则组类型' }).locator('.ant-select').click();
const typeOpt = this.page
.locator('.ant-select-item-option')
.filter({ hasText: /强制必须|任选其一/ })
.last();
await expect(typeOpt, '规则组类型选项应可见').toBeVisible({ timeout: 5_000 });
await typeOpt.click();
await modal.getByRole('button', { name: /确\s*定/ }).click();
await expect(this.page.getByRole('button', { name: /移\s*除/ }).first(), '规则组应写入左侧列表').toBeVisible({
timeout: 8_000,
});
// 保存失败属预期:允许业务错误提示,避免健康检查误判
getRuntimeHealthGuard(this.page)?.allowExpected(
/已存在|重复|失败|不能|不可|请|规则|保存|组合|冲突/,
);
await this.assertBeforeBusinessAction('保存');
await this.click(this.page.getByRole('button', { name: /保\s*存/ }).first(), '保存');
const prompt = this.page
.locator('.ant-modal-confirm')
.or(this.page.locator('.ant-modal-wrap').filter({ hasText: /提示|失败|已存在|不能|错误|重复/ }))
.or(this.page.locator('.ant-message-notice, .ant-message-error, .ant-notification-notice'))
.or(this.page.locator('.ant-form-item-explain-error'))
.first();
await expect(prompt, '保存失败应弹出对应提示弹框/错误提示').toBeVisible({ timeout: 12_000 });
// 关闭提示,返回列表,避免脏状态
const ok = this.page
.locator('.ant-modal-confirm, .ant-modal-wrap')
.getByRole('button', { name: /确\s*定|知\s*道|关\s*闭/ })
.last();
if (await ok.isVisible().catch(() => false)) {
await ok.click().catch(() => undefined);
}
await this.closeLayer();
await this.expectOnPage();
}
} }
export class ProdmQualOverviewPage extends ProdmSharedPage { export class ProdmQualOverviewPage extends ProdmSharedPage {
......
import { expect, type Locator, type Page } from '@playwright/test'; import { expect, test, type Locator, type Page } from '@playwright/test';
import { timeouts } from '../../config/timeouts.config';
import { headersJoinedMatchExpected } from '../../core/column-header-match.util'; import { headersJoinedMatchExpected } from '../../core/column-header-match.util';
import { type ProdmPageMeta, prodmMenuGroup } from '../../data/prodm.data'; import { getRuntimeHealthGuard } from '../../core/runtime-health.util';
import {
type ProdmComboFieldDef,
type ProdmComboSample,
type ProdmPageMeta,
prodmMenuGroup,
} from '../../data/prodm.data';
import {
exportAfterQueryByFirstRowSample,
tryFillCommonSearchInput,
} from './helpers/export-after-first-row-query.helper';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper'; import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper'; import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { SpdProTableListControls } from './helpers/spd-protable-list.controls'; import { SpdProTableListControls } from './helpers/spd-protable-list.controls';
...@@ -10,6 +21,7 @@ import { WhFlowSharedPage } from './wh-flow-shared.page'; ...@@ -10,6 +21,7 @@ import { WhFlowSharedPage } from './wh-flow-shared.page';
/** 工具栏文案别名:CSV 标题 → 现网按钮文案候选 */ /** 工具栏文案别名:CSV 标题 → 现网按钮文案候选 */
const TOOLBAR_ALIASES: Record<string, string[]> = { const TOOLBAR_ALIASES: Record<string, string[]> = {
重新发起审核: ['批量重新审核', '重新发起审核'], 重新发起审核: ['批量重新审核', '重新发起审核'],
批量重新审核: ['批量重新审核', '重新发起审核'],
产品初审核对: ['产品初审核对', '批量审核', '审核'], 产品初审核对: ['产品初审核对', '批量审核', '审核'],
批量审核: ['批量审核', '审核'], 批量审核: ['批量审核', '审核'],
批量是否收费: ['批量是否收费'], 批量是否收费: ['批量是否收费'],
...@@ -18,6 +30,7 @@ const TOOLBAR_ALIASES: Record<string, string[]> = { ...@@ -18,6 +30,7 @@ const TOOLBAR_ALIASES: Record<string, string[]> = {
批量调整: ['批量调整', '批量调整(0)'], 批量调整: ['批量调整', '批量调整(0)'],
采纳: ['采纳'], 采纳: ['采纳'],
分类维护: ['分类维护', '新增分类'], 分类维护: ['分类维护', '新增分类'],
新增分类: ['新增分类', '分类维护'],
添加: ['添加', '添 加'], 添加: ['添加', '添 加'],
新增: ['新增', '新 增'], 新增: ['新增', '新 增'],
添加中心库库区产品: ['添加中心库库区产品', '添加'], 添加中心库库区产品: ['添加中心库库区产品', '添加'],
...@@ -25,7 +38,6 @@ const TOOLBAR_ALIASES: Record<string, string[]> = { ...@@ -25,7 +38,6 @@ const TOOLBAR_ALIASES: Record<string, string[]> = {
一键导入: ['一键导入'], 一键导入: ['一键导入'],
缺货信息维护: ['缺货信息维护', '新增', '新 增'], 缺货信息维护: ['缺货信息维护', '新增', '新 增'],
替代产品: ['替代产品'], 替代产品: ['替代产品'],
资质判断结果: ['资质判断结果'],
}; };
/** 产品管理共用 Page:动作与取数;断言在 spec。 */ /** 产品管理共用 Page:动作与取数;断言在 spec。 */
...@@ -251,13 +263,338 @@ export class ProdmSharedPage extends WhFlowSharedPage { ...@@ -251,13 +263,338 @@ export class ProdmSharedPage extends WhFlowSharedPage {
expect(snap.total, '有数据时应展示总条数').toBeGreaterThan(0); expect(snap.total, '有数据时应展示总条数').toBeGreaterThan(0);
} }
/**
* 行内详情:断言详情页物料编号/编码与列表首行一致,再返回列表。
*/
async assertDetailMaterialCodeMatchesList(): Promise<void> {
await this.assertRowActionMaterialCodeMatchesList('详情');
}
/**
* 行内审核:断言审核页物料编号/编码与主列表首行一致,再关闭返回。
*/
async assertAuditMaterialCodeMatchesList(): Promise<void> {
await this.assertRowActionMaterialCodeMatchesList('审核');
}
/**
* 行内日志:断言日志详情弹框物料编号/编码与主列表该行一致,再关闭返回。
*/
async assertLogMaterialCodeMatchesList(): Promise<void> {
await this.assertRowActionMaterialCodeMatchesList('日志');
}
/**
* 行内详情:断言详情页「变更类型」与主列表该行一致,再返回列表。
*/
async assertDetailChangeTypeMatchesList(): Promise<void> {
await this.assertRowActionChangeTypeMatchesList('详情');
}
/**
* 行内编辑:断言编辑页「变更类型」与主列表该行一致,再关闭返回。
*/
async assertEditChangeTypeMatchesList(): Promise<void> {
await this.assertRowActionChangeTypeMatchesList('编辑');
}
/**
* 行内编辑:断言编辑页「产品性质」与主列表该行一致,再关闭返回。
*/
async assertEditProductNatureMatchesList(): Promise<void> {
await this.assertRowActionFieldMatchesList({
op: '编辑',
listHeaders: ['产品性质'],
emptyListSkip: '目标行产品性质为空',
readLayer: () => this.readDetailLabeledValue(['产品性质']),
missingLayerMsg: '编辑页应展示产品性质',
mismatchMsg: '编辑页产品性质应与主列表一致',
});
}
/**
* 行内操作:打开弹层后点击「关闭」关闭(不提交)。
* 用于「调整是否收费」等明确要求点「关闭」的冒烟。
*/
async assertRowActionOpenAndCloseByCloseBtn(op: string): Promise<void> {
await this.ensureListDataReady();
const rows = await this.listRows();
const rowCount = await rows.count();
if (rowCount === 0) {
await this.skipWhenDataInsufficient('列表无数据');
}
await this.page
.locator('.ant-table-body')
.first()
.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
})
.catch(() => undefined);
const compact = op.replace(/\s/g, '');
const pattern = new RegExp(compact.split('').join('\\s*'));
const fixedBtn = this.page
.locator('.ant-table-cell-fix-right')
.getByRole('button', { name: pattern })
.first();
let btn: Locator | null = null;
if (await fixedBtn.isVisible().catch(() => false)) {
btn = fixedBtn;
} else {
const scanLimit = Math.min(rowCount, 20);
for (let i = 0; i < scanLimit; i += 1) {
const rowBtn = this.rowAction(rows.nth(i), op);
if (await rowBtn.isVisible().catch(() => false)) {
btn = rowBtn;
break;
}
}
}
if (!btn) {
await this.skipWhenDataInsufficient(`行内无「${op}」按钮`);
return;
}
await this.assertBeforeBusinessAction(op);
await this.click(btn, op);
const opened = await this.waitLayerOpen(12_000);
expect(opened, `点击「${op}」后应打开弹窗/抽屉`).toBeTruthy();
const layer = this.page
.locator('.ant-modal:visible, .ant-drawer-open')
.last();
await expect(layer, `「${op}」弹层应可见`).toBeVisible({ timeout: 8_000 });
const closeBtn = layer.getByRole('button', { name: /关\s*闭/ }).first();
await expect(closeBtn, '弹框应可见「关闭」').toBeVisible({ timeout: 8_000 });
await this.click(closeBtn, '关闭');
await this.waitSettle(400);
await expect(layer, '点击「关闭」后弹层应关闭').toBeHidden({ timeout: 8_000 });
await this.expectOnPage();
}
/**
* 行内操作进入层后:断言层内物料编号/编码与列表该行一致,再关闭。
* (现网字段文案为「物料编号」,亦兼容「物料编码」。)
*/
async assertRowActionMaterialCodeMatchesList(
op: '详情' | '审核' | '查看' | '日志',
): Promise<void> {
await this.assertRowActionFieldMatchesList({
op,
listHeaders: ['物料编号', '物料编码'],
emptyListSkip: '目标行物料编号/编码为空',
readLayer: () => this.readDetailMaterialCode(),
missingLayerMsg: `${op}页应展示物料编号/编码`,
mismatchMsg: `${op}页物料编号应与主列表一致`,
});
}
/**
* 行内查看:断言弹层物料编号/编码与列表该行一致,再关闭返回。
*/
async assertViewMaterialCodeMatchesList(): Promise<void> {
await this.assertRowActionMaterialCodeMatchesList('查看');
}
/**
* 行内追溯:断言详情弹框「器械注册人」与列表该行一致,再关闭返回。
*/
async assertTraceRegistrantMatchesList(): Promise<void> {
await this.assertRowActionFieldMatchesList({
op: '追溯',
listHeaders: ['器械注册人'],
emptyListSkip: '目标行器械注册人为空',
readLayer: () => this.readDetailLabeledValue(['器械注册人', '注册人名称', '注册人']),
missingLayerMsg: '追溯详情弹框应展示器械注册人',
mismatchMsg: '追溯详情弹框器械注册人应与主列表一致',
});
}
/**
* 行内操作进入层后:断言层内「变更类型」与列表该行一致,再关闭。
*/
async assertRowActionChangeTypeMatchesList(op: '详情' | '编辑'): Promise<void> {
await this.assertRowActionFieldMatchesList({
op,
listHeaders: ['变更类型'],
emptyListSkip: '目标行变更类型为空',
readLayer: () => this.readDetailChangeType(),
missingLayerMsg: `${op}页应展示变更类型`,
mismatchMsg: `${op}页变更类型应与主列表一致`,
});
}
/**
* 行内操作:记录列表字段 → 打开层 → 断言层内同名字段一致 → 关闭。
*/
protected async assertRowActionFieldMatchesList(opts: {
op: string;
listHeaders: string[];
emptyListSkip: string;
readLayer: () => Promise<string>;
missingLayerMsg: string;
mismatchMsg: string;
}): Promise<void> {
const { op, listHeaders, emptyListSkip, readLayer, missingLayerMsg, mismatchMsg } = opts;
await this.ensureListDataReady();
const rows = await this.listRows();
const rowCount = await rows.count();
if (rowCount === 0) {
await this.skipWhenDataInsufficient('列表无数据');
}
await this.page
.locator('.ant-table-body')
.first()
.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
})
.catch(() => undefined);
const compact = op.replace(/\s/g, '');
const pattern = new RegExp(compact.split('').join('\\s*'));
const fixedBtn = this.page
.locator('.ant-table-cell-fix-right')
.getByRole('button', { name: pattern })
.first();
let targetRow = rows.first();
let btn: Locator | null = null;
if (await fixedBtn.isVisible().catch(() => false)) {
btn = fixedBtn;
} else {
const scanLimit = Math.min(rowCount, 20);
for (let i = 0; i < scanLimit; i += 1) {
const row = rows.nth(i);
const rowBtn = this.rowAction(row, op);
if (await rowBtn.isVisible().catch(() => false)) {
targetRow = row;
btn = rowBtn;
break;
}
}
}
if (!btn) {
await this.skipWhenDataInsufficient(`行内无「${op}」按钮`);
return;
}
let listValue = '';
for (const header of listHeaders) {
listValue = this.usableSampleValue(await this.tryCell(targetRow, header));
if (listValue) break;
}
if (!listValue) {
await this.skipWhenDataInsufficient(emptyListSkip);
return;
}
await this.assertBeforeBusinessAction(op);
await this.click(btn, op);
const opened = await this.waitLayerOpen(12_000);
expect(opened, `应进入「${op}」页/弹层`).toBeTruthy();
const layerValue = await readLayer();
expect(layerValue, missingLayerMsg).toBeTruthy();
expect(layerValue.replace(/\s/g, ''), mismatchMsg).toBe(listValue.replace(/\s/g, ''));
await this.closeLayer();
await this.expectOnPage();
}
protected async readDetailMaterialCode(): Promise<string> {
return this.readDetailLabeledValue(['物料编号', '物料编码']);
}
protected async readDetailChangeType(): Promise<string> {
return this.readDetailLabeledValue(['变更类型']);
}
/** 详情/编辑层:按表单项/描述项标签读取值(兼容 input、select、纯文本)。 */
protected async readDetailLabeledValue(labels: string[]): Promise<string> {
const detailRoot = this.detailContentRoot();
for (const label of labels) {
const item = detailRoot
.locator('.ant-form-item, .ant-descriptions-item')
.filter({
has: this.page
.locator('.ant-form-item-label, .ant-descriptions-item-label, label')
.filter({ hasText: new RegExp(`^\\s*${label}\\s*[::]?\\s*$`) }),
})
.first();
if (!(await item.isVisible().catch(() => false))) continue;
const selectItem = item.locator('.ant-select-selection-item').first();
if ((await selectItem.count()) > 0 && (await selectItem.isVisible().catch(() => false))) {
const selected = this.usableSampleValue(
((await selectItem.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' '),
);
if (selected && !/^请选择/.test(selected)) return selected;
}
const input = item.locator('input:not([type="hidden"]), textarea').first();
if ((await input.count()) > 0 && (await input.isVisible().catch(() => false))) {
const typed = this.usableSampleValue(await input.inputValue().catch(() => ''));
if (typed) return typed;
}
const content = item
.locator('.ant-descriptions-item-content, .ant-form-item-control')
.first();
const text = this.usableSampleValue(
((await content.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' '),
);
if (text && !/^请选择/.test(text)) return text;
}
const labeled = await detailRoot.evaluate((root, labs: string[]) => {
const walk = (label: string): string => {
const nodes = [...root.querySelectorAll('*')];
for (const el of nodes) {
const raw = (el.textContent || '').replace(/\s+/g, ' ').trim();
if (raw !== label && raw !== `${label}:` && raw !== `${label}:`) continue;
const next = el.parentElement;
const block = (next?.innerText || '').replace(/\s+/g, ' ').trim();
const m = block.match(new RegExp(`${label}\\s*[::]?\\s*(.+)$`));
const val = (m?.[1] || '').trim();
if (val && !val.startsWith('请选择')) return val.split(/\s{2,}|\n/)[0] || val;
}
return '';
};
for (const lab of labs) {
const v = walk(lab);
if (v) return v;
}
return '';
}, labels);
return this.usableSampleValue(labeled);
}
/** 详情/审核层内容区:兼容返回、关闭、通过/驳回等页脚按钮。 */
protected detailContentRoot(): Locator {
const layerBtn = /返\s*回|关\s*闭|通\s*过|驳\s*回|取\s*消/;
return this.page
.locator('.ant-drawer-open .ant-drawer-body, .ant-modal:visible .ant-modal-body, .put-page, .ant-pro-page-container')
.filter({ has: this.page.getByRole('button', { name: layerBtn }) })
.last()
.or(
this.page
.locator('main, .ant-pro-page-container, .put-page')
.filter({ has: this.page.getByRole('button', { name: layerBtn }) })
.last(),
);
}
async rowActionSmoke(op?: string): Promise<void> { async rowActionSmoke(op?: string): Promise<void> {
await this.ensureListDataReady(); await this.ensureListDataReady();
const rows = await this.listRows(); const rows = await this.listRows();
if ((await rows.count()) === 0) { const rowCount = await rows.count();
if (rowCount === 0) {
await this.skipWhenDataInsufficient('列表无数据'); await this.skipWhenDataInsufficient('列表无数据');
} }
// 操作列可能在视口外 // 操作列可能在视口外 / 右侧固定列
await this.page await this.page
.locator('.ant-table-body') .locator('.ant-table-body')
.first() .first()
...@@ -266,27 +603,44 @@ export class ProdmSharedPage extends WhFlowSharedPage { ...@@ -266,27 +603,44 @@ export class ProdmSharedPage extends WhFlowSharedPage {
}) })
.catch(() => undefined); .catch(() => undefined);
const row = rows.first();
const targets = op ? [op] : this.prodm.rowOps; const targets = op ? [op] : this.prodm.rowOps;
for (const name of targets) { const tryOpen = async (btn: Locator, name: string, row?: Locator): Promise<boolean> => {
const btn = this.rowAction(row, name); if (!(await btn.isVisible().catch(() => false))) return false;
if (!(await btn.isVisible().catch(() => false))) continue;
await this.click(btn, name); await this.click(btn, name);
const opened = await this.waitLayerOpen(8_000); const opened = await this.waitLayerOpen(8_000);
if (opened) { if (opened) {
await this.closeLayer(); await this.closeLayer();
await this.expectOnPage(); await this.expectOnPage();
return; return true;
} }
// 行内编辑:点击后同行出现取消/保存,无独立弹层 if (row) {
const inlineCancel = row.getByRole('button', { name: /取\s*消|保\s*存/ }).first(); const inlineCancel = row.getByRole('button', { name: /取\s*消|保\s*存/ }).first();
if (await inlineCancel.isVisible().catch(() => false)) { if (await inlineCancel.isVisible().catch(() => false)) {
await this.click(inlineCancel, '取消行内编辑'); await this.click(inlineCancel, '取消行内编辑');
await this.waitSettle(300); await this.waitSettle(300);
await this.expectOnPage(); await this.expectOnPage();
return; return true;
}
}
return false;
};
for (const name of targets) {
const compact = name.replace(/\s/g, '');
const pattern = new RegExp(compact.split('').join('\\s*'));
// 右侧固定操作列(详情常在此,与主表行分离)
const fixedBtn = this.page
.locator('.ant-table-cell-fix-right')
.getByRole('button', { name: pattern })
.first();
if (await tryOpen(fixedBtn, name)) return;
// 「全部产品」等混合态:首行可能是「审核」、其它行才是目标操作——指定 op 时扫前若干行
const scanLimit = op ? Math.min(rowCount, 20) : 1;
for (let i = 0; i < scanLimit; i += 1) {
const row = rows.nth(i);
if (await tryOpen(this.rowAction(row, name), name, row)) return;
} }
await this.skipWhenDataInsufficient(`行内「${name}」未打开弹层/详情/行内编辑`);
} }
await this.skipWhenDataInsufficient(`行内无可见操作:${targets.join('/')}`); await this.skipWhenDataInsufficient(`行内无可见操作:${targets.join('/')}`);
} }
...@@ -328,6 +682,111 @@ export class ProdmSharedPage extends WhFlowSharedPage { ...@@ -328,6 +682,111 @@ export class ProdmSharedPage extends WhFlowSharedPage {
} }
} }
/** 清空列表行勾选(含表头全选)。 */
async clearTableRowChecks(): Promise<void> {
const headerChecked = this.page.locator('.ant-table-thead .ant-checkbox-checked').first();
if (await headerChecked.isVisible().catch(() => false)) {
await headerChecked.click().catch(() => undefined);
await this.waitSettle(200);
}
const checked = this.page.locator(
'.ant-table-tbody tr.ant-table-row .ant-checkbox-input:checked',
);
const n = await checked.count();
for (let i = 0; i < n; i += 1) {
await checked.nth(0).uncheck({ force: true }).catch(() => undefined);
}
await this.waitSettle(200);
}
/** 未勾选列表明细时,工具栏按钮应置灰不可点击。 */
async assertToolbarDisabledWithoutRowSelection(label: string): Promise<void> {
await this.clearTableRowChecks();
const btn = this.toolbarButton(label);
if (!(await btn.isVisible({ timeout: 8_000 }).catch(() => false))) {
await this.skipWhenDataInsufficient(`工具栏无「${label}」按钮`);
}
const disabled =
(await btn.isDisabled().catch(() => false)) ||
((await btn.getAttribute('class')) ?? '').includes('ant-btn-disabled') ||
(await btn.getAttribute('aria-disabled')) === 'true';
expect(disabled, `未勾选列表明细时「${label}」应置灰不可点击`).toBeTruthy();
}
/** 读取工具栏按钮文案中的计数,如「批量转移(2)」→ 2。 */
async readToolbarCountLabel(label: string): Promise<number> {
const text = ((await this.toolbarButton(label).innerText().catch(() => '')) ?? '').replace(
/\s/g,
'',
);
const match = text.match(/[((](\d+)[))]/);
return match ? Number(match[1]) : 0;
}
/**
* 勾选列表明细后,断言工具栏计数按钮(如「批量转移(N)」)与勾选条数一致。
* 只读校验,不点击提交。
*/
async assertSelectionMatchesToolbarCount(label: string, maxSelect = 2): Promise<void> {
await this.ensureListDataReady();
await this.clearTableRowChecks();
const btn = this.toolbarButton(label);
if (!(await btn.isVisible({ timeout: 8_000 }).catch(() => false))) {
await this.skipWhenDataInsufficient(`工具栏无「${label}」按钮`);
}
const before = await this.readToolbarCountLabel(label);
expect(before, `未勾选时「${label}」数量应为 0`).toBe(0);
const rows = this.page.locator(
'.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)',
);
const rowCount = await rows.count();
if (rowCount === 0) {
await this.skipWhenDataInsufficient('列表无数据');
}
const checkCount = Math.min(maxSelect, rowCount);
expect(checkCount, '应至少可勾选 1 行').toBeGreaterThan(0);
for (let i = 0; i < checkCount; i += 1) {
const checkbox = rows.nth(i).locator('input.ant-checkbox-input, .ant-checkbox-input').first();
if (!(await checkbox.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('列表无勾选框');
}
await checkbox.check({ force: true });
await this.waitSettle(200);
}
const after = await this.readToolbarCountLabel(label);
expect(after, `「${label}」显示数量应与勾选明细数一致`).toBe(checkCount);
await this.clearTableRowChecks();
}
/** 勾选首行后点击工具栏,断言提示弹框/弹层出现;关闭不落库。 */
async assertToolbarPromptAfterRowSelect(label: string): Promise<void> {
await this.ensureListDataReady();
await this.clearTableRowChecks();
const checkbox = this.page
.locator('.ant-table-tbody tr.ant-table-row .ant-checkbox-input')
.first();
if (!(await checkbox.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('列表无勾选框或无数据行');
}
await checkbox.check({ force: true });
await this.waitSettle(300);
const btn = this.toolbarButton(label);
if (!(await btn.isVisible({ timeout: 8_000 }).catch(() => false))) {
await this.skipWhenDataInsufficient(`工具栏无「${label}」按钮`);
}
if (await btn.isDisabled().catch(() => false)) {
await this.skipWhenDataInsufficient(`勾选后「${label}」仍禁用`);
}
await this.assertBeforeBusinessAction(`点击「${label}」`);
await this.click(btn, label);
const opened = await this.waitLayerOpen();
expect(opened, `勾选后点击「${label}」应弹出提示弹框`).toBeTruthy();
await this.closeOverlay();
}
async openToolbarOverlay(label: string): Promise<Locator> { async openToolbarOverlay(label: string): Promise<Locator> {
await this.ensureListDataReady(); await this.ensureListDataReady();
const btn = this.toolbarButton(label); const btn = this.toolbarButton(label);
...@@ -348,10 +807,39 @@ export class ProdmSharedPage extends WhFlowSharedPage { ...@@ -348,10 +807,39 @@ export class ProdmSharedPage extends WhFlowSharedPage {
return this.overlay().or(this.detailBackButton()); return this.overlay().or(this.detailBackButton());
} }
async assertRequiredInterceptFor(label: string): Promise<void> { /**
* 工具栏入口跳转详情/表单页:点击后应进入详情(弹层或带「返回」页),无报错;再关闭回到列表。
* 用于「单属性变更」「多属性变更」等新建类入口冒烟。
*/
async assertToolbarNavigatesToDetailWithoutError(label: string): Promise<void> {
const btn = this.toolbarButton(label);
if (!(await btn.isVisible({ timeout: 8_000 }).catch(() => false))) {
await this.skipWhenDataInsufficient(`工具栏无「${label}」按钮`);
}
if (await btn.isDisabled().catch(() => false)) {
await this.skipWhenDataInsufficient(`工具栏「${label}」禁用`);
}
await this.assertBeforeBusinessAction(`点击「${label}」`);
await this.click(btn, label);
const opened = await this.waitLayerOpen(15_000);
expect(opened, `点击「${label}」应跳转至详情页`).toBeTruthy();
await this.assertUiReady(`「${label}」详情页`);
const exit = this.detailBackButton()
.or(this.page.getByRole('button', { name: /取\s*消|关\s*闭/ }).first())
.first();
await expect(exit, '详情页应可见返回/取消/关闭').toBeVisible({ timeout: 8_000 });
await this.closeLayer();
await this.expectOnPage();
await this.assertUiReady(`从「${label}」返回列表`);
}
async assertRequiredInterceptFor(
label: string,
submitName: RegExp = /确\s*定|确\s*认|保\s*存|提\s*交/,
): Promise<void> {
await this.openToolbarOverlay(label); await this.openToolbarOverlay(label);
const confirm = this.page const confirm = this.page
.getByRole('button', { name: /确\s*定|确\s*认|保\s*存|提\s*交/ }) .getByRole('button', { name: submitName })
.filter({ hasNot: this.page.locator('[disabled]') }) .filter({ hasNot: this.page.locator('[disabled]') })
.last(); .last();
if (await confirm.isVisible().catch(() => false)) { if (await confirm.isVisible().catch(() => false)) {
...@@ -361,7 +849,14 @@ export class ProdmSharedPage extends WhFlowSharedPage { ...@@ -361,7 +849,14 @@ export class ProdmSharedPage extends WhFlowSharedPage {
const state = await this.interceptState(); const state = await this.interceptState();
const stillOpen = await this.isLayerOpen(); const stillOpen = await this.isLayerOpen();
expect(state.hintVisible || state.overlayOpen || stillOpen, '必填应拦截').toBeTruthy(); expect(state.hintVisible || state.overlayOpen || stillOpen, '必填应拦截').toBeTruthy();
// 必填 toast(如「请填写审批意见」)属本用例预期,收尾健康检查勿当缺陷
getRuntimeHealthGuard(this.page)?.allowExpected(/请填写|必填|请输入|请选择|不能为空|不可为空/);
await this.closeLayer(); await this.closeLayer();
await this.page
.locator('.ant-message-error, .ant-notification-notice-error')
.first()
.waitFor({ state: 'hidden', timeout: 8_000 })
.catch(() => undefined);
} }
async cancelToolbarWithoutCommit(label: string): Promise<void> { async cancelToolbarWithoutCommit(label: string): Promise<void> {
...@@ -406,6 +901,43 @@ export class ProdmSharedPage extends WhFlowSharedPage { ...@@ -406,6 +901,43 @@ export class ProdmSharedPage extends WhFlowSharedPage {
await this.waitSettle(800); await this.waitSettle(800);
} }
/**
* 取首行样本(优先物料编号/编码,其次 page2Header)填入筛选并查询,有数据再导出;无数据则 skip。
*/
async exportAfterQueryByFirstRowSample(): Promise<void> {
const headers = [
'物料编号',
'物料编码',
this.prodm.page2Header,
...(this.prodm.headers ?? []),
].filter((h, i, arr) => !!h && arr.indexOf(h) === i);
await exportAfterQueryByFirstRowSample({
page: this.page,
ensureReady: () => this.ensureListDataReady(),
getDataRowCount: () => this.getDataRowCount(),
listRows: () => this.listRows(),
tryCell: (row, header) => this.tryCell(row, header),
sampleHeaders: headers,
expandFilters: () => this.ensureFilterExpanded(),
tryFillSample: (value) =>
tryFillCommonSearchInput(this.page, value, {
fill: (loc, v, label) => this.fill(loc, v, label),
textInput: (label) => this.textInput(label),
labels: ['物料编号', '物料编码', '物料名称', this.prodm.page2Header, ...headers.slice(0, 4)],
}),
clickQuery: () => this.clickQuery(),
clickExport: () => this.clickExportIfVisible(),
skipWhenDataInsufficient: (reason) => this.skipWhenDataInsufficient(reason),
usableSampleValue: (raw) => this.usableSampleValue(raw),
});
}
/** @deprecated 请用 exportAfterQueryByFirstRowSample;保留别名兼容已有调用。 */
async exportAfterQueryByFirstMaterialCode(): Promise<void> {
await this.exportAfterQueryByFirstRowSample();
}
async clickPrintIfVisible(): Promise<void> { async clickPrintIfVisible(): Promise<void> {
const btn = this.button('打印产品码').or(this.button('打印')).or(this.button('打 印')).first(); const btn = this.button('打印产品码').or(this.button('打印')).or(this.button('打 印')).first();
await expect(btn, '打印').toBeVisible(); await expect(btn, '打印').toBeVisible();
...@@ -445,17 +977,93 @@ export class ProdmSharedPage extends WhFlowSharedPage { ...@@ -445,17 +977,93 @@ export class ProdmSharedPage extends WhFlowSharedPage {
if (await close.isVisible().catch(() => false)) await this.click(close, '关闭'); if (await close.isVisible().catch(() => false)) await this.click(close, '关闭');
} }
async clickStatusTabs(): Promise<void> { statusTab(label: string): Locator {
const tabs = this.page.locator('.ant-segmented-item, .ant-radio-button-wrapper, .ant-tabs-tab').filter({ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
hasNot: this.page.locator( return this.page
'.ant-tabs-tab-active, .ant-segmented-item-selected, .ant-radio-button-wrapper-checked', .locator('.tab-li, .ant-tabs-tab, .ant-segmented-item, .ant-radio-button-wrapper')
), .filter({ hasText: new RegExp(`^\\s*${escaped}(\\s*\\(\\d+\\))?\\s*$`) })
.first();
}
async clickStatusTab(label: string): Promise<void> {
const tab = this.statusTab(label);
if (!(await tab.isVisible({ timeout: 8_000 }).catch(() => false))) {
await this.skipWhenDataInsufficient(`未找到状态 Tab「${label}」`);
}
await this.assertBeforeBusinessAction(`切换状态 Tab「${label}」`);
await this.click(tab, `切换状态 Tab「${label}」`);
await this.waitForTableReady();
await this.expectOnPage();
const active = this.page.locator(
'.tab-li.active, .ant-tabs-tab-active, .ant-segmented-item-selected, .ant-radio-button-wrapper-checked',
);
await expect(active.filter({ hasText: label }).first(), `Tab「${label}」应高亮`).toBeVisible({
timeout: 8_000,
}); });
}
async clickStatusTabs(): Promise<void> {
const labels = this.prodm.statusTabs ?? [];
if (labels.length > 0) {
let hit = 0;
for (const label of labels) {
const tab = this.statusTab(label);
if (!(await tab.isVisible().catch(() => false))) continue;
await this.clickStatusTab(label);
hit += 1;
}
if (hit === 0) await this.skipWhenDataInsufficient('未找到配置的状态 Tab');
return;
}
const tabs = this.page
.locator('.tab-li, .ant-segmented-item, .ant-radio-button-wrapper, .ant-tabs-tab')
.filter({
hasNot: this.page.locator(
'.tab-li.active, .ant-tabs-tab-active, .ant-segmented-item-selected, .ant-radio-button-wrapper-checked',
),
});
if ((await tabs.count()) === 0) await this.skipWhenDataInsufficient('无其它 Tab'); if ((await tabs.count()) === 0) await this.skipWhenDataInsufficient('无其它 Tab');
await this.click(tabs.first(), '切换 Tab'); await this.click(tabs.first(), '切换 Tab');
await this.waitForTableReady(); await this.waitForTableReady();
} }
/** 读取状态 Tab 括号计数(如「全部(12)」「待提交(0)」)。 */
async readStatusTabCount(label: string): Promise<number | null> {
const text = ((await this.statusTab(label).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const matched = text.match(/[((](\d+)[))]/);
return matched ? Number(matched[1]) : null;
}
/**
* 切换状态 Tab,并断言 Tab 上显示数量与列表「共 N 条」一致。
* Tab 无括号计数时仅校验可切换。
*/
async clickStatusTabAndMatchCount(label: string): Promise<void> {
const marked = await this.readStatusTabCount(label);
await this.clickStatusTab(label);
if (marked === null) return;
expect(await this.getTotalCount(), `状态 Tab「${label}」显示数量应与列表「共 N 条」一致`).toBe(
marked,
);
}
/** 依次切换配置的状态 Tab,并校验各 Tab 计数与列表总数一致。 */
async clickStatusTabsAndMatchCounts(): Promise<void> {
const labels = this.prodm.statusTabs ?? [];
if (labels.length === 0) {
await this.clickStatusTabs();
return;
}
let hit = 0;
for (const label of labels) {
const tab = this.statusTab(label);
if (!(await tab.isVisible().catch(() => false))) continue;
await this.clickStatusTabAndMatchCount(label);
hit += 1;
}
if (hit === 0) await this.skipWhenDataInsufficient('未找到配置的状态 Tab');
}
async visibleTreeNames(): Promise<string[]> { async visibleTreeNames(): Promise<string[]> {
// 大树可能上千节点:只取视口内前若干个,避免逐节点 innerText 超时 // 大树可能上千节点:只取视口内前若干个,避免逐节点 innerText 超时
const nodes = this.treeRoot().locator('.ant-tree-treenode:visible .ant-tree-title'); const nodes = this.treeRoot().locator('.ant-tree-treenode:visible .ant-tree-title');
...@@ -478,11 +1086,37 @@ export class ProdmSharedPage extends WhFlowSharedPage { ...@@ -478,11 +1086,37 @@ export class ProdmSharedPage extends WhFlowSharedPage {
} }
async clickTreeNode(name: string): Promise<void> { async clickTreeNode(name: string): Promise<void> {
const node = this.treeTitles().filter({ hasText: name }).first(); const exact = new RegExp(`^\\s*${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`);
const node = this.treeTitles().filter({ hasText: exact }).first();
// 虚拟树:节点可能未进视口,滚动 list-holder 直至可见
const holder = this.treeRoot().locator('.ant-tree-list-holder').first();
if (!(await node.isVisible().catch(() => false)) && (await holder.count()) > 0) {
await holder.evaluate((el) => {
(el as HTMLElement).scrollTop = 0;
});
for (let i = 0; i < 60; i += 1) {
if (await node.isVisible().catch(() => false)) break;
await holder.evaluate((el) => {
const nodeEl = el as HTMLElement;
nodeEl.scrollTop += Math.max(nodeEl.clientHeight, 200);
});
await this.waitSettle(80);
}
}
await node.scrollIntoViewIfNeeded().catch(() => undefined);
await this.click(node, `选中树节点 ${name}`); await this.click(node, `选中树节点 ${name}`);
await this.waitForTableReady(); await this.waitForTableReady();
} }
/** 选中配置/指定的左侧树节点(已选中则跳过)。 */
async selectDefaultTreeNode(name?: string): Promise<void> {
const target = (name ?? this.prodm.defaultTreeNode ?? '').trim();
if (!target) return;
const current = await this.selectedTreeName();
if (current === target) return;
await this.clickTreeNode(target);
}
async switchOtherTreeNode(): Promise<string | null> { async switchOtherTreeNode(): Promise<string | null> {
const current = await this.selectedTreeName(); const current = await this.selectedTreeName();
const names = await this.visibleTreeNames(); const names = await this.visibleTreeNames();
...@@ -523,4 +1157,185 @@ export class ProdmSharedPage extends WhFlowSharedPage { ...@@ -523,4 +1157,185 @@ export class ProdmSharedPage extends WhFlowSharedPage {
(el as HTMLElement).scrollLeft = 0; (el as HTMLElement).scrollLeft = 0;
}); });
} }
protected usableSampleValue(raw: string | undefined): string {
const text = (raw ?? '').replace(/\s+/g, ' ').trim();
if (!text || text === '--' || text === '—' || text === '-') return '';
return text;
}
/** 展开筛选区,使折叠字段可填。 */
async ensureFilterExpanded(): Promise<void> {
await this.expandFiltersIfCollapsed(this.page);
}
async extractComboSample(
fields: readonly ProdmComboFieldDef[],
rowIndex = 0,
): Promise<ProdmComboSample> {
await this.ensureListDataReady();
const rows = await this.listRows();
const count = await rows.count();
if (count === 0) {
await this.skipWhenDataInsufficient('默认查询无数据,无法组合查询取样');
}
const row = rows.nth(Math.min(rowIndex, count - 1));
const sample: ProdmComboSample = {};
for (const field of fields) {
const cell = this.usableSampleValue(await this.tryCell(row, field.column));
if (!cell) continue;
sample[field.key] = cell;
}
// 物料名称输入框支持编号/名称/简拼:名称空时回退物料编号
if (!sample.goodsName) {
const code = this.usableSampleValue(await this.tryCell(row, '物料编号'));
if (code) sample.goodsName = code;
}
return sample;
}
async applyProdmComboFields(
sample: ProdmComboSample,
fields: readonly ProdmComboFieldDef[],
options?: { onlyIfPresent?: boolean },
): Promise<string[]> {
const onlyIfPresent = options?.onlyIfPresent !== false;
const applied: string[] = [];
for (const field of fields) {
const raw = this.usableSampleValue(sample[field.key]);
if (onlyIfPresent && !raw) continue;
if (!raw) continue;
if (field.type === 'select') {
const byFormId = field.formId
? this.page
.locator('.ant-form-item')
.filter({ has: this.page.locator(field.formId) })
.locator('.ant-select')
.first()
: null;
const select =
byFormId && (await byFormId.isVisible().catch(() => false))
? byFormId
: this.formSelect(field.label);
if (!(await select.isVisible().catch(() => false))) continue;
await this.click(select, `组合查询打开「${field.label}」`);
const dropdown = await this.activeSelectDropdown(this.page);
const optionsLoc = dropdown.locator(
'.ant-select-item-option:not(.ant-select-item-option-disabled)',
);
await expect
.poll(async () => (await optionsLoc.count()) > 0, { timeout: 15_000 })
.toBeTruthy();
const compact = raw.replace(/\s/g, '');
let target: Locator | null = null;
const count = await optionsLoc.count();
for (let i = 0; i < count; i += 1) {
const text = ((await optionsLoc.nth(i).innerText()) ?? '').replace(/\s/g, '');
if (!text) continue;
if (text === compact || text.includes(compact) || compact.includes(text)) {
target = optionsLoc.nth(i);
break;
}
}
if (!target) {
await this.page.keyboard.press('Escape').catch(() => undefined);
continue;
}
await this.click(target, `组合查询选择「${field.label}=${raw}」`);
await this.page
.locator('.ant-select-dropdown:visible')
.waitFor({ state: 'hidden', timeout: 3_000 })
.catch(() => undefined);
await this.waitSettle(200);
applied.push(field.key);
continue;
}
const input = field.formId
? this.page.locator(field.formId).first()
: this.textInput(field.label).first();
await expect(input, `「${field.label}」应可见`).toBeVisible({ timeout: 8_000 });
await input.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }));
await this.fill(input, raw, `组合查询:${field.label}`);
await expect(input, `组合查询后「${field.label}」应保留样本值`).toHaveValue(raw);
applied.push(field.key);
}
return applied;
}
/**
* 对齐 center-report-sheet STAT-IOD-004:
* 默认查询取样 → 按配置填快捷区(样本有值才写)→ 抽屉字段(本页可为空)→ 查询。
*/
async searchWithQuickAndDrawerCombo(
quickFields: readonly ProdmComboFieldDef[],
drawerFields: readonly ProdmComboFieldDef[] = [],
): Promise<ProdmComboSample> {
test.setTimeout(timeouts.comboTest);
await this.ensureListDataReady();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('默认查询无数据,跳过组合查询');
}
const allFields = [...quickFields, ...drawerFields];
const sample = await this.extractComboSample(allFields);
const usableKeys = allFields.filter((f) => this.usableSampleValue(sample[f.key]));
if (usableKeys.length === 0) {
await this.skipWhenDataInsufficient('首行样本字段均为空,无法组合查询');
}
await this.ensureFilterExpanded();
const appliedQuick = await this.applyProdmComboFields(sample, quickFields, { onlyIfPresent: true });
expect(appliedQuick.length, '快捷区至少应写入 1 个有值条件').toBeGreaterThan(0);
if (drawerFields.length > 0) {
const adv = this.page.getByText(/高级筛选/).first();
if (await adv.isVisible().catch(() => false)) {
await this.click(adv, '高级筛选');
await expect(this.page.locator('.ant-drawer-open').first(), '高级筛选抽屉').toBeVisible({
timeout: 8_000,
});
await this.applyProdmComboFields(sample, drawerFields, { onlyIfPresent: true });
const applyBtn = this.page
.locator('.ant-drawer-open')
.getByRole('button', { name: /筛\s*选|确\s*定|查\s*询/ })
.first();
if (await applyBtn.isVisible().catch(() => false)) {
await this.click(applyBtn, '应用高级筛选');
} else {
await this.closeLayer().catch(() => undefined);
}
}
}
await this.clickQuery();
await this.assertPageHealthy('组合查询后');
return sample;
}
/** 抽样首行(或当前页任一行)同时满足已填条件。 */
async expectComboResultMatchesSample(
sample: ProdmComboSample,
assertKeys: readonly string[],
): Promise<void> {
const snap = await this.snapshotList();
expect(snap.rows, '组合查询后应有结果').toBeGreaterThan(0);
const rows = await this.listRows();
const count = await rows.count();
for (const key of assertKeys) {
const value = this.usableSampleValue(sample[key]);
if (!value) continue;
const compact = value.replace(/\s+/g, '');
let matched = false;
for (let i = 0; i < count; i += 1) {
const rowText = ((await rows.nth(i).innerText().catch(() => '')) ?? '').replace(/\s+/g, '');
if (rowText.includes(compact)) {
matched = true;
break;
}
}
expect(matched, `结果应含条件「${key}=${value}」`).toBeTruthy();
}
}
} }
...@@ -692,12 +692,26 @@ export class VendorQualPage extends VendorSharedPage { ...@@ -692,12 +692,26 @@ export class VendorQualPage extends VendorSharedPage {
return { listSupplier, detailSupplier }; return { listSupplier, detailSupplier };
} }
/** 查询并选中首行后触发导出(不校验文件内容)。 */ /** 取首行销售商/供应商样本查询后再导出;列表无数据则 skip。不校验文件内容。 */
async exportAfterQueryWithFirstRow(profile: VendorQualTabProfile = vendorQualCertExpiryProfile): Promise<void> { async exportAfterQueryWithFirstRow(profile: VendorQualTabProfile = vendorQualCertExpiryProfile): Promise<void> {
test.setTimeout(120_000); test.setTimeout(120_000);
await this.openOnMainTab(profile.tab); await this.openOnMainTab(profile.tab);
await this.query(); await this.query();
await this.selectFirstRow(); if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('列表无数据,跳过导出');
return;
}
const row = await this.selectFirstRow();
const listValue = await this.sampleSupplierFromRow(row);
const filterLabel = await this.resolveVisibleSupplierFilterLabel();
await this.fillFilter(filterLabel, listValue.slice(0, Math.min(listValue.length, 24)));
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('按首行样本查询后无数据,跳过导出');
return;
}
const exportBtn = this.button('导出').or(this.button('导出明细')).first(); const exportBtn = this.button('导出').or(this.button('导出明细')).first();
if (!(await exportBtn.isVisible().catch(() => false))) { if (!(await exportBtn.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('当前 Tab 无导出按钮'); await this.skipWhenDataInsufficient('当前 Tab 无导出按钮');
......
#!/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/open-entry.spec.ts'],
['testcase/期初管理-期初明细查询-UI自动化测试用例-01.csv', 'tests/spd/open-detail.spec.ts'],
['testcase/期初管理-快速盘点记录-UI自动化测试用例-01.csv', 'tests/spd/open-scan.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].trim())) {
missing.push(`${specRel}: module ${cols[modIdx]}`);
}
}
}
if (missing.length) {
console.error(`OPEN CSV↔spec sync FAILED (${missing.length}/${total}):`);
for (const m of missing) console.error(' -', m);
process.exit(1);
}
console.log(`OPEN CSV↔spec sync OK: ${total} cases across ${PAIRS.length} pages`);
...@@ -83,6 +83,26 @@ const PAGES = { ...@@ -83,6 +83,26 @@ const PAGES = {
await clickQuery(page); await clickQuery(page);
}, },
}, },
'prodm-change-detail': {
path: '/spd/basic/goods/platform/change',
async prep(page) {
await clickQuery(page);
},
},
'prodm-supply': {
path: '/spd/basic/goods/paltform/purchases/calatog',
async prep(page) {
await selectLeftTreeByName(page, '国药控股医学检验(山东)有限公司');
await clickQuery(page);
},
},
'prodm-maintain': {
path: '/spd/basic/goods/paltform/product/calatog',
async prep(page) {
await selectLeftTreeFirst(page);
await clickQuery(page);
},
},
}; };
async function selectLeftTreeFirst(page) { async function selectLeftTreeFirst(page) {
...@@ -95,6 +115,32 @@ async function selectLeftTreeFirst(page) { ...@@ -95,6 +115,32 @@ async function selectLeftTreeFirst(page) {
} }
} }
async function selectLeftTreeByName(page, name) {
const tree = page.locator('.ant-tree').first();
await tree.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
const exact = new RegExp(`^\\s*${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`);
const node = tree.locator('.ant-tree-title').filter({ hasText: exact }).first();
const holder = tree.locator('.ant-tree-list-holder').first();
if (!(await node.isVisible().catch(() => false)) && (await holder.count()) > 0) {
await holder.evaluate((el) => {
el.scrollTop = 0;
});
for (let i = 0; i < 80; i += 1) {
if (await node.isVisible().catch(() => false)) break;
await holder.evaluate((el) => {
el.scrollTop += Math.max(el.clientHeight, 200);
});
await page.waitForTimeout(40);
}
}
if (await node.isVisible().catch(() => false)) {
await node.click();
await page.waitForTimeout(800);
return;
}
await selectLeftTreeFirst(page);
}
async function selectLeftListRow(page, index) { async function selectLeftListRow(page, index) {
const rows = page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)'); const rows = page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
const count = await rows.count(); const count = await rows.count();
......
#!/usr/bin/env python3
"""生成期初管理 3 页结构化 MD(testcases/)与同源 CSV(testcase/)。
约束:H3/用例标题/绑定的 TP 名须符合 ui-test-point-rules 第三节;
Playwright 由 generate-open-playwright.py 从 CSV 生成,须与 CSV 严格一致。
"""
from __future__ import annotations
import csv
import importlib.util
import json
import re
import sys
from collections import Counter, defaultdict
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCAN_DIR = ROOT / "data" / "scan"
TP_DIR = ROOT / "testpoint"
MD_DIR = ROOT / "testcases"
CSV_DIR = ROOT / "testcase"
TODAY = date.today().isoformat()
POSITIVE_USER = "test_1"
MOCK = "__NOT_EXIST_OPEN__99999"
GROUP = "期初管理"
CSV_FIELDS = [
"用例编号",
"功能模块",
"功能测试点",
"用例标题",
"优先级",
"前置条件",
"测试步骤",
"预期结果",
"实际结果",
]
TP_LINE_RE = re.compile(
r"\*\*(TP_OPEN_[A-Z]+_\d{3})\*\*((高|中|低))|([^|]+)|验证要点:"
)
H3_RE = re.compile(r"^### (.+)$")
H4_RE = re.compile(r"^#### (.+)$")
PRIO_MD = {"高": "P0", "中": "P1", "低": "P2"}
sys.path.insert(0, str(ROOT / "scripts"))
from spd_list_table_caps import list_table_caps, sortable_column_labels # noqa: E402
_prodm_spec = importlib.util.spec_from_file_location(
"prodm_md", ROOT / "scripts" / "generate-prodm-md.py"
)
assert _prodm_spec and _prodm_spec.loader
prodm_md = importlib.util.module_from_spec(_prodm_spec)
_prodm_spec.loader.exec_module(prodm_md)
case = prodm_md.case
combo_query = prodm_md.combo_query
reset_case = prodm_md.reset_case
expand_case = prodm_md.expand_case
advanced_open_case = prodm_md.advanced_open_case
list_core = prodm_md.list_core
list_table_feature_cases = prodm_md.list_table_feature_cases
pagination_cases = prodm_md.pagination_cases
export_case = prodm_md.export_case
write_op_cases = prodm_md.write_op_cases
page_has_advanced_drawer = prodm_md.page_has_advanced_drawer
toolbar_caps = prodm_md.toolbar_caps
detect_page_size_changer = prodm_md.detect_page_size_changer
detect_expand = prodm_md.detect_expand
def empty_list_case() -> dict:
return case(
0,
"列表",
"边界验证点",
"P1",
"无匹配空态(mock反例)",
"无匹配空列表",
[
f"名称/编号类关键字填入 `{MOCK}` 后查询",
"断言表头仍在,0 行或空态,分页总数为 0",
],
"无匹配不报错;不展示过期成功态数据。",
)
_open_tp = importlib.util.spec_from_file_location(
"open_tp", ROOT / "scripts" / "generate-open-testpoints.py"
)
assert _open_tp and _open_tp.loader
open_tp = importlib.util.module_from_spec(_open_tp)
_open_tp.loader.exec_module(open_tp)
OPEN_PAGES: list[dict] = open_tp.PAGES
def load_tp_index(name: str) -> dict[str, list[dict]]:
path = TP_DIR / f"{GROUP}-{name}-测试点分析-01.md"
text = path.read_text(encoding="utf-8")
index: dict[str, list[dict]] = defaultdict(list)
h3 = ""
h4 = ""
for line in text.splitlines():
m3 = H3_RE.match(line)
if m3:
h3 = m3.group(1).strip()
continue
m4 = H4_RE.match(line)
if m4:
h4 = m4.group(1).strip()
continue
mt = TP_LINE_RE.search(line)
if mt:
index[mt.group(3).strip()].append(
{
"id": mt.group(1),
"prio": mt.group(2),
"title": mt.group(3).strip(),
"h3": h3,
"h4": h4,
}
)
return index
def resolve_tp(index: dict[str, list[dict]], c: dict, label: str) -> dict:
title = c["tp_title"]
hits = index.get(title) or []
hint = c.get("tp_hint") or ""
if hint:
filtered = [h for h in hits if hint in h["h3"] or hint in h["title"]]
if filtered:
hits = filtered
if len(hits) == 1:
return hits[0]
raise SystemExit(
f"{label}: 无法唯一匹配测试点「{title}」hint={hint!r} hits={[h['id'] for h in hits]}\n"
f"可用: {', '.join(sorted(index))}"
)
def status_row_cases() -> list[dict]:
return [
case(
0,
"单据状态与行操作",
"功能验证点",
"P0",
"待生成行操作匹配",
"待生成行操作匹配",
[
"默认查询后定位状态为「待生成」的行(无则 skip)",
"断言该行可见「录入」和/或「删除」",
],
"待生成行操作与状态匹配。",
pre="当前页存在待生成行,否则 skip",
tp_hint="单据状态与行操作",
),
case(
0,
"单据状态与行操作",
"功能验证点",
"P0",
"已生成行操作匹配",
"已生成行操作匹配",
[
"默认查询后定位状态为「已生成」的行(无则 skip)",
"断言该行可见「查看详情」",
],
"已生成行操作与状态匹配。",
pre="当前页存在已生成行,否则 skip",
tp_hint="单据状态与行操作",
),
]
def build_cases(meta: dict) -> list[dict]:
pk = meta["pageKey"]
advanced = page_has_advanced_drawer(pk)
cases: list[dict] = []
cases.append(reset_case())
cases.append(combo_query(advanced=advanced))
if detect_expand(pk) or meta.get("filter_collapsed"):
cases.append(expand_case())
if advanced:
cases.append(advanced_open_case())
row_ops = meta.get("row_ops", "—")
include_row = meta.get("row_action", True)
cases += list_core(meta.get("columns", "主表核心列"), row_ops, include_row=include_row)
cases += list_table_feature_cases(pk)
cases.append(empty_list_case())
cases += pagination_cases(pk)
if pk == "open-entry":
cases += status_row_cases()
caps = toolbar_caps(pk)
if caps["export"]:
cases.append(export_case())
for op in meta.get("write_ops") or []:
wo = write_op_cases(op)
cases.append(wo[0])
cases.append(wo[2])
cases.append(wo[1])
return cases
def short_code(prefix: str) -> str:
return prefix.replace("TP_OPEN_", "")
def page_doc(meta: dict) -> dict:
pk = meta["pageKey"]
code = f"OPEN-{short_code(meta['prefix'])}"
scan_path = SCAN_DIR / f"{pk}-elements.json"
el_count = 0
if scan_path.exists():
el_count = json.loads(scan_path.read_text(encoding="utf-8")).get("elementCount", 0)
return {
"code": code,
"group": GROUP,
"name": meta["name"],
"path": meta["path"],
"scan": pk,
"count": el_count,
"spec": f"UI-SPEC-SPD-{meta['specId']}",
"spec_file": f"{pk}.spec.ts",
"tp_total": 0,
"nav_note": (
f"菜单「{GROUP} → {meta['name']}」;"
f"`openPath('{meta['path']}')` 并断言 URL,勿误入兄弟页。"
),
"iframe_note": "整页渲染;定位在 `page` 上。",
"write_note": "写操作仅打开弹层后取消或做必填拦截,不提交成功落库;导出不校验文件内容。",
"samples": f"查询后从首行读取样本字段;mock:`{MOCK}`。",
"skip": "会话过期、无菜单权限、菜单进入单条、未登录探针、接口失败注入、导出文件内容、写操作成功落库、弹窗全字段",
"cases": build_cases(meta),
}
def bind_tps(p: dict) -> None:
index = load_tp_index(p["name"])
p["tp_total"] = sum(len(v) for v in index.values())
for c in p["cases"]:
rec = resolve_tp(index, c, p["code"])
c["tp_id"] = rec["id"]
c["prio"] = PRIO_MD[rec["prio"]]
c["h4"] = rec["h4"]
if rec["h3"]:
c["h3"] = rec["h3"]
def renumber_cases(cases: list[dict]) -> None:
for i, c in enumerate(cases, start=1):
c["seq"] = i
def render_csv_rows(p: dict) -> list[dict]:
logged = (
f"测试环境可访问;正例账号 {POSITIVE_USER} 已通过 auth.setup 写入 storageState;"
f"具备「{GROUP} → {p['name']}」权限"
)
rows: list[dict] = []
for c in p["cases"]:
case_id = f"{p['code']}-{c['seq']:03d}"
pre_parts = [logged, f"已进入{p['name']}页({p['path']})"]
if c["pre"]:
pre_parts.append(c["pre"])
steps = "\n".join(
[f"1. 确认已加载 storageState({POSITIVE_USER})"]
+ [f"{i + 1}. {s}" for i, s in enumerate(c["steps"], start=1)]
)
rows.append(
{
"用例编号": case_id,
"功能模块": GROUP,
"功能测试点": f"{p['name']} · {c['h3']}",
"用例标题": c["title"],
"优先级": {"P0": "高", "P1": "中", "P2": "低"}[c["prio"]],
"前置条件": ";".join(pre_parts),
"测试步骤": steps,
"预期结果": c["expect"],
"实际结果": "",
}
)
return rows
def render_md(p: dict) -> str:
n = len(p["cases"])
cnt = Counter(c["prio"] for c in p["cases"])
prio_txt = " / ".join(f"{k}={cnt[k]}" for k in ("P0", "P1", "P2") if cnt[k])
lines = [
f"# {GROUP}-{p['name']} UI 自动化测试用例",
"",
f"**来源测试点**:`testpoint/{GROUP}-{p['name']}-测试点分析-01.md` ",
f"**执行 CSV**:`testcase/{GROUP}-{p['name']}-UI自动化测试用例-01.csv` ",
f"**页面 URL**:`{p['path']}` ",
f"**定位器 YAML**:`locators/spd/{p['scan']}.locator.yaml` ",
f"**元素扫描**:`data/scan/{p['scan']}-elements.json`({p['count']} 项) ",
f"**关联规范**:`knowledge-base/08-UI专项规范库/{p['spec']}.md` ",
f"**版本**:{TODAY} ",
f"**自动化框架**:Playwright + TypeScript(`tests/spd/{p['spec_file']}`,依赖 `auth.setup` 登录态)",
"",
f"**通用前置**:正例账号 `{POSITIVE_USER}` + `storageState`;具备「{GROUP} → {p['name']}」权限。 ",
f"**页面约定**:{p['iframe_note']} ",
"**隔离**:每用例独立 context(playwright-storage-state-isolation)。 ",
f"**菜单导航**:{p['nav_note']} ",
f"**写操作**:{p['write_note']} ",
f"**动态取数**:{p['samples']}",
"",
"---",
"",
f"## {GROUP}",
"",
]
current_h3 = ""
current_h4 = ""
for c in p["cases"]:
h3 = f"{p['name']} · {c['h3']}"
if h3 != current_h3:
current_h3 = h3
current_h4 = ""
lines += [f"### {h3}", ""]
if c["h4"] != current_h4:
current_h4 = c["h4"]
lines += [f"#### {current_h4}", ""]
case_id = f"{p['code']}-{c['seq']:03d}"
tc = f"TC-UI-{case_id}"
pre_txt = c["pre"] or f"已进入{p['name']}页"
lines += [
f"##### 【{c['prio']}】【{tc}】【{c['tp_id']}】{c['title']}",
"",
f"- 前置条件:{pre_txt}",
"- 操作步骤:",
]
for i, step in enumerate(c["steps"], 1):
lines.append(f" {i}. {step}")
lines.append(
f"- 自动化断言:与 CSV `{case_id}` 及 `tests/spd/{p['spec_file']}` 严格一致"
)
lines += ["", "###### 预期结果", "", c["expect"], "", "---", ""]
while lines and lines[-1] in ("", "---"):
lines.pop()
lines += [
"",
"## 质量与覆盖说明",
"",
"| 项 | 说明 |",
"|----|------|",
f"| 用例数 | **{n}** 条({prio_txt}) |",
f"| 来源 | 从 {p['tp_total']} 条测试点选取可稳定自动化场景;H3 与测试点同名 |",
f"| 未纳入自动化 | {p['skip']} |",
"| 列排序 | 测试点 1 条「各可排序列升序降序」;本 MD **每 sorter 列一条**(3.6) |",
"| 每页条数 | 有 size-changer 时标题「切换20条每页…」,绑定 TP「切换每页条数」(3.7) |",
"| 单一职责 | 每条只断言一个核心业务结果 |",
"",
]
return "\n".join(lines)
def main() -> None:
filter_keys = set(sys.argv[1:])
pages = [p for p in OPEN_PAGES if not filter_keys or p["pageKey"] in filter_keys]
if not pages:
raise SystemExit("No pages matched")
MD_DIR.mkdir(parents=True, exist_ok=True)
CSV_DIR.mkdir(parents=True, exist_ok=True)
total = 0
for meta in pages:
p = page_doc(meta)
bind_tps(p)
renumber_cases(p["cases"])
sort_tp = "各可排序列升序降序"
ids = [c["tp_id"] for c in p["cases"] if c.get("tp_title") != sort_tp]
dup = [i for i, n in Counter(ids).items() if n > 1]
if dup:
raise SystemExit(f"{p['code']} 多条用例共用 TP: {sorted(dup)}")
md_path = MD_DIR / f"{GROUP}-{p['name']}-UI自动化测试用例-01.md"
csv_path = CSV_DIR / f"{GROUP}-{p['name']}-UI自动化测试用例-01.csv"
md_path.write_text(render_md(p), encoding="utf-8")
rows = render_csv_rows(p)
with csv_path.open("w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=CSV_FIELDS)
w.writeheader()
w.writerows(rows)
total += len(rows)
print(f"OK {p['code']}\t{len(rows):2d} cases\t{md_path.name}")
print(f"done pages={len(pages)} cases={total}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""从 testcase CSV 生成 tests/spd/open-*.spec.ts,与 CSV 用例编号+标题+功能测试点严格一致。"""
from __future__ import annotations
import csv
import re
from collections import OrderedDict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CSV_DIR = ROOT / "testcase"
SPEC_DIR = ROOT / "tests" / "spd"
PAGES = [
("openEntryPage", "open-entry.spec.ts", "期初管理-期初录入-UI自动化测试用例-01.csv"),
("openDetailPage", "open-detail.spec.ts", "期初管理-期初明细查询-UI自动化测试用例-01.csv"),
("openScanPage", "open-scan.spec.ts", "期初管理-快速盘点记录-UI自动化测试用例-01.csv"),
]
ROW_OP_RE = re.compile(r"^行内「(.+?)」可达")
OPEN_RE = re.compile(r"^打开「(.+?)」对应弹窗")
CANCEL_RE = re.compile(r"^「(.+?)」取消不落库$")
REQUIRED_RE = re.compile(r"^「(.+?)」必填为空拦截$")
SORT_RE = re.compile(r"^(.+)列升序降序切换$")
def parse_csv(path: Path):
text = path.read_text(encoding="utf-8-sig")
return [r for r in csv.DictReader(text.splitlines()) if r.get("用例编号")]
def body_for(title, fixture):
f = fixture
if title == "快捷区重置恢复默认":
if fixture in ("openScanPage", "openDetailPage"):
return (
f""" await {f}.open();
await {f}.fillQuickVisibleThenResetExpectDefaults();""",
False,
)
return (
f""" await {f}.open();
await {f}.clickResetQuick();
await {f}.expectOnPage();""",
False,
)
if title == "抽屉内重置恢复默认":
return (
f""" await {f}.open();
await {f}.fillDrawerThenResetExpectDefaults();""",
False,
)
if title in ("全量可见条件组合查询", "全量查询条件组合查询"):
if fixture == "openScanPage":
return (
f""" await {f}.open();
const sample = await {f}.searchWithQuickAndDrawerCombo(
openScanComboQuickFields,
openScanComboDrawerFields,
);
await {f}.expectComboResultMatchesSample(sample, openScanComboAssertKeys);""",
False,
)
if fixture == "openDetailPage":
return (
f""" await {f}.open();
const sample = await {f}.searchWithQuickAndDrawerCombo(
openDetailComboQuickFields,
openDetailComboDrawerFields,
);
await {f}.expectComboResultMatchesSample(sample, openDetailComboAssertKeys);""",
False,
)
return (
""" test.skip(
true,
'组合查询须按页面配置 quickFields/drawerFields 实现 searchWithQuickAndDrawerCombo(对齐 center-report-sheet STAT-IOD-004),禁止临时启发式填表',
);""",
False,
)
if title == "点击高级筛选打开抽屉":
return (
f""" await {f}.open();
await {f}.openAdvancedFilterDrawer();""",
False,
)
if title in ("展开后更多条件可见", "展开第二行筛选", "筛选区收起展开"):
return (
f""" await {f}.open();
await {f}.toggleFilterExpand();""",
False,
)
if title == "列表核心列完整":
return (
f""" await {f}.open();
await {f}.assertConfiguredHeaders();""",
False,
)
if title == "列设置隐藏后再恢复":
return (
f""" await {f}.open();
await {f}.clickQuery();
await {f}.executeColumnSettingsToggleRestoreTest();""",
False,
)
sort_m = SORT_RE.match(title)
if sort_m:
col = sort_m.group(1)
return (
f""" await {f}.open();
await {f}.expectColumnSortAscDesc({col!r});""",
False,
)
if title in ("切换至第 2 页", "分页翻至第 2 页"):
return (
f""" await {f}.open();
await {f}.goToPage2ForOpen();""",
False,
)
if title == "切换20条每页页面正常且列表同步":
return (
f""" await {f}.open();
const {{ total, rowsBefore, rowsAfter }} = await {f}.switchToPageSizeTwenty();
const expectedRows = Math.min(total, 20);
expect(rowsAfter, '当前页行数应与 20 条/页及总条数一致').toBe(expectedRows);
if (total > 20) {{
expect(rowsAfter, '切换后可见行应随每页条数变化').not.toBe(rowsBefore);
}}
expect(await {f}.getTotalCount(), '总条数应不变').toBe(total);""",
False,
)
if title == "有数据时总条数":
return (
f""" await {f}.open();
await {f}.assertTotalWhenHasData();""",
False,
)
if "无匹配空态" in title:
return (
f""" await {f}.open();
await {f}.searchByMockKeyword();
const snap = await {f}.snapshotList();
expect(snap.rows === 0 || snap.emptyVisible, '无匹配应空态').toBeTruthy();""",
False,
)
if title == "横向滚动与列对齐":
return (
f""" await {f}.open();
await {f}.expectHorizontalScrollAlignment();""",
False,
)
if title in ("当前查询条件下导出", "导出入口可触发"):
return (
f""" await {f}.open();
await {f}.clickExportIfVisible();""",
False,
)
if title == "待生成行操作匹配":
return (
f""" await {f}.open();
await {f}.assertPendingRowOps();""",
False,
)
if title == "已生成行操作匹配":
return (
f""" await {f}.open();
await {f}.assertGeneratedRowOps();""",
False,
)
m = ROW_OP_RE.search(title)
if m:
op = m.group(1)
return (
f""" await {f}.open();
await {f}.rowActionSmoke({op!r});""",
False,
)
m = OPEN_RE.search(title)
if m:
label = m.group(1)
return (
f""" await {f}.open();
await {f}.openToolbarOverlay({label!r});
await {f}.closeLayer();""",
False,
)
m = REQUIRED_RE.match(title)
if m:
label = m.group(1)
return (
f""" await {f}.open();
await {f}.assertRequiredInterceptFor({label!r});""",
False,
)
m = CANCEL_RE.match(title)
if m:
label = m.group(1)
return (
f""" await {f}.open();
await {f}.cancelToolbarWithoutCommit({label!r});""",
False,
)
raise ValueError(f"未映射用例标题: {title}")
def render_spec(fixture, csv_name, spec_file, cases):
grouped = OrderedDict()
for c in cases:
grouped.setdefault(c["功能测试点"].strip(), []).append(c)
npm_script = spec_file.replace(".spec.ts", "")
lines = []
if fixture == "openScanPage":
lines.extend(
[
"import {",
" openScanComboAssertKeys,",
" openScanComboDrawerFields,",
" openScanComboQuickFields,",
"} from '../../data/open.data';",
]
)
elif fixture == "openDetailPage":
lines.extend(
[
"import {",
" openDetailComboAssertKeys,",
" openDetailComboDrawerFields,",
" openDetailComboQuickFields,",
"} from '../../data/open.data';",
]
)
lines.extend(
[
"import { expect, test } from '../../fixtures';",
"",
"/**",
f" * 全量用例:与 testcase/{csv_name} 严格一致",
f" * 运行:npm run test:{npm_script}:all",
" */",
"test.describe('期初管理', () => {",
]
)
for point, items in grouped.items():
lines.append(f" test.describe({point!r}, () => {{")
for c in items:
cid = c["用例编号"].strip()
title = c["用例标题"].strip()
test_title = f"{cid} {title}"
body, _ = body_for(title, fixture)
smoke = " { tag: '@smoke' }," if cid.endswith("-001") else ""
lines.append(f" test({test_title!r},{smoke} async ({{ {fixture} }}) => {{")
lines.append(body)
lines.append(" });")
lines.append("")
while lines and lines[-1] == "":
lines.pop()
lines.append(" });")
lines.append("")
lines.append("});")
lines.append("")
return "\n".join(lines)
def main() -> None:
SPEC_DIR.mkdir(parents=True, exist_ok=True)
for fixture, spec_file, csv_name in PAGES:
csv_path = CSV_DIR / csv_name
if not csv_path.exists():
raise SystemExit(f"missing CSV: {csv_path}")
cases = parse_csv(csv_path)
if not cases:
raise SystemExit(f"empty CSV: {csv_path}")
text = render_spec(fixture, csv_name, spec_file, cases)
out = SPEC_DIR / spec_file
out.write_text(text, encoding="utf-8")
print(f"OK {spec_file}\t{len(cases)} tests")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Generate 期初管理 (OPEN-001~003) testpoint MD and patch UI-SPEC / INDEX.
约束:测试点须严格遵循 `.cursor/rules/ui-test-point-rules.mdc` 第三节;禁止自造 TP 名或默认拆单字段筛选。
范围:`testpoint/scope/期初管理-UI测试点范围.md`
"""
from __future__ import annotations
import importlib.util
import re
import sys
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "testpoint"
KB = ROOT / "knowledge-base" / "08-UI专项规范库"
INDEX = KB / "INDEX.md"
SCOPE = "testpoint/scope/期初管理-UI测试点范围.md"
TODAY = date.today().isoformat()
_spec = importlib.util.spec_from_file_location(
"prodm_tp", ROOT / "scripts" / "generate-prodm-testpoints.py"
)
assert _spec and _spec.loader
prodm = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(prodm)
Seq = prodm.Seq
section = prodm.section
none_merged = prodm.none_merged
filter_collapse_section = prodm.filter_collapse_section
row_ops_section = prodm.row_ops_section
page_has_advanced_drawer = prodm.page_has_advanced_drawer
load_scan = prodm.load_scan
detect_expand = prodm.detect_expand
detect_page_size_changer = prodm.detect_page_size_changer
needs_horizontal_scroll = prodm.needs_horizontal_scroll
list_table_caps = prodm.list_table_caps
quick_filter_32 = prodm.quick_filter_32
advanced_filter_32 = prodm.advanced_filter_32
list_32 = prodm.list_32
export_print_32 = prodm.export_print_32
write_ops_chunks = prodm.write_ops_chunks
PEERS = "期初录入、期初明细查询、快速盘点记录"
PAGES = [
{
"pageKey": "open-entry",
"specId": "OPEN-001",
"name": "期初录入",
"prefix": "TP_OPEN_ENT",
"path": "/spd/start/init/import",
"ready": "筛选(库房/状态/期初单号)、查询/重置/高级筛选、新增、列表(待生成·录入/删除;已生成·查看详情)与分页可见",
"cover": "导航、快捷/高级筛选、列表分页、新增、行内录入/删除/查看详情",
"columns": "期初单号、库房、库区、耗材类型、品规数、库存总数、制单人、制单时间、生成时间、状态、操作",
"row_ops": "录入、删除、查看详情",
"write_ops": ["新增"],
"sibling_diff": "期初单据头列表(QC 单号)+ 状态驱动行操作;勿与「期初明细查询」明细行或「快速盘点记录」扫码流水混淆",
},
{
"pageKey": "open-detail",
"specId": "OPEN-002",
"name": "期初明细查询",
"prefix": "TP_OPEN_DTL",
"path": "/spd/start/init/detail",
"ready": "筛选(库房/期初单号/耗材类型)、查询/重置/高级筛选、明细列表与分页可见",
"cover": "导航、快捷/高级筛选、列表分页",
"columns": "期初单号、库房、库区、制单人、生成时间、规格型号、赋码管理、耗材类型、单位、库存数量、结算方式、批号",
"row_ops": "—",
"row_action": False,
"sibling_diff": "期初明细只读查询(赋码/批号/库存数量等);勿与「期初录入」单据头或盘点扫码记录混淆",
},
{
"pageKey": "open-scan",
"specId": "OPEN-003",
"name": "快速盘点记录",
"prefix": "TP_OPEN_SCN",
"path": "/spd/start/init/scanRecord",
"ready": "筛选(扫码日期/科室名称/条码号/扫码人)、查询/重置/展开收起、导出、扫码记录列表与分页可见",
"cover": "导航、快捷筛选、查询条件展开收起、列表分页、导出",
"columns": "序号、扫码时间、科室名称、条码号、扫码人",
"row_ops": "—",
"row_action": False,
"sibling_diff": "扫码盘点流水(扫码时间/条码号),无期初单号列;勿与期初录入/明细查询单据体系混淆",
},
]
for p in PAGES:
p["file"] = f"期初管理-{p['name']}-测试点分析-01.md"
SCOPE_OUTSIDE_COMMON = (
"全局左侧菜单与顶栏导航噪声、导出文件字节内容、新增/录入弹窗全字段组合与真实落库、断网/弱网"
)
def nav_section(p: dict, seq: Seq) -> str:
name = p["name"]
path = p["path"]
return section(
"页面访问与导航",
[
(
"功能验证点",
[
seq.add(
"高",
"页面主区加载完成",
f"携带有效登录态进入 `{path}`(菜单或直链),{p['ready']};左侧菜单与顶栏 Tab 高亮为{name},不误入同组其它期初管理页",
),
seq.add(
"中",
"同组期初管理菜单可切换",
f"在 scope 所列期初管理 3 个子菜单({PEERS})中切换后 URL 与内容对应,再返回本页状态正确",
),
],
),
(
"边界验证点",
[
seq.add(
"低",
"已登录直接打开 URL",
f"携带有效登录态打开 `{path}`,筛选区与列表可见,无报错遮罩,地址仍为该路径",
)
],
),
(
"异常验证点",
[
"- 无(未登录/会话过期/无菜单权限引用用户管理 `TP_USER_USR_005`~`007`;本模块自动化不单独写「菜单进入」「未登录访问拦截」用例,见 ui-test-point-rules 3.5)",
],
),
(
"集成验证点",
[
seq.add(
"中",
"顶栏院区与筛选默认一致",
"顶栏当前院区与页面库房/科室默认范围不矛盾(允许按权限裁剪)",
)
],
),
],
)
def sibling_diff_section(seq: Seq, p: dict) -> str:
note = p["sibling_diff"]
return section(
"与同组期初管理页差异",
[
(
"功能验证点",
[
seq.add(
"中",
"本页独有列与能力",
f"{note};与同组它页切换后列集/工具栏差异可辨识",
)
],
),
(
"边界验证点",
[
seq.add(
"低",
"直链不误展示它页列",
f"仅打开本页 URL `{p['path']}` 时不应出现它页独有工具栏(如错页 Tab)",
)
],
),
(
"异常验证点",
[
seq.add(
"中",
"菜单高亮与 URL 一致",
"左侧高亮菜单名、顶栏 Tab 与地址栏路径同属本页,非兄弟页 URL",
)
],
),
(
"集成验证点",
[
seq.add(
"低",
"跨页数据不串行",
"从本页切至它页再返回,本页筛选/列表恢复为本页数据域,不残留它页查询条件(以现网为准)",
)
],
),
],
)
def status_row_ops_section(seq: Seq) -> str:
"""期初录入:状态与行操作匹配(scope 业务侧重点)。"""
return section(
"单据状态与行操作",
[
(
"功能验证点",
[
seq.add(
"高",
"待生成行操作匹配",
"状态为「待生成」的行展示「录入」「删除」,不出现「查看详情」为主操作(以现网为准)",
),
seq.add(
"高",
"已生成行操作匹配",
"状态为「已生成」的行展示「查看详情」,不误提供删除落库入口(以现网为准)",
),
],
),
(
"边界验证点",
[seq.add("低", "空列表无行操作", "无数据时空态,行内按钮不可用或不可见")],
),
(
"异常验证点",
[seq.add("中", "状态与操作不一致提示", "若操作与当前状态不符被拦截,有明确提示且列表状态不误改")],
),
(
"集成验证点",
[
seq.add(
"中",
"生成后状态与明细可追溯",
"已生成单据的期初单号可在「期初明细查询」按单号检索到对应明细(允许权限裁剪)",
)
],
),
],
)
def standard_chunks(p: dict, seq: Seq) -> list[str]:
chunks: list[str] = []
advanced = page_has_advanced_drawer(p["pageKey"])
chunks.append(quick_filter_32(seq, include_combo=not advanced))
if detect_expand(p["pageKey"]) or p.get("filter_collapsed"):
chunks.append(filter_collapse_section(seq))
if advanced:
chunks.append(advanced_filter_32(seq))
row_ops = p.get("row_ops", "—")
split_row = row_ops_section(seq, row_ops)
include_row = p.get("row_action", True) and split_row is None
chunks.append(
list_32(
seq,
p.get("columns", "主表核心列(以现网为准)"),
row_ops,
page_key=p["pageKey"],
has_tabs=False,
include_row_action=include_row,
)
)
if split_row:
chunks.append(split_row)
if p["pageKey"] == "open-entry":
chunks.append(status_row_ops_section(seq))
chunks.extend(export_print_32(seq, page_key=p["pageKey"]))
chunks.extend(write_ops_chunks(seq, p))
return chunks
def quality(p: dict, tp_count: int, el_count: int) -> str:
caps = list_table_caps(p["pageKey"])
sort_note = (
"有 sorter:列表 H3 已写「各可排序列升序降序」;下游按 3.6 每列一条"
if caps["sortable"]
else "页面无列头 sorter(自检)"
)
colset_note = (
"有列设置:列表 H3 已写「列设置隐藏后再恢复」"
if caps["column_settings"]
else "页面无列设置齿轮(自检)"
)
hscroll_note = (
"宽表已写「横向滚动与列对齐」"
if needs_horizontal_scroll(p["pageKey"])
else "页面无宽表启发式,未写横向滚动(自检)"
)
pagesize_note = (
"有每页条数下拉:已写「切换每页条数」"
if detect_page_size_changer(p["pageKey"])
else "页面无 size-changer,未写切换每页条数(自检)"
)
return f"""## 质量自检摘要
| 项 | 结论 |
|----|------|
| 范围 | 在范围内:{p['cover']}。排除:断网/弱网、导出文件内容、弹窗全字段落库、登录/改密通用流程(见 `{SCOPE}`)。未声称 100% 全覆盖。 |
| 范围外未覆盖 | {SCOPE_OUTSIDE_COMMON} |
| 覆盖 | 已写功能域见正文 H3;共 **{tp_count}** 条原子测试点;扫描元素约 {el_count} 项 |
| 分类 | 功能 / 边界 / 异常 / 集成四类均已按功能点拆分 |
| 去重 | 登录态过期、无菜单权限、改密引用 `TP_USER_USR_006`/`007`,本页不展开。编号前缀 `{p['prefix']}` |
| 待评审项 | 写操作冒烟不提交脏数据;筛选收起后字段是否保留;重置后列表是否自动刷新;{sort_note};{colset_note};{hscroll_note};{pagesize_note} |
| 功能点拆分 | 未使用「筛选与查询 / 列表与分页」;快捷与高级筛选分开;有展开则单开查询条件展开收起;列表→分页→导出;写操作 H3 用业务名 |
| H3 顺序 | 快捷筛选 →(展开)→(高级筛选)→ 列表 → 分页 → 导出 → 写操作;导出/写操作未插在列表、分页前 |
| 3.2 反别名 | 无「重置恢复默认 / 分页翻页 / 导出入口可触发」等 3.2.1 禁止别名 |
| 写操作 H3 命名 | 业务名(非「xxx弹窗/抽屉」);层内为「打开对应弹窗/抽屉」,必填在边界 |
| 横向滚动 | {hscroll_note} |
| 报表能力 | 整页渲染,非统计报表 iframe 第四节清单 |
| 列排序下游 | {sort_note} |
| 分页每页条数 | {pagesize_note} |
| 下游 | 可转 testcases/MD、CSV、Playwright;扫描 `npm run discover:scan-open`;YAML `npm run discover:yaml-from-json-open`;测试点 `npm run generate:testpoints-open` |
"""
def render_testpoint(p: dict, scan: dict) -> tuple[str, int]:
spec = f"UI-SPEC-SPD-{p['specId']}"
seq = Seq(p["prefix"], spec)
nav = nav_section(p, seq)
body = "\n---\n\n".join(standard_chunks(p, seq) + [sibling_diff_section(seq, p)])
md = f"""# 期初管理-{p['name']} — 测试点分析
**版本**:01
**定稿日期**:{TODAY}
**关联需求**:`knowledge-base/08-UI专项规范库/{spec}.md`
**页面 URL**:`{scan['url']}`
**元素扫描**:`data/scan/{p['pageKey']}-elements.json`({scan['elementCount']} 项)
**定位器**:`locators/spd/{p['pageKey']}.locator.yaml`
**本次生成范围**:`{SCOPE}`
---
## 期初管理 · {p['name']}
{nav}
---
{body}
---
{quality(p, seq.n, scan['elementCount'])}
"""
return md, seq.n
def patch_ui_spec(p: dict, tp_file: str) -> None:
path = KB / f"UI-SPEC-SPD-{p['specId']}.md"
if not path.exists():
print(f"WARN missing UI-SPEC {path.name}")
return
text = path.read_text(encoding="utf-8")
text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1)
if 'related_tc: ""' in text:
text = text.replace('related_tc: ""', f'related_tc: "testpoint/{tp_file}"', 1)
else:
text = re.sub(r'related_tc: "[^"]*"', f'related_tc: "testpoint/{tp_file}"', text, count=1)
path.write_text(text, encoding="utf-8")
def patch_index(counts: dict[str, int], scans: dict[str, dict]) -> None:
text = INDEX.read_text(encoding="utf-8")
text = re.sub(r'update_time: "[^"]*"', f'update_time: "{TODAY}"', text, count=1)
for p in PAGES:
sid = p["specId"]
el = scans[sid]["elementCount"]
tp_n = counts[sid]
prefix = p["prefix"].replace("TP_", "`TP_") + "`"
row = (
f"| {p['name']} | {sid} | `{p['path']}` | "
f"已扫描 · `{p['pageKey']}` · **{el}** 项 · 测试点 **{tp_n}** 条 · {prefix} |"
)
text = re.sub(
rf"\| {re.escape(p['name'])} \| {sid} \| `{re.escape(p['path'])}` \| .+ \|",
row,
text,
count=1,
)
if "generate:testpoints-open" not in text:
text = text.replace(
"npm run discover:scan-open\n",
"npm run discover:scan-open\n"
"npm run discover:yaml-from-json-open\n"
"npm run generate:testpoints-open\n",
)
INDEX.write_text(text, encoding="utf-8")
def assert_no_banned_aliases(md: str, file: str) -> None:
"""仅扫描测试点标题(|…|),避免质量自检表提到禁止别名时误报。"""
titles = re.findall(r"\*\*TP_[A-Z0-9_]+_\d{3}\*\*([^)]+)|([^|]+)|", md)
banned_exact = {
"重置恢复默认",
"分页翻页",
"导出入口可触发",
"导出明细可触发",
"每页条数切换",
"打开新增弹窗",
"打开新增抽屉",
}
for title in titles:
if title in banned_exact:
raise SystemExit(f"banned alias TP title in {file}: {title}")
for m in re.finditer(r"^### (.+)\s*$", md, re.M):
h3 = m.group(1).strip()
if h3 in ("高级筛选抽屉",):
continue
if h3.endswith("弹窗") or h3.endswith("抽屉") or "弹窗/抽屉" in h3:
raise SystemExit(f"banned write-op H3 name in {file}: {h3}")
def main() -> None:
filter_keys = set(sys.argv[1:])
pages = [p for p in PAGES if not filter_keys or p["pageKey"] in filter_keys]
if not pages:
raise SystemExit("No pages matched. Available: " + ", ".join(p["pageKey"] for p in PAGES))
OUT_DIR.mkdir(parents=True, exist_ok=True)
counts: dict[str, int] = {}
scans: dict[str, dict] = {}
for p in pages:
scan = load_scan(p["pageKey"])
md, n = render_testpoint(p, scan)
assert_no_banned_aliases(md, p["file"])
out = OUT_DIR / p["file"]
out.write_text(md, encoding="utf-8")
patch_ui_spec(p, p["file"])
counts[p["specId"]] = n
scans[p["specId"]] = scan
print(f"OK {p['specId']}\t{p['name']}\t{scan['elementCount']} el\t{n} TP\t{out.name}")
for p in PAGES:
if p["specId"] not in scans:
scans[p["specId"]] = load_scan(p["pageKey"])
if p["specId"] not in counts:
tp_file = OUT_DIR / p["file"]
counts[p["specId"]] = (
len(re.findall(r"\*\*TP_[A-Z0-9_]+_\d{3}\*\*", tp_file.read_text(encoding="utf-8")))
if tp_file.exists()
else 0
)
patch_index(counts, scans)
print("done", sum(counts.values()), "TP total across", len(PAGES), "pages")
if __name__ == "__main__":
main()
...@@ -198,6 +198,62 @@ def body_for(title, fixture): ...@@ -198,6 +198,62 @@ def body_for(title, fixture):
await {f}.rowActionSmoke({op!r});""".format(f=f, op=op), await {f}.rowActionSmoke({op!r});""".format(f=f, op=op),
False, False,
) )
if title == "打开「新增分类」对应弹窗/抽屉":
return (
""" await {f}.open();
await {f}.openAddCategoryOverlay();
await {f}.closeOverlay();""".format(f=f),
False,
)
if title == "「新增分类」取消不落库":
return (
""" await {f}.open();
await {f}.cancelAddCategoryWithoutCommit();""".format(f=f),
False,
)
if title == "「新增分类」必填为空拦截":
return (
""" await {f}.open();
await {f}.assertAddCategoryRequiredIntercept();""".format(f=f),
False,
)
if title == "打开「编辑分类」对应弹窗/抽屉":
return (
""" await {f}.open();
await {f}.openEditCategoryOverlay();
await {f}.closeOverlay();""".format(f=f),
False,
)
if title == "「编辑分类」取消不落库":
return (
""" await {f}.open();
await {f}.cancelEditCategoryWithoutCommit();""".format(f=f),
False,
)
if title == "「编辑分类」必填为空拦截":
return (
""" await {f}.open();
await {f}.assertEditCategoryRequiredIntercept();""".format(f=f),
False,
)
if title == "添加产品列表组合查询":
return (
""" await {f}.open();
await {f}.assertAddProductComboQuery();""".format(f=f),
False,
)
if title == "添加产品列表切换至第2页":
return (
""" await {f}.open();
await {f}.assertAddProductGotoPage2();""".format(f=f),
False,
)
if title == "勾选列表明细数量与确定按钮显示数量一致":
return (
""" await {f}.open();
await {f}.assertAddProductSelectionMatchesConfirm();""".format(f=f),
False,
)
m = OPEN_RE.search(title) m = OPEN_RE.search(title)
if m: if m:
label = m.group(1) label = m.group(1)
......
...@@ -74,12 +74,12 @@ PAGES = [ ...@@ -74,12 +74,12 @@ PAGES = [
"prefix": "TP_PRODM_MNT", "prefix": "TP_PRODM_MNT",
"path": "/spd/basic/goods/paltform/product/calatog", "path": "/spd/basic/goods/paltform/product/calatog",
"ready": "左右分栏:左侧耗材分类树(可搜/新增分类)、右侧筛选+高级筛选、添加/导出/导入扩展属性、列表与详情", "ready": "左右分栏:左侧耗材分类树(可搜/新增分类)、右侧筛选+高级筛选、添加/导出/导入扩展属性、列表与详情",
"cover": "导航、分类树、快捷/高级筛选、列表分页、导出导入、分类维护弹窗、详情页", "cover": "导航、分类树、快捷/高级筛选、列表分页、导出导入、新增分类弹窗、详情页",
"columns": "HIS编码、阳采ID、规格型号、器械注册人、单位、耗材类型、品牌、器械注册证号等", "columns": "HIS编码、阳采ID、规格型号、器械注册人、单位、耗材类型、品牌、器械注册证号等",
"row_ops": "详情", "row_ops": "详情",
"layout": "left_tree", "layout": "left_tree",
"tree_label": "耗材分类", "tree_label": "耗材分类",
"write_ops": ["分类维护", "添加"], "write_ops": ["新增分类", "添加"],
"sibling_diff": "SPD 产品目录维护 + 分类树;勿与「物料信息管理」望海物料页混淆", "sibling_diff": "SPD 产品目录维护 + 分类树;勿与「物料信息管理」望海物料页混淆",
}, },
{ {
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
* node scripts/json-to-locator-yaml.mjs --all-deptm * node scripts/json-to-locator-yaml.mjs --all-deptm
* node scripts/json-to-locator-yaml.mjs --all-iface * node scripts/json-to-locator-yaml.mjs --all-iface
* node scripts/json-to-locator-yaml.mjs --all-prodm * node scripts/json-to-locator-yaml.mjs --all-prodm
* node scripts/json-to-locator-yaml.mjs --all-open
*/ */
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
...@@ -127,6 +128,9 @@ const NEW_PAGES = { ...@@ -127,6 +128,9 @@ const NEW_PAGES = {
'prodm-qual-rule': { pageTitle: '基础设置 > 产品管理 > 产品资质齐全规则', module: 'spd-basic' }, 'prodm-qual-rule': { pageTitle: '基础设置 > 产品管理 > 产品资质齐全规则', module: 'spd-basic' },
'prodm-qual-overview': { pageTitle: '基础设置 > 产品管理 > 产品资质一览', module: 'spd-basic' }, 'prodm-qual-overview': { pageTitle: '基础设置 > 产品管理 > 产品资质一览', module: 'spd-basic' },
'prodm-material-chg-detail': { pageTitle: '基础设置 > 产品管理 > 物料变更单明细查询', module: 'spd-basic' }, 'prodm-material-chg-detail': { pageTitle: '基础设置 > 产品管理 > 物料变更单明细查询', module: 'spd-basic' },
'open-entry': { pageTitle: '期初管理 > 期初录入', module: 'spd-open' },
'open-detail': { pageTitle: '期初管理 > 期初明细查询', module: 'spd-open' },
'open-scan': { pageTitle: '期初管理 > 快速盘点记录', module: 'spd-open' },
}; };
const GROUPS = { const GROUPS = {
...@@ -216,6 +220,7 @@ const GROUPS = { ...@@ -216,6 +220,7 @@ const GROUPS = {
'prodm-qual-overview', 'prodm-qual-overview',
'prodm-material-chg-detail', 'prodm-material-chg-detail',
], ],
'--all-open': ['open-entry', 'open-detail', 'open-scan'],
'--all-new': Object.keys(NEW_PAGES), '--all-new': Object.keys(NEW_PAGES),
}; };
...@@ -241,7 +246,7 @@ async function main() { ...@@ -241,7 +246,7 @@ async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
if (!args.length) { if (!args.length) {
console.error( console.error(
'Usage: node scripts/json-to-locator-yaml.mjs <json-path|--all-extended|--all-basic|--all-user|--all-admit|--all-vendor|--all-surgery|--all-warehouse|--all-flow|--all-deptm|--all-iface|--all-prodm|--all-new>', 'Usage: node scripts/json-to-locator-yaml.mjs <json-path|--all-extended|--all-basic|--all-user|--all-admit|--all-vendor|--all-surgery|--all-warehouse|--all-flow|--all-deptm|--all-iface|--all-prodm|--all-open|--all-new>',
); );
process.exit(1); process.exit(1);
} }
......
#!/usr/bin/env node
/**
* Batch scan: 期初管理 (UI-SPEC OPEN-001~003).
* 先按菜单点击解析真实 URL,再扫描交互元素,最后由 JSON 提炼 locator YAML。
*
* 输出:
* data/scan/open-*-elements.json
* locators/spd/open-*.locator.yaml
* data/scan/open-menu-urls.json
* data/scan/open-scan-summary.json
*
* Usage:
* node scripts/scan-open-batch.mjs
* node scripts/scan-open-batch.mjs open-entry open-detail
*
* 只重算 YAML(不重新扫描):
* node scripts/json-to-locator-yaml.mjs --all-open
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import './playwright-browsers-path.mjs';
import { chromium } from 'playwright';
import { runScan, writeYamlFromScanJson } from './scan-interactive-elements.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const AUTH_STATE = path.join(ROOT, 'auth/user.json');
const BASE = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const OUT_DIR = path.join(ROOT, 'data/scan');
const LOC_DIR = path.join(ROOT, 'locators/spd');
const URL_MAP_OUT = path.join(OUT_DIR, 'open-menu-urls.json');
const SUMMARY_OUT = path.join(OUT_DIR, 'open-scan-summary.json');
if (existsSync(path.join(ROOT, '.env'))) {
process.loadEnvFile(path.join(ROOT, '.env'));
}
const COMMON_WAIT = [
'.ant-form-item input',
'.ant-form-item .ant-select',
'.ant-form',
'.ant-table',
'.ant-btn',
'.ant-pagination',
'.ant-menu-item',
];
const MENUS = [
{
pageKey: 'open-entry',
menu: '期初录入',
pageTitle: '期初管理 > 期初录入',
specId: 'OPEN-001',
skipQueryClick: false,
interactivePhases: [
expandCollapsedFilters,
openFirstFilterDropdown,
openAdvancedFilter,
(page) => openNamedButton(page, /新\s*增/, 'addDialogOpened'),
(page) => openRowAction(page, /录\s*入|查\s*看详情|详\s*情/, 'rowActionOpened'),
],
},
{
pageKey: 'open-detail',
menu: '期初明细查询',
pageTitle: '期初管理 > 期初明细查询',
specId: 'OPEN-002',
skipQueryClick: false,
interactivePhases: [
expandCollapsedFilters,
openFirstFilterDropdown,
openAdvancedFilter,
waitTableAndPager,
(page) => openNamedButton(page, /导\s*出/, 'toolbarOpened'),
],
},
{
pageKey: 'open-scan',
menu: '快速盘点记录',
pageTitle: '期初管理 > 快速盘点记录',
specId: 'OPEN-003',
skipQueryClick: false,
interactivePhases: [
expandCollapsedFilters,
waitTableAndPager,
(page) => openNamedButton(page, /导\s*出/, 'exportOpened'),
],
},
];
async function closeOverlays(page) {
await page.keyboard.press('Escape').catch(() => undefined);
await page.waitForTimeout(200);
const footerCancel = page
.locator('.ant-modal .ant-modal-footer button, .ant-drawer .ant-drawer-footer button')
.filter({ hasText: /取\s*消|关\s*闭/ })
.last();
if (await footerCancel.isVisible().catch(() => false)) {
await footerCancel.click({ timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(400);
}
for (let i = 0; i < 3; i += 1) {
const popCancel = page
.locator('.ant-popconfirm button, .ant-popover-open button, .ant-modal-confirm button')
.filter({ hasText: /取\s*消/ })
.last();
if (await popCancel.isVisible().catch(() => false)) {
await popCancel.click({ timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(300);
continue;
}
const modalClose = page.locator('.ant-modal-wrap .ant-modal-close, .ant-drawer-close').last();
if (await modalClose.isVisible().catch(() => false)) {
await modalClose.click({ timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(400);
continue;
}
break;
}
}
function firstDataRow(page) {
return page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
}
async function layerOpened(page) {
const modal = await page.locator('.ant-modal, .ant-drawer-open').first().isVisible().catch(() => false);
if (modal) return true;
return page.locator('.put-page').first().isVisible().catch(() => false);
}
async function expandCollapsedFilters(page) {
await closeOverlays(page);
const btn = page.getByRole('button', { name: /展\s*开/ }).first();
if (await btn.isVisible().catch(() => false)) {
await btn.click({ timeout: 8_000 }).catch(() => undefined);
await page.waitForTimeout(800);
return { filterExpanded: true };
}
return { filterExpanded: false, skipRescan: true };
}
async function waitTableAndPager(page) {
await closeOverlays(page);
await page
.locator('.ant-table-tbody tr.ant-table-row, .ant-pagination')
.first()
.waitFor({ state: 'visible', timeout: 20_000 })
.catch(() => undefined);
await page.locator('.ant-pagination').first().scrollIntoViewIfNeeded().catch(() => undefined);
await page.waitForTimeout(800);
const hasPager = await page.locator('.ant-pagination').first().isVisible().catch(() => false);
const hasRows = await page
.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)')
.first()
.isVisible()
.catch(() => false);
return { tableReady: hasRows, pagerReady: hasPager, skipRescan: !(hasRows || hasPager) };
}
async function openFirstFilterDropdown(page) {
await closeOverlays(page);
const select = page.locator('.ant-form .ant-select, .ant-form-item .ant-select').first();
if (!(await select.isVisible().catch(() => false))) return { dropdownOpened: false, skipRescan: true };
await select.click({ timeout: 10_000 });
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(500);
return { dropdownOpened: true };
}
async function openAdvancedFilter(page) {
await closeOverlays(page);
const btn = page.getByText(/高级筛选/).first();
if (!(await btn.isVisible().catch(() => false))) return { advancedFilterOpened: false, skipRescan: true };
await btn.click({ timeout: 10_000 }).catch(() => undefined);
await page
.locator('.ant-drawer-open, .ant-modal')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(600);
return { advancedFilterOpened: true };
}
async function openNamedButton(page, pattern, flag) {
await closeOverlays(page);
const btn = page.getByRole('button', { name: pattern }).first();
if (!(await btn.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
await btn.click({ timeout: 12_000, force: true });
await page
.locator('.ant-modal, .ant-drawer-open, .put-page')
.first()
.waitFor({ state: 'visible', timeout: 8_000 })
.catch(() => undefined);
await page.waitForTimeout(600);
const opened = await layerOpened(page);
return { [flag]: opened, skipRescan: !opened };
}
async function openRowAction(page, pattern, flag) {
await closeOverlays(page);
const row = firstDataRow(page);
if (!(await row.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
let action = row.getByRole('button', { name: pattern }).first();
if (!(await action.isVisible().catch(() => false))) {
action = row.locator('a,button,span').filter({ hasText: pattern }).first();
}
if (!(await action.isVisible().catch(() => false))) return { [flag]: false, skipRescan: true };
const before = page.url();
await action.click({ timeout: 8_000, force: true });
await page.waitForTimeout(1600);
const moved = page.url() !== before && !page.url().includes('/login');
if (moved) return { [flag]: true, navigated: true, returnUrl: before };
const opened = await layerOpened(page);
return { [flag]: Boolean(opened), skipRescan: !opened };
}
async function ensureAuthStorage() {
mkdirSync(path.dirname(AUTH_STATE), { recursive: true });
const user = process.env.TEST_USER;
const pass = process.env.TEST_PASS;
if (!user || !pass) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
}
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
storageState: existsSync(AUTH_STATE) ? AUTH_STATE : undefined,
});
const page = await context.newPage();
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(2000);
if (!page.url().includes('/login')) {
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Auth state OK:', AUTH_STATE);
return;
}
await page.goto(`${BASE}/spd/login`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.locator('#username, input[name="username"], input[placeholder*="用户"]').first().fill(user);
await page.locator('#password, input[type="password"]').first().fill(pass);
await Promise.all([
page.waitForURL((url) => url.pathname.includes('/spd/home'), { timeout: 120_000 }),
page.getByRole('button', { name: /登\s*录/ }).click(),
]).catch(async () => {
for (let i = 0; i < 60; i += 1) {
if (page.url().includes('/spd/home')) return;
await page.waitForTimeout(1000);
}
throw new Error(`登录后未进入首页,当前 URL: ${page.url()}`);
});
await page.waitForTimeout(1500);
await context.storageState({ path: AUTH_STATE });
await browser.close();
console.log('Logged in and saved auth:', AUTH_STATE);
}
function escapeRegExp(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function openOpeningModule(page) {
const top = page.getByRole('menuitem', { name: '期初管理' }).first();
await top.click({ timeout: 20_000 });
await page.waitForTimeout(800);
// 侧栏子菜单就绪
await page
.locator('.ant-menu-item')
.filter({ hasText: /期初录入|期初明细查询|快速盘点记录/ })
.first()
.waitFor({ state: 'visible', timeout: 15_000 })
.catch(() => undefined);
}
async function discoverMenuUrls(menus) {
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
const context = await browser.newContext({
storageState: AUTH_STATE,
viewport: { width: 1920, height: 1080 },
locale: 'zh-CN',
});
const page = await context.newPage();
const urls = {};
for (const item of menus) {
await page.goto(`${BASE}/spd/home/index`, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForTimeout(1500);
if (page.url().includes('/login')) {
throw new Error('登录态失效,无法打开期初管理菜单');
}
await openOpeningModule(page);
await page
.locator('.ant-menu-item')
.filter({ hasText: new RegExp(`^\\s*${escapeRegExp(item.menu)}\\s*$`) })
.first()
.click({ timeout: 20_000 });
await page.waitForTimeout(2000);
const url = page.url().split('?')[0];
if (url.includes('/login') || url.endsWith('/spd/home/index')) {
throw new Error(`未能进入 ${item.menu},当前 URL: ${page.url()}`);
}
urls[item.pageKey] = url;
console.log(`URL ${item.menu}: ${url}`);
}
await browser.close();
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(URL_MAP_OUT, JSON.stringify({ base: BASE, scannedAt: new Date().toISOString(), urls }, null, 2), 'utf8');
console.log('Wrote URL map:', URL_MAP_OUT);
return urls;
}
function pageCfg(targetUrl, item) {
return {
targetUrl,
useIframe: false,
iframeMatch: '',
iframeLabel: 'main',
module: 'spd-open',
pageKey: item.pageKey,
pageTitle: item.pageTitle,
jsonOut: path.join(OUT_DIR, `${item.pageKey}-elements.json`),
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
jsonSourceName: `${item.pageKey}-elements.json`,
waitSelectors: COMMON_WAIT,
waitSelector: '.ant-form-item input, .ant-table, .ant-btn, .ant-form',
waitUntil: 'domcontentloaded',
expandFirstRow: false,
openAdvancedFilter: false,
skipQueryClick: item.skipQueryClick === true,
interactivePhases: item.interactivePhases,
};
}
async function main() {
const filter = process.argv.slice(2);
const menus = filter.length ? MENUS.filter((p) => filter.includes(p.pageKey)) : MENUS;
if (!menus.length) {
console.error('No pages matched. Available:', MENUS.map((p) => p.pageKey).join(', '));
process.exit(1);
}
await ensureAuthStorage();
const urls = await discoverMenuUrls(menus);
const summary = [];
for (const item of menus) {
const targetUrl = urls[item.pageKey];
console.log(`\n=== Scanning ${item.pageKey} ${targetUrl} ===`);
try {
const result = await runScan(pageCfg(targetUrl, item));
const yaml = writeYamlFromScanJson(result.jsonOut, {
pageKey: item.pageKey,
pageTitle: item.pageTitle,
module: 'spd-open',
jsonSourceName: `${item.pageKey}-elements.json`,
iframeMatch: '',
yamlOut: path.join(LOC_DIR, `${item.pageKey}.locator.yaml`),
});
summary.push({
page: item.pageKey,
menu: item.menu,
specId: item.specId,
ok: true,
url: targetUrl,
elementCount: result.elementCount,
jsonOut: result.jsonOut,
yamlOut: yaml.yamlOut,
});
} catch (err) {
console.error(`FAILED ${item.pageKey}:`, err);
summary.push({
page: item.pageKey,
menu: item.menu,
specId: item.specId,
ok: false,
error: String(err),
});
}
}
console.log('\n========== SUMMARY ==========');
for (const row of summary) {
if (row.ok) {
console.log(`✓ ${row.page}: ${row.elementCount} elements ${row.url}`);
console.log(` JSON: ${row.jsonOut}`);
console.log(` YAML: ${row.yamlOut}`);
} else {
console.log(`✗ ${row.page}: ${row.error}`);
}
}
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(SUMMARY_OUT, JSON.stringify(summary, null, 2), 'utf8');
if (summary.some((r) => !r.ok)) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
#!/usr/bin/env python3 #!/usr/bin/env python3
"""将 docs/ 下页面截图同步至 knowledge-base/08-UI专项规范库(统计报表、准入、厂商、产品管理、用户、库房、流程、科室管理、接口管理等)。""" """将 docs/ 下页面截图同步至 knowledge-base/08-UI专项规范库(统计报表、准入、厂商、产品管理、用户、库房、流程、科室管理、接口管理、期初管理等)。"""
from __future__ import annotations from __future__ import annotations
...@@ -1192,6 +1192,180 @@ IFACE_PAGES: list[dict] = [ ...@@ -1192,6 +1192,180 @@ IFACE_PAGES: list[dict] = [
}, },
] ]
# 顶层菜单 → 期初管理(docs/期初管理-*;菜单父 path=/start)
OPEN_PAGES: list[dict] = [
{
"id": "OPEN-001",
"name": "期初录入",
"folder": "期初管理-期初录入",
"path": "/spd/start/init/import",
"scan_key": "open-entry",
"siblings": "**期初录入** / **期初明细查询** / **快速盘点记录**",
"layout": "整页单表:顶部筛选 + **新增** + 期初单列表;支持高级筛选抽屉与「新增」弹窗",
"filters": [
("库房", "下拉", "占位「请选择库房」"),
("状态", "下拉", "占位「请选择状态」(待生成 / 已生成 等)"),
("期初单号", "文本", "占位「期初单号」,单号前缀常见 `QC`"),
],
"actions": "**查询**、**重置**、**高级筛选**",
"toolbar": "**新增**;列设置、刷新",
"columns": [
("序号", "行号"),
("期初单号", "如 `QC20261029156856`"),
("库房", "如「千泉山院区中心库房」「边仓-科室2库房」"),
("库区", "如「千泉院区中心库库区1」「默认库区」"),
("耗材类型", "高值 / 低值 / 检验等"),
("品规数", "整数,可为 0"),
("库存总数", "库存数量合计"),
("制单人", "如「蒋华」"),
("制单时间", "YYYY-MM-DD HH:mm"),
("生成时间", "已生成有时间;未生成多为 `--`"),
("状态", "待生成 / 已生成"),
("操作", "待生成:**录入**、**删除**;已生成:**查看详情**"),
],
"row_ops": "待生成行 **录入**、**删除**;已生成行 **查看详情**",
"paging": "「共 N 条」(截图约 198)、页码、**50 条/页**、跳至",
"po_hint": "pages/spd/open-entry.page.ts",
"notes": [
"顶栏先进入 **期初管理**,再点侧栏 **期初录入**:`page.getByRole('menuitem', { name: '期初录入', exact: true }).click()`;或 `openPath('/spd/start/init/import')`。",
"**新增**、**录入**、**删除** 为写操作;冒烟优先查询/重置/打开高级筛选后关闭、打开新增后取消。",
"高级筛选字段含库房、状态、期初单号、耗材类型;抽屉 **筛选** 后列表与「共 N 条」须一致。",
],
"points": [
"库房 + 状态 + 期初单号组合查询后,列表行与分页「共 N 条」一致;重置恢复默认。",
"待生成行展示 **录入** / **删除**;已生成行展示 **查看详情**,状态与操作列匹配。",
"打开高级筛选后可见耗材类型等扩展条件;关闭后主区筛选条件不丢失。",
"点击 **新增** 打开弹窗:库房/库区/耗材类型为必填;含「导入后使用原系统唯一码做新系统UDI」勾选;取消不落库。",
],
"extra_md": """
### 高级筛选抽屉
![期初录入-高级筛选](UI-SPEC-SPD-OPEN-001/page-main-2.png)
| 字段 | 控件 | 占位/默认值 |
|------|------|-------------|
| 库房 | 下拉 | 请选择库房 |
| 状态 | 下拉 | 请选择状态 |
| 期初单号 | 文本 | 期初单号 |
| 耗材类型 | 下拉 | 请选择耗材类型 |
- 操作:**重置**、**筛选**
### 新增弹窗
![期初录入-新增](UI-SPEC-SPD-OPEN-001/page-main-3.png)
| 字段 | 控件 | 说明 |
|------|------|------|
| 库房 | 下拉(必填) | 如「千泉山院区中心库房」 |
| 库区 | 文本/选择(必填) | 随库房联动 |
| 耗材类型 | 下拉(必填) | 请选择 |
| 导入后使用原系统唯一码做新系统UDI | 勾选 | 导入期初场景 |
- 底部:**取消**、**确定**
""",
},
{
"id": "OPEN-002",
"name": "期初明细查询",
"folder": "期初管理-期初明细查询",
"path": "/spd/start/init/detail",
"scan_key": "open-detail",
"siblings": "**期初录入** / **期初明细查询** / **快速盘点记录**",
"layout": "整页单表:顶部快捷筛选 + 明细列表;**高级筛选** 抽屉展开更多条件",
"filters": [
("库房", "下拉", "占位「请选择库房」"),
("期初单号", "文本", "占位「期初单号」"),
("耗材类型", "下拉", "占位「请选择耗材类型」"),
],
"actions": "**查询**、**重置**、**高级筛选**",
"toolbar": "列设置、刷新(截图未见独立新增)",
"columns": [
("期初单号", "如 `QC20261026156852`;部分行旁有条码图标"),
("库房", "如「千泉山院区中心库房」"),
("库区", "如「千泉院区中心库库区1」"),
("制单人", "如「蒋华」"),
("生成时间", "日期,如 2026-10-26"),
("规格型号", "如 `ZSJ-001`、`(新)wh25121…`"),
("赋码管理", "赋码 / 非赋码"),
("耗材类型", "高值耗材 / 低值耗材 / 检验试剂等"),
("单位", "块(新) / 支 / 个 等"),
("库存数量", "明细库存数"),
("结算方式", "中心库… / 科室登… 等"),
("批号", "批号文本"),
],
"row_ops": "截图主表未见统一行操作列(以现网为准)",
"paging": "「共 N 条」(截图约 228)、页码、**50 条/页**",
"po_hint": "pages/spd/open-detail.page.ts",
"notes": [
"菜单:`page.getByRole('menuitem', { name: '期初明细查询', exact: true }).click()`;或 `openPath('/spd/start/init/detail')`。",
"本页为明细只读查询,勿与「期初录入」单据头列表混淆;单号可追溯至录入单。",
"宽表需横向滚动核对赋码管理、结算方式、批号等右侧列。",
],
"points": [
"库房 + 期初单号 + 耗材类型查询后,列表明细与「共 N 条」一致;重置恢复默认。",
"高级筛选补充结算方式、物料名称、生成时间区间后,结果同时满足已填条件。",
"列表期初单号与「期初录入」中已生成单据可对应;赋码管理、耗材类型、库存数量不串行。",
"无匹配关键字时列表空态且分页为 0,无报错遮罩。",
],
"extra_md": """
### 高级筛选抽屉
![期初明细查询-高级筛选](UI-SPEC-SPD-OPEN-002/page-main-2.png)
| 字段 | 控件 | 占位/默认值 |
|------|------|-------------|
| 库房 | 下拉 | 请选择库房 |
| 期初单号 | 文本 | 期初单号 |
| 耗材类型 | 下拉 | 请选择耗材类型 |
| 结算方式 | 下拉 | 请选择结算方式 |
| 物料名称 | 文本 | 空 |
| 生成时间 | 日期区间 | 开始日期 ~ 结束日期 |
- 操作:**查询**、**重置**
""",
},
{
"id": "OPEN-003",
"name": "快速盘点记录",
"folder": "期初管理-快速盘点记录",
"path": "/spd/start/init/scanRecord",
"scan_key": "open-scan",
"siblings": "**期初录入** / **期初明细查询** / **快速盘点记录**",
"layout": "整页单表:顶部筛选(含扫码日期区间)+ 扫码记录列表 + **导出**",
"filters": [
("扫码日期", "日期区间", "截图默认约近半年,如 2026-05-06 ~ 2026-11-06"),
("科室名称", "文本", "占位「科室名称」"),
("条码号", "文本", "占位「条码号」"),
("扫码人", "文本", "占位「扫码人」"),
],
"actions": "**查询**、**重置**、**收起**(筛选区折叠)",
"toolbar": "**导出**;列设置、刷新",
"columns": [
("序号", "行号"),
("扫码时间", "YYYY-MM-DD HH:mm:ss"),
("科室名称", "如「中心库」「手术室」"),
("条码号", "长数字/条码串"),
("扫码人", "如「蒋中心库」"),
],
"row_ops": "截图未见行内操作(只读记录)",
"paging": "「共 N 条」(截图约 52)、页码、**50 条/页**、跳至",
"po_hint": "pages/spd/open-scan.page.ts",
"notes": [
"菜单:`page.getByRole('menuitem', { name: '快速盘点记录', exact: true }).click()`;或 `openPath('/spd/start/init/scanRecord')`。",
"与「期初录入 / 明细查询」单据体系不同,本页为扫码盘点流水;断言勿混用期初单号列。",
"**导出** 只断言可触发且无报错,默认不校验文件内容。",
],
"points": [
"扫码日期 + 科室名称 + 条码号 + 扫码人组合查询后,列表与「共 N 条」一致;重置恢复默认区间与空条件。",
"收起后额外筛选隐藏且已填值保留(产品未定则标待评审)。",
"导出在当前查询条件下可触发,无 error toast/遮罩。",
"无匹配条件时列表空态,分页为 0。",
],
"extra_md": "",
},
]
PAGES: list[dict] = ( PAGES: list[dict] = (
STAT_PAGES STAT_PAGES
+ ADMIT_PAGES + ADMIT_PAGES
...@@ -1202,6 +1376,7 @@ PAGES: list[dict] = ( ...@@ -1202,6 +1376,7 @@ PAGES: list[dict] = (
+ FLOW_PAGES + FLOW_PAGES
+ DEPTM_PAGES + DEPTM_PAGES
+ IFACE_PAGES + IFACE_PAGES
+ OPEN_PAGES
) )
...@@ -1946,7 +2121,127 @@ author: "" ...@@ -1946,7 +2121,127 @@ author: ""
""" """
def render_open_spec(page: dict, spec_id: str) -> str:
title = f"期初管理-{page['name']}页面说明"
menu = f"**期初管理** → **{page['name']}**"
docs_path = f"docs/{page['folder']}/"
kb_dir = f"UI-SPEC-SPD-{spec_id}"
scan_key = page["scan_key"]
siblings = page.get("siblings", f"**{page['name']}**")
shot = page.get("shot_note") or f"`{kb_dir}/page-main.png`"
notes = "\n".join(f"{i}. {n}" for i, n in enumerate(page["notes"], start=2))
points = "\n".join(f"{i}. {p}" for i, p in enumerate(page["points"], start=1))
filter_rows = "\n".join(f"| {f} | {c} | {d} |" for f, c, d in page["filters"])
col_rows = "\n".join(f"| {name} | {desc} |" for name, desc in page["columns"])
extra = (page.get("extra_md") or "").strip()
extra = f"\n{extra}\n" if extra else "\n"
page_url = (
f"`http://spdtest.cmic.com.cn:8080{page['path']}`"
if page.get("path")
else f"待元素扫描补充(菜单名:`{page['name']}`)"
)
# 多图:主图之外按 page-main-2… 引用(与 sync 复制规则一致)
extra_imgs = ""
src_dir = DOCS / page["folder"]
if src_dir.exists():
n_extra = max(0, len(png_files(src_dir)) - 1)
# 若 extra_md 已手工嵌入图,不再自动追加,避免重复
if n_extra and "page-main-" not in (page.get("extra_md") or ""):
for i in range(2, n_extra + 2):
extra_imgs += f"\n\n![{page['name']}-补充截图{i - 1}]({kb_dir}/page-main-{i}.png)\n"
peer = (
"- 同模块:期初录入([`UI-SPEC-SPD-OPEN-001`](./UI-SPEC-SPD-OPEN-001.md))、"
"期初明细查询([`UI-SPEC-SPD-OPEN-002`](./UI-SPEC-SPD-OPEN-002.md))、"
"快速盘点记录([`UI-SPEC-SPD-OPEN-003`](./UI-SPEC-SPD-OPEN-003.md))。\n"
"- 顶层菜单:与基础设置(`UI-SPEC-SPD-BASIC-*` / `VENDOR` / `PRODM` 等)、"
"中心库管理、统计报表并列;编号前缀 **OPEN** 表示期初管理(菜单父 path `/start`)。"
)
return f"""---
title: {title}
module: 期初管理
status: 生效
related_br: ""
related_api: ""
related_jrn: ""
related_tc: ""
related_def: ""
create_time: "{TODAY}"
update_time: "{TODAY}"
author: ""
---
# 期初管理 - {page['name']}
> 来源:`{docs_path}` 页面截图。用于 UI 自动化定位、功能测试与期初建账/盘点验收参考。
> 同模块索引见 [`INDEX.md`](./INDEX.md)。
## 文档与截图
| 项 | 路径 |
|----|------|
| 截图原件 | `{docs_path}` |
| 知识库副本 | {shot} |
## 页面路径
{menu}(同组页面:{siblings})
| 项 | 值 |
|----|-----|
| 主框架 URL | {page_url} |
| iframe 选择器 | 无(整页渲染,非 iframe 报表) |
| 元素扫描 JSON | `data/scan/{scan_key}-elements.json` |
| 定位器 YAML | `locators/spd/{scan_key}.locator.yaml` |
| 测试环境示例 | `http://spdtest.cmic.com.cn:8080/spd/form/` |
## 主界面
![{page['name']}主界面]({kb_dir}/page-main.png)
{extra_imgs}
{page['layout']}
### 筛选/搜索区
| 字段 | 控件 | 占位/默认值 |
|------|------|-------------|
{filter_rows}
- 操作:{page['actions']}
### 列表与工具栏
工具栏:{page.get('toolbar', '列设置')}。
| 列 | 说明 |
|----|------|
{col_rows}
行操作:{page['row_ops']}。
### 分页
{page['paging']}
{extra}
## 自动化建议
1. 元素扫描 JSON 与定位器 YAML 待生成;下一步补 Page Object(建议 `{page['po_hint']}`)。
{notes}
## 测试关注点
{points}
## 关联
{peer}
"""
def render_spec(page: dict, spec_id: str) -> str: def render_spec(page: dict, spec_id: str) -> str:
if spec_id.startswith("OPEN-"):
return render_open_spec(page, spec_id)
if spec_id.startswith("IFACE-"): if spec_id.startswith("IFACE-"):
return render_iface_spec(page, spec_id) return render_iface_spec(page, spec_id)
if spec_id.startswith("PRODM-"): if spec_id.startswith("PRODM-"):
......
用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果 用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果
PRODM-CLM-001,基础设置 · 产品管理,中心库上下限建议 · 快捷筛选查询,快捷区重置恢复默认,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1) PRODM-CLM-001,基础设置 · 产品管理,中心库上下限建议 · 快捷筛选查询,快捷区重置恢复默认,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1)
2. 任意填写快捷区可见字段后点击「重 置」 2. 任意填写快捷区可见字段后点击「重 置」
3. 断言条件恢复默认;列表是否刷新按产品规则(未定则记待评审)",重置后条件恢复默认;页面无报错。, 3. 断言条件恢复默认;列表是否刷新按产品规则(未定则记待评审)",重置后条件恢复默认;页面无报错。,
PRODM-CLM-002,基础设置 · 产品管理,中心库上下限建议 · 快捷筛选查询,全量可见条件组合查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);默认查询至少 1 条,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CLM-002,基础设置 · 产品管理,中心库上下限建议 · 快捷筛选查询,全量可见条件组合查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);默认查询至少 1 条,否则 skip,"1. 确认已加载 storageState(test_1)
2. 默认查询 → 从首行提取全量样本(只填当前结果里有值的条件) 2. 默认查询 → 从首行提取全量样本(只填当前结果里有值的条件)
3. 快捷区可见非空条件全部填入后查询 3. 快捷区可见非空条件全部填入后查询
4. 抽样首行同时满足全部已填条件;无未捕获异常",查询正常无报错;查询结果与查询条件一致。, 4. 抽样首行同时满足全部已填条件;无未捕获异常",查询正常无报错;查询结果与查询条件一致。,
PRODM-CLM-003,基础设置 · 产品管理,中心库上下限建议 · 查询条件展开收起,展开后更多条件可见,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1) PRODM-CLM-003,基础设置 · 产品管理,中心库上下限建议 · 查询条件展开收起,展开后更多条件可见,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1)
2. 点击「展开」 2. 点击「展开」
3. 断言额外筛选字段进入可视区且可填写 3. 断言额外筛选字段进入可视区且可填写
4. 按钮文案变为「收起」",展开后更多条件可见并可交互。, 4. 按钮文案变为「收起」",展开后更多条件可见并可交互。,
PRODM-CLM-004,基础设置 · 产品管理,中心库上下限建议 · 列表,列表核心列完整,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1) PRODM-CLM-004,基础设置 · 产品管理,中心库上下限建议 · 列表,列表核心列完整,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1)
2. 断言表头含核心列(抽样):建议上下限、现上下限、状态等(以现网为准) 2. 断言表头含核心列(抽样):建议上下限、现上下限、状态等(以现网为准)
3. 若有数据,抽样首行关键字段非空",核心列完整;列表结构正确。, 3. 若有数据,抽样首行关键字段非空",核心列完整;列表结构正确。,
PRODM-CLM-005,基础设置 · 产品管理,中心库上下限建议 · 行内操作,行内「详情」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);列表至少 1 条,"1. 确认已加载 storageState(test_1) PRODM-CLM-005,基础设置 · 产品管理,中心库上下限建议 · 行内操作,行内「详情」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);列表至少 1 条,"1. 确认已加载 storageState(test_1)
2. 首行有数据时点击「详情」 2. 首行有数据时点击「详情」
3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回 3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回
4. 关闭弹层或点返回,列表仍可见",行内「详情」可达;关闭后回到列表。, 4. 关闭弹层或点返回,列表仍可见",行内「详情」可达;关闭后回到列表。,
PRODM-CLM-006,基础设置 · 产品管理,中心库上下限建议 · 行内操作,行内「编辑」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);列表至少 1 条,"1. 确认已加载 storageState(test_1) PRODM-CLM-006,基础设置 · 产品管理,中心库上下限建议 · 行内操作,行内「编辑」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);列表至少 1 条,"1. 确认已加载 storageState(test_1)
2. 首行有数据时点击「编辑」 2. 首行有数据时点击「编辑」
3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回 3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回
4. 关闭弹层或点返回,列表仍可见",行内「编辑」可达;关闭后回到列表。, 4. 关闭弹层或点返回,列表仍可见",行内「编辑」可达;关闭后回到列表。,
PRODM-CLM-007,基础设置 · 产品管理,中心库上下限建议 · 行内操作,行内「调整」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);列表至少 1 条,"1. 确认已加载 storageState(test_1) PRODM-CLM-007,基础设置 · 产品管理,中心库上下限建议 · 行内操作,行内「调整」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);列表至少 1 条,"1. 确认已加载 storageState(test_1)
2. 首行有数据时点击「调整」 2. 首行有数据时点击「调整」
3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回 3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回
4. 关闭弹层或点返回,列表仍可见",行内「调整」可达;关闭后回到列表。, 4. 关闭弹层或点返回,列表仍可见",行内「调整」可达;关闭后回到列表。,
PRODM-CLM-008,基础设置 · 产品管理,中心库上下限建议 · 列表,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1) PRODM-CLM-008,基础设置 · 产品管理,中心库上下限建议 · 列表,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1)
2. 名称/编号类关键字填入 `__NOT_EXIST_PRODM__99999` 后查询 2. 名称/编号类关键字填入 `__NOT_EXIST_PRODM__99999` 后查询
3. 断言表头仍在,0 行或空态,分页总数为 0",无匹配不报错;不展示过期成功态数据。, 3. 断言表头仍在,0 行或空态,分页总数为 0",无匹配不报错;不展示过期成功态数据。,
PRODM-CLM-009,基础设置 · 产品管理,中心库上下限建议 · 分页,有数据时总条数,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1) PRODM-CLM-009,基础设置 · 产品管理,中心库上下限建议 · 分页,有数据时总条数,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1)
2. 默认查询后读取「共 N 条」 2. 默认查询后读取「共 N 条」
3. 断言 N 与本次查询匹配条数规则一致(有数据或 0)",「共 N 条」与查询结果一致。, 3. 断言 N 与本次查询匹配条数规则一致(有数据或 0)",「共 N 条」与查询结果一致。,
PRODM-CLM-010,基础设置 · 产品管理,中心库上下限建议 · 分页,切换至第 2 页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);总条数大于一页,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CLM-010,基础设置 · 产品管理,中心库上下限建议 · 分页,切换至第 2 页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);总条数大于一页,否则 skip,"1. 确认已加载 storageState(test_1)
2. 记录第 1 页首行关键字段 2. 记录第 1 页首行关键字段
3. 点击页码「2」或「下一页」 3. 点击页码「2」或「下一页」
4. 断言页码高亮正确,首行数据与第 1 页不同",翻页成功;数据更换。, 4. 断言页码高亮正确,首行数据与第 1 页不同",翻页成功;数据更换。,
PRODM-CLM-011,基础设置 · 产品管理,中心库上下限建议 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);N>10 且切换前可见行数 > min(N,20),否则 skip(见 3.7)","1. 确认已加载 storageState(test_1) PRODM-CLM-011,基础设置 · 产品管理,中心库上下限建议 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);N>10 且切换前可见行数 > min(N,20),否则 skip(见 3.7)","1. 确认已加载 storageState(test_1)
2. 记录「共 N 条」与当前页可见行数 2. 记录「共 N 条」与当前页可见行数
3. 打开每页条数下拉,选择「20 条/页」 3. 打开每页条数下拉,选择「20 条/页」
4. 断言分页器显示 20 条/页;N 不变;当前页行数=min(N,20) 且与切换前不同",每页条数切换生效;总数不变;列表同步。, 4. 断言分页器显示 20 条/页;N 不变;当前页行数=min(N,20) 且与切换前不同",每页条数切换生效;总数不变;列表同步。,
PRODM-CLM-012,基础设置 · 产品管理,中心库上下限建议 · 导出,当前查询条件下导出,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1) PRODM-CLM-012,基础设置 · 产品管理,中心库上下限建议 · 导出,首行样本查询后导出,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1)
2. 先执行一次查询 2. 记录首行样本字段(优先物料编号/编码)
3. 点击「导 出」或「导出」或「导出Excel」 3. 将样本填入筛选区后点击「查 询」
4. 断言无未捕获异常(不校验文件内容)",导出流程可启动。, 4. 查询后列表无数据则 skip
5. 点击「导 出」或「导出」或「导出Excel」
6. 断言无未捕获异常(不校验文件内容)",按首行样本查询后导出流程可启动;无数据则跳过。,
PRODM-CLM-013,基础设置 · 产品管理,中心库上下限建议 · 打印,按当前条件打印,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1) PRODM-CLM-013,基础设置 · 产品管理,中心库上下限建议 · 打印,按当前条件打印,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1)
2. 点击「打 印」或「打印产品码」等页面级打印入口 2. 点击「打 印」或「打印产品码」等页面级打印入口
3. 断言可拉起对话框/预览/toast,无 error",打印流程可启动。, 3. 断言可拉起对话框/预览/toast,无 error",打印流程可启动。,
PRODM-CLM-014,基础设置 · 产品管理,中心库上下限建议 · 全部调整,打开「全部调整」对应弹窗/抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1) PRODM-CLM-014,基础设置 · 产品管理,中心库上下限建议 · 全部调整,打开「全部调整」对应弹窗/抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1)
2. 点击「全部调整」或等价入口 2. 点击「全部调整」或等价入口
3. 断言弹窗/抽屉/详情可见,关键字段可交互 3. 断言弹窗/抽屉/详情可见,关键字段可交互
4. 可见取消/关闭/返回",「全部调整」层打开;关键控件可见。, 4. 可见取消/关闭/返回",「全部调整」层打开;关键控件可见。,
PRODM-CLM-015,基础设置 · 产品管理,中心库上下限建议 · 全部调整,「全部调整」取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1) PRODM-CLM-015,基础设置 · 产品管理,中心库上下限建议 · 全部调整,「全部调整」取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1)
2. 打开「全部调整」,任意填写非空后点击「取 消」或关闭 2. 打开「全部调整」,任意填写非空后点击「取 消」或关闭
3. 断言弹层关闭,列表行数与打开前一致(抽样)",取消后列表无新增/无修改。, 3. 断言弹层关闭,列表行数与打开前一致(抽样)",取消后列表无新增/无修改。,
PRODM-CLM-016,基础设置 · 产品管理,中心库上下限建议 · 全部调整,「全部调整」必填为空拦截,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);该层存在必填提交,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CLM-016,基础设置 · 产品管理,中心库上下限建议 · 全部调整,「全部调整」必填为空拦截,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);该层存在必填提交,否则 skip,"1. 确认已加载 storageState(test_1)
2. 打开「全部调整」,必填项留空后点击「确 定」 2. 打开「全部调整」,必填项留空后点击「确 定」
3. 断言出现必填提示,弹层仍打开,列表不刷新为成功态",确定被拦截;不落库。, 3. 断言出现必填提示,弹层仍打开,列表不刷新为成功态",确定被拦截;不落库。,
PRODM-CLM-017,基础设置 · 产品管理,中心库上下限建议 · 批量调整,打开「批量调整」对应弹窗/抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1) PRODM-CLM-017,基础设置 · 产品管理,中心库上下限建议 · 批量调整,打开「批量调整」对应弹窗/抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1)
2. 点击「批量调整」或等价入口 2. 点击「批量调整」或等价入口
3. 断言弹窗/抽屉/详情可见,关键字段可交互 3. 断言弹窗/抽屉/详情可见,关键字段可交互
4. 可见取消/关闭/返回",「批量调整」层打开;关键控件可见。, 4. 可见取消/关闭/返回",「批量调整」层打开;关键控件可见。,
PRODM-CLM-018,基础设置 · 产品管理,中心库上下限建议 · 批量调整,「批量调整」取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1) PRODM-CLM-018,基础设置 · 产品管理,中心库上下限建议 · 批量调整,「批量调整」取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit),"1. 确认已加载 storageState(test_1)
2. 打开「批量调整」,任意填写非空后点击「取 消」或关闭 2. 打开「批量调整」,任意填写非空后点击「取 消」或关闭
3. 断言弹层关闭,列表行数与打开前一致(抽样)",取消后列表无新增/无修改。, 3. 断言弹层关闭,列表行数与打开前一致(抽样)",取消后列表无新增/无修改。,
PRODM-CLM-019,基础设置 · 产品管理,中心库上下限建议 · 批量调整,「批量调整」必填为空拦截,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);该层存在必填提交,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CLM-019,基础设置 · 产品管理,中心库上下限建议 · 批量调整,「批量调整」必填为空拦截,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议」权限;已进入中心库上下限建议页(/spd/basic/goods/paltform/product/centerLimit);该层存在必填提交,否则 skip,"1. 确认已加载 storageState(test_1)
2. 打开「批量调整」,必填项留空后点击「确 定」 2. 打开「批量调整」,必填项留空后点击「确 定」
3. 断言出现必填提示,弹层仍打开,列表不刷新为成功态",确定被拦截;不落库。, 3. 断言出现必填提示,弹层仍打开,列表不刷新为成功态",确定被拦截;不落库。,
用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果 用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果
PRODM-CLA-001,基础设置 · 产品管理,中心库上下限建议(AI) · 快捷筛选查询,快捷区重置恢复默认,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-001,基础设置 · 产品管理,中心库上下限建议(AI) · 快捷筛选查询,快捷区重置恢复默认,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 任意填写快捷区可见字段后点击「重 置」 2. 任意填写快捷区可见字段后点击「重 置」
3. 断言条件恢复默认;列表是否刷新按产品规则(未定则记待评审)",重置后条件恢复默认;页面无报错。, 3. 断言条件恢复默认;列表是否刷新按产品规则(未定则记待评审)",重置后条件恢复默认;页面无报错。,
PRODM-CLA-002,基础设置 · 产品管理,中心库上下限建议(AI) · 快捷筛选查询,全量可见条件组合查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);默认查询至少 1 条,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CLA-002,基础设置 · 产品管理,中心库上下限建议(AI) · 快捷筛选查询,全量可见条件组合查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);默认查询至少 1 条,否则 skip,"1. 确认已加载 storageState(test_1)
2. 默认查询 → 从首行提取全量样本(只填当前结果里有值的条件) 2. 默认查询 → 从首行提取全量样本(只填当前结果里有值的条件)
3. 快捷区可见非空条件全部填入后查询 3. 快捷区可见非空条件全部填入后查询
4. 抽样首行同时满足全部已填条件;无未捕获异常",查询正常无报错;查询结果与查询条件一致。, 4. 抽样首行同时满足全部已填条件;无未捕获异常",查询正常无报错;查询结果与查询条件一致。,
PRODM-CLA-003,基础设置 · 产品管理,中心库上下限建议(AI) · 查询条件展开收起,展开后更多条件可见,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-003,基础设置 · 产品管理,中心库上下限建议(AI) · 查询条件展开收起,展开后更多条件可见,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 点击「展开」 2. 点击「展开」
3. 断言额外筛选字段进入可视区且可填写 3. 断言额外筛选字段进入可视区且可填写
4. 按钮文案变为「收起」",展开后更多条件可见并可交互。, 4. 按钮文案变为「收起」",展开后更多条件可见并可交互。,
PRODM-CLA-004,基础设置 · 产品管理,中心库上下限建议(AI) · 列表,列表核心列完整,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-004,基础设置 · 产品管理,中心库上下限建议(AI) · 列表,列表核心列完整,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 断言表头含核心列(抽样):建议上下限、现上下限、采纳状态等(以现网为准) 2. 断言表头含核心列(抽样):建议上下限、现上下限、采纳状态等(以现网为准)
3. 若有数据,抽样首行关键字段非空",核心列完整;列表结构正确。, 3. 若有数据,抽样首行关键字段非空",核心列完整;列表结构正确。,
PRODM-CLA-005,基础设置 · 产品管理,中心库上下限建议(AI) · 行内操作,行内「详情」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);列表至少 1 条,"1. 确认已加载 storageState(test_1) PRODM-CLA-005,基础设置 · 产品管理,中心库上下限建议(AI) · 行内操作,行内「详情」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);列表至少 1 条,"1. 确认已加载 storageState(test_1)
2. 首行有数据时点击「详情」 2. 首行有数据时点击「详情」
3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回 3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回
4. 关闭弹层或点返回,列表仍可见",行内「详情」可达;关闭后回到列表。, 4. 关闭弹层或点返回,列表仍可见",行内「详情」可达;关闭后回到列表。,
PRODM-CLA-006,基础设置 · 产品管理,中心库上下限建议(AI) · 行内操作,行内「编辑」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);列表至少 1 条,"1. 确认已加载 storageState(test_1) PRODM-CLA-006,基础设置 · 产品管理,中心库上下限建议(AI) · 行内操作,行内「编辑」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);列表至少 1 条,"1. 确认已加载 storageState(test_1)
2. 首行有数据时点击「编辑」 2. 首行有数据时点击「编辑」
3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回 3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回
4. 关闭弹层或点返回,列表仍可见",行内「编辑」可达;关闭后回到列表。, 4. 关闭弹层或点返回,列表仍可见",行内「编辑」可达;关闭后回到列表。,
PRODM-CLA-007,基础设置 · 产品管理,中心库上下限建议(AI) · 行内操作,行内「调整」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);列表至少 1 条,"1. 确认已加载 storageState(test_1) PRODM-CLA-007,基础设置 · 产品管理,中心库上下限建议(AI) · 行内操作,行内「调整」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);列表至少 1 条,"1. 确认已加载 storageState(test_1)
2. 首行有数据时点击「调整」 2. 首行有数据时点击「调整」
3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回 3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回
4. 关闭弹层或点返回,列表仍可见",行内「调整」可达;关闭后回到列表。, 4. 关闭弹层或点返回,列表仍可见",行内「调整」可达;关闭后回到列表。,
PRODM-CLA-008,基础设置 · 产品管理,中心库上下限建议(AI) · 列表,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-008,基础设置 · 产品管理,中心库上下限建议(AI) · 列表,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 名称/编号类关键字填入 `__NOT_EXIST_PRODM__99999` 后查询 2. 名称/编号类关键字填入 `__NOT_EXIST_PRODM__99999` 后查询
3. 断言表头仍在,0 行或空态,分页总数为 0",无匹配不报错;不展示过期成功态数据。, 3. 断言表头仍在,0 行或空态,分页总数为 0",无匹配不报错;不展示过期成功态数据。,
PRODM-CLA-009,基础设置 · 产品管理,中心库上下限建议(AI) · 分页,有数据时总条数,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-009,基础设置 · 产品管理,中心库上下限建议(AI) · 分页,有数据时总条数,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 默认查询后读取「共 N 条」 2. 默认查询后读取「共 N 条」
3. 断言 N 与本次查询匹配条数规则一致(有数据或 0)",「共 N 条」与查询结果一致。, 3. 断言 N 与本次查询匹配条数规则一致(有数据或 0)",「共 N 条」与查询结果一致。,
PRODM-CLA-010,基础设置 · 产品管理,中心库上下限建议(AI) · 分页,切换至第 2 页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);总条数大于一页,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CLA-010,基础设置 · 产品管理,中心库上下限建议(AI) · 分页,切换至第 2 页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);总条数大于一页,否则 skip,"1. 确认已加载 storageState(test_1)
2. 记录第 1 页首行关键字段 2. 记录第 1 页首行关键字段
3. 点击页码「2」或「下一页」 3. 点击页码「2」或「下一页」
4. 断言页码高亮正确,首行数据与第 1 页不同",翻页成功;数据更换。, 4. 断言页码高亮正确,首行数据与第 1 页不同",翻页成功;数据更换。,
PRODM-CLA-011,基础设置 · 产品管理,中心库上下限建议(AI) · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);N>10 且切换前可见行数 > min(N,20),否则 skip(见 3.7)","1. 确认已加载 storageState(test_1) PRODM-CLA-011,基础设置 · 产品管理,中心库上下限建议(AI) · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);N>10 且切换前可见行数 > min(N,20),否则 skip(见 3.7)","1. 确认已加载 storageState(test_1)
2. 记录「共 N 条」与当前页可见行数 2. 记录「共 N 条」与当前页可见行数
3. 打开每页条数下拉,选择「20 条/页」 3. 打开每页条数下拉,选择「20 条/页」
4. 断言分页器显示 20 条/页;N 不变;当前页行数=min(N,20) 且与切换前不同",每页条数切换生效;总数不变;列表同步。, 4. 断言分页器显示 20 条/页;N 不变;当前页行数=min(N,20) 且与切换前不同",每页条数切换生效;总数不变;列表同步。,
PRODM-CLA-012,基础设置 · 产品管理,中心库上下限建议(AI) · 导出,当前查询条件下导出,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-012,基础设置 · 产品管理,中心库上下限建议(AI) · 导出,首行样本查询后导出,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 先执行一次查询 2. 记录首行样本字段(优先物料编号/编码)
3. 点击「导 出」或「导出」或「导出Excel」 3. 将样本填入筛选区后点击「查 询」
4. 断言无未捕获异常(不校验文件内容)",导出流程可启动。, 4. 查询后列表无数据则 skip
5. 点击「导 出」或「导出」或「导出Excel」
6. 断言无未捕获异常(不校验文件内容)",按首行样本查询后导出流程可启动;无数据则跳过。,
PRODM-CLA-013,基础设置 · 产品管理,中心库上下限建议(AI) · 打印,按当前条件打印,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-013,基础设置 · 产品管理,中心库上下限建议(AI) · 打印,按当前条件打印,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 点击「打 印」或「打印产品码」等页面级打印入口 2. 点击「打 印」或「打印产品码」等页面级打印入口
3. 断言可拉起对话框/预览/toast,无 error",打印流程可启动。, 3. 断言可拉起对话框/预览/toast,无 error",打印流程可启动。,
PRODM-CLA-014,基础设置 · 产品管理,中心库上下限建议(AI) · 全部调整,打开「全部调整」对应弹窗/抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-014,基础设置 · 产品管理,中心库上下限建议(AI) · 全部调整,打开「全部调整」对应弹窗/抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 点击「全部调整」或等价入口 2. 点击「全部调整」或等价入口
3. 断言弹窗/抽屉/详情可见,关键字段可交互 3. 断言弹窗/抽屉/详情可见,关键字段可交互
4. 可见取消/关闭/返回",「全部调整」层打开;关键控件可见。, 4. 可见取消/关闭/返回",「全部调整」层打开;关键控件可见。,
PRODM-CLA-015,基础设置 · 产品管理,中心库上下限建议(AI) · 全部调整,「全部调整」取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-015,基础设置 · 产品管理,中心库上下限建议(AI) · 全部调整,「全部调整」取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 打开「全部调整」,任意填写非空后点击「取 消」或关闭 2. 打开「全部调整」,任意填写非空后点击「取 消」或关闭
3. 断言弹层关闭,列表行数与打开前一致(抽样)",取消后列表无新增/无修改。, 3. 断言弹层关闭,列表行数与打开前一致(抽样)",取消后列表无新增/无修改。,
PRODM-CLA-016,基础设置 · 产品管理,中心库上下限建议(AI) · 全部调整,「全部调整」必填为空拦截,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);该层存在必填提交,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CLA-016,基础设置 · 产品管理,中心库上下限建议(AI) · 全部调整,「全部调整」必填为空拦截,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);该层存在必填提交,否则 skip,"1. 确认已加载 storageState(test_1)
2. 打开「全部调整」,必填项留空后点击「确 定」 2. 打开「全部调整」,必填项留空后点击「确 定」
3. 断言出现必填提示,弹层仍打开,列表不刷新为成功态",确定被拦截;不落库。, 3. 断言出现必填提示,弹层仍打开,列表不刷新为成功态",确定被拦截;不落库。,
PRODM-CLA-017,基础设置 · 产品管理,中心库上下限建议(AI) · 批量调整,打开「批量调整」对应弹窗/抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-017,基础设置 · 产品管理,中心库上下限建议(AI) · 批量调整,打开「批量调整」对应弹窗/抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 点击「批量调整」或等价入口 2. 点击「批量调整」或等价入口
3. 断言弹窗/抽屉/详情可见,关键字段可交互 3. 断言弹窗/抽屉/详情可见,关键字段可交互
4. 可见取消/关闭/返回",「批量调整」层打开;关键控件可见。, 4. 可见取消/关闭/返回",「批量调整」层打开;关键控件可见。,
PRODM-CLA-018,基础设置 · 产品管理,中心库上下限建议(AI) · 批量调整,「批量调整」取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-018,基础设置 · 产品管理,中心库上下限建议(AI) · 批量调整,「批量调整」取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 打开「批量调整」,任意填写非空后点击「取 消」或关闭 2. 打开「批量调整」,任意填写非空后点击「取 消」或关闭
3. 断言弹层关闭,列表行数与打开前一致(抽样)",取消后列表无新增/无修改。, 3. 断言弹层关闭,列表行数与打开前一致(抽样)",取消后列表无新增/无修改。,
PRODM-CLA-019,基础设置 · 产品管理,中心库上下限建议(AI) · 批量调整,「批量调整」必填为空拦截,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);该层存在必填提交,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CLA-019,基础设置 · 产品管理,中心库上下限建议(AI) · 批量调整,「批量调整」必填为空拦截,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);该层存在必填提交,否则 skip,"1. 确认已加载 storageState(test_1)
2. 打开「批量调整」,必填项留空后点击「确 定」 2. 打开「批量调整」,必填项留空后点击「确 定」
3. 断言出现必填提示,弹层仍打开,列表不刷新为成功态",确定被拦截;不落库。, 3. 断言出现必填提示,弹层仍打开,列表不刷新为成功态",确定被拦截;不落库。,
PRODM-CLA-020,基础设置 · 产品管理,中心库上下限建议(AI) · 采纳,打开「采纳」对应弹窗/抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-020,基础设置 · 产品管理,中心库上下限建议(AI) · 采纳,打开「采纳」对应弹窗/抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 点击「采纳」或等价入口 2. 点击「采纳」或等价入口
3. 断言弹窗/抽屉/详情可见,关键字段可交互 3. 断言弹窗/抽屉/详情可见,关键字段可交互
4. 可见取消/关闭/返回",「采纳」层打开;关键控件可见。, 4. 可见取消/关闭/返回",「采纳」层打开;关键控件可见。,
PRODM-CLA-021,基础设置 · 产品管理,中心库上下限建议(AI) · 采纳,「采纳」取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1) PRODM-CLA-021,基础设置 · 产品管理,中心库上下限建议(AI) · 采纳,「采纳」取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi),"1. 确认已加载 storageState(test_1)
2. 打开「采纳」,任意填写非空后点击「取 消」或关闭 2. 打开「采纳」,任意填写非空后点击「取 消」或关闭
3. 断言弹层关闭,列表行数与打开前一致(抽样)",取消后列表无新增/无修改。, 3. 断言弹层关闭,列表行数与打开前一致(抽样)",取消后列表无新增/无修改。,
PRODM-CLA-022,基础设置 · 产品管理,中心库上下限建议(AI) · 采纳,「采纳」必填为空拦截,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);该层存在必填提交,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CLA-022,基础设置 · 产品管理,中心库上下限建议(AI) · 采纳,「采纳」必填为空拦截,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库上下限建议(AI)」权限;已进入中心库上下限建议(AI)页(/spd/basic/goods/paltform/product/centerLimitAi);该层存在必填提交,否则 skip,"1. 确认已加载 storageState(test_1)
2. 打开「采纳」,必填项留空后点击「确 定」 2. 打开「采纳」,必填项留空后点击「确 定」
3. 断言出现必填提示,弹层仍打开,列表不刷新为成功态",确定被拦截;不落库。, 3. 断言出现必填提示,弹层仍打开,列表不刷新为成功态",确定被拦截;不落库。,
用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果 用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果
PRODM-CZO-001,基础设置 · 产品管理,中心库库区产品设置 · 快捷筛选查询,快捷区重置恢复默认,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1) PRODM-CZO-001,基础设置 · 产品管理,中心库库区产品设置 · 快捷筛选查询,快捷区重置恢复默认,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1)
2. 任意填写快捷区可见字段后点击「重 置」 2. 任意填写快捷区可见字段后点击「重 置」
3. 断言条件恢复默认;列表是否刷新按产品规则(未定则记待评审)",重置后条件恢复默认;页面无报错。, 3. 断言条件恢复默认;列表是否刷新按产品规则(未定则记待评审)",重置后条件恢复默认;页面无报错。,
PRODM-CZO-002,基础设置 · 产品管理,中心库库区产品设置 · 高级筛选抽屉,全量查询条件组合查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);默认查询至少 1 条,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CZO-002,基础设置 · 产品管理,中心库库区产品设置 · 高级筛选抽屉,全量查询条件组合查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);默认查询至少 1 条,否则 skip,"1. 确认已加载 storageState(test_1)
2. 默认查询 → 从首行提取全量样本(不写死业务数据,见 rules 3.4) 2. 默认查询 → 从首行提取全量样本(不写死业务数据,见 rules 3.4)
3. 快捷区填入样本中有值的可见字段 3. 快捷区填入样本中有值的可见字段
4. 高级筛选抽屉填入样本中所有非空字段(文本直接填、下拉选同值;禁用字段记录默认) 4. 高级筛选抽屉填入样本中所有非空字段(文本直接填、下拉选同值;禁用字段记录默认)
5. 「查询/筛选」后抽样首行;首行同时满足样本中全部已填字段",查询正常无报错;查询条件写值正常;查询结果与查询条件一致。, 5. 「查询/筛选」后抽样首行;首行同时满足样本中全部已填字段",查询正常无报错;查询条件写值正常;查询结果与查询条件一致。,
PRODM-CZO-003,基础设置 · 产品管理,中心库库区产品设置 · 高级筛选抽屉,点击高级筛选打开抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1) PRODM-CZO-003,基础设置 · 产品管理,中心库库区产品设置 · 高级筛选抽屉,点击高级筛选打开抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1)
2. 点击「高级筛选」 2. 点击「高级筛选」
3. 断言抽屉标题为「高级筛选」,关键字段可交互 3. 断言抽屉标题为「高级筛选」,关键字段可交互
4. 关闭抽屉,快捷区条件不被误清空(以现网为准)",高级筛选抽屉可打开并关闭。, 4. 关闭抽屉,快捷区条件不被误清空(以现网为准)",高级筛选抽屉可打开并关闭。,
PRODM-CZO-004,基础设置 · 产品管理,中心库库区产品设置 · 列表,列表核心列完整,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1) PRODM-CZO-004,基础设置 · 产品管理,中心库库区产品设置 · 列表,列表核心列完整,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1)
2. 断言表头含核心列(抽样):库区、产品、上下限/货位等(以现网为准) 2. 断言表头含核心列(抽样):库区、产品、上下限/货位等(以现网为准)
3. 若有数据,抽样首行关键字段非空",核心列完整;列表结构正确。, 3. 若有数据,抽样首行关键字段非空",核心列完整;列表结构正确。,
PRODM-CZO-005,基础设置 · 产品管理,中心库库区产品设置 · 行内操作,行内「详情」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);列表至少 1 条,"1. 确认已加载 storageState(test_1) PRODM-CZO-005,基础设置 · 产品管理,中心库库区产品设置 · 行内操作,行内「详情」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);列表至少 1 条,"1. 确认已加载 storageState(test_1)
2. 首行有数据时点击「详情」 2. 首行有数据时点击「详情」
3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回 3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回
4. 关闭弹层或点返回,列表仍可见",行内「详情」可达;关闭后回到列表。, 4. 关闭弹层或点返回,列表仍可见",行内「详情」可达;关闭后回到列表。,
PRODM-CZO-006,基础设置 · 产品管理,中心库库区产品设置 · 行内操作,行内「编辑」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);列表至少 1 条,"1. 确认已加载 storageState(test_1) PRODM-CZO-006,基础设置 · 产品管理,中心库库区产品设置 · 行内操作,行内「编辑」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);列表至少 1 条,"1. 确认已加载 storageState(test_1)
2. 首行有数据时点击「编辑」 2. 首行有数据时点击「编辑」
3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回 3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回
4. 关闭弹层或点返回,列表仍可见",行内「编辑」可达;关闭后回到列表。, 4. 关闭弹层或点返回,列表仍可见",行内「编辑」可达;关闭后回到列表。,
PRODM-CZO-007,基础设置 · 产品管理,中心库库区产品设置 · 列表,列设置隐藏后再恢复,低,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);列表表头已加载,"1. 确认已加载 storageState(test_1) PRODM-CZO-007,基础设置 · 产品管理,中心库库区产品设置 · 列表,列设置隐藏后再恢复,低,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);列表表头已加载,"1. 确认已加载 storageState(test_1)
2. 默认查询后点击表格工具栏列设置(齿轮) 2. 默认查询后点击表格工具栏列设置(齿轮)
3. 取消勾选一列非核心字段并关闭面板,断言表头隐藏 3. 取消勾选一列非核心字段并关闭面板,断言表头隐藏
4. 再次打开列设置勾选恢复并关闭,断言表头恢复",勾选状态与表头一致。, 4. 再次打开列设置勾选恢复并关闭,断言表头恢复",勾选状态与表头一致。,
PRODM-CZO-008,基础设置 · 产品管理,中心库库区产品设置 · 列表,横向滚动与列对齐,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);列表有数据且列超出视口,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CZO-008,基础设置 · 产品管理,中心库库区产品设置 · 列表,横向滚动与列对齐,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);列表有数据且列超出视口,否则 skip,"1. 确认已加载 storageState(test_1)
2. 宽表场景下横向滚动使远侧列进入视口 2. 宽表场景下横向滚动使远侧列进入视口
3. 断言表头列数与数据列数一致、对齐无重叠",远侧列可进入视口;表头与数据列对齐。, 3. 断言表头列数与数据列数一致、对齐无重叠",远侧列可进入视口;表头与数据列对齐。,
PRODM-CZO-009,基础设置 · 产品管理,中心库库区产品设置 · 列表,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1) PRODM-CZO-009,基础设置 · 产品管理,中心库库区产品设置 · 列表,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1)
2. 名称/编号类关键字填入 `__NOT_EXIST_PRODM__99999` 后查询 2. 名称/编号类关键字填入 `__NOT_EXIST_PRODM__99999` 后查询
3. 断言表头仍在,0 行或空态,分页总数为 0",无匹配不报错;不展示过期成功态数据。, 3. 断言表头仍在,0 行或空态,分页总数为 0",无匹配不报错;不展示过期成功态数据。,
PRODM-CZO-010,基础设置 · 产品管理,中心库库区产品设置 · 分页,有数据时总条数,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1) PRODM-CZO-010,基础设置 · 产品管理,中心库库区产品设置 · 分页,有数据时总条数,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1)
2. 默认查询后读取「共 N 条」 2. 默认查询后读取「共 N 条」
3. 断言 N 与本次查询匹配条数规则一致(有数据或 0)",「共 N 条」与查询结果一致。, 3. 断言 N 与本次查询匹配条数规则一致(有数据或 0)",「共 N 条」与查询结果一致。,
PRODM-CZO-011,基础设置 · 产品管理,中心库库区产品设置 · 分页,切换至第 2 页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);总条数大于一页,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CZO-011,基础设置 · 产品管理,中心库库区产品设置 · 分页,切换至第 2 页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);总条数大于一页,否则 skip,"1. 确认已加载 storageState(test_1)
2. 记录第 1 页首行关键字段 2. 记录第 1 页首行关键字段
3. 点击页码「2」或「下一页」 3. 点击页码「2」或「下一页」
4. 断言页码高亮正确,首行数据与第 1 页不同",翻页成功;数据更换。, 4. 断言页码高亮正确,首行数据与第 1 页不同",翻页成功;数据更换。,
PRODM-CZO-012,基础设置 · 产品管理,中心库库区产品设置 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);N>10 且切换前可见行数 > min(N,20),否则 skip(见 3.7)","1. 确认已加载 storageState(test_1) PRODM-CZO-012,基础设置 · 产品管理,中心库库区产品设置 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);N>10 且切换前可见行数 > min(N,20),否则 skip(见 3.7)","1. 确认已加载 storageState(test_1)
2. 记录「共 N 条」与当前页可见行数 2. 记录「共 N 条」与当前页可见行数
3. 打开每页条数下拉,选择「20 条/页」 3. 打开每页条数下拉,选择「20 条/页」
4. 断言分页器显示 20 条/页;N 不变;当前页行数=min(N,20) 且与切换前不同",每页条数切换生效;总数不变;列表同步。, 4. 断言分页器显示 20 条/页;N 不变;当前页行数=min(N,20) 且与切换前不同",每页条数切换生效;总数不变;列表同步。,
PRODM-CZO-013,基础设置 · 产品管理,中心库库区产品设置 · 导出,当前查询条件下导出,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1) PRODM-CZO-013,基础设置 · 产品管理,中心库库区产品设置 · 导出,首行样本查询后导出,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1)
2. 先执行一次查询 2. 记录首行样本字段(优先物料编号/编码)
3. 点击「导 出」或「导出」或「导出Excel」 3. 将样本填入筛选区后点击「查 询」
4. 断言无未捕获异常(不校验文件内容)",导出流程可启动。, 4. 查询后列表无数据则 skip
5. 点击「导 出」或「导出」或「导出Excel」
6. 断言无未捕获异常(不校验文件内容)",按首行样本查询后导出流程可启动;无数据则跳过。,
PRODM-CZO-014,基础设置 · 产品管理,中心库库区产品设置 · 添加中心库库区产品,打开「添加中心库库区产品」对应弹窗/抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1) PRODM-CZO-014,基础设置 · 产品管理,中心库库区产品设置 · 添加中心库库区产品,打开「添加中心库库区产品」对应弹窗/抽屉,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1)
2. 点击「添加中心库库区产品」或等价入口 2. 点击「添加中心库库区产品」或等价入口
3. 断言弹窗/抽屉/详情可见,关键字段可交互 3. 断言弹窗/抽屉/详情可见,关键字段可交互
4. 可见取消/关闭/返回",「添加中心库库区产品」层打开;关键控件可见。, 4. 可见取消/关闭/返回",「添加中心库库区产品」层打开;关键控件可见。,
PRODM-CZO-015,基础设置 · 产品管理,中心库库区产品设置 · 添加中心库库区产品,「添加中心库库区产品」取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1) PRODM-CZO-015,基础设置 · 产品管理,中心库库区产品设置 · 添加中心库库区产品,「添加中心库库区产品」取消不落库,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product),"1. 确认已加载 storageState(test_1)
2. 打开「添加中心库库区产品」,任意填写非空后点击「取 消」或关闭 2. 打开「添加中心库库区产品」,任意填写非空后点击「取 消」或关闭
3. 断言弹层关闭,列表行数与打开前一致(抽样)",取消后列表无新增/无修改。, 3. 断言弹层关闭,列表行数与打开前一致(抽样)",取消后列表无新增/无修改。,
PRODM-CZO-016,基础设置 · 产品管理,中心库库区产品设置 · 添加中心库库区产品,「添加中心库库区产品」必填为空拦截,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);该层存在必填提交,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-CZO-016,基础设置 · 产品管理,中心库库区产品设置 · 添加中心库库区产品,「添加中心库库区产品」必填为空拦截,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 中心库库区产品设置」权限;已进入中心库库区产品设置页(/spd/basic/goods/platform/area/product);该层存在必填提交,否则 skip,"1. 确认已加载 storageState(test_1)
2. 打开「添加中心库库区产品」,必填项留空后点击「确 定」 2. 打开「添加中心库库区产品」,必填项留空后点击「确 定」
3. 断言出现必填提示,弹层仍打开,列表不刷新为成功态",确定被拦截;不落库。, 3. 断言出现必填提示,弹层仍打开,列表不刷新为成功态",确定被拦截;不落库。,
...@@ -13,36 +13,34 @@ PRODM-DIC-003,基础设置 · 产品管理,产品DI变更 · 查询条件展开 ...@@ -13,36 +13,34 @@ PRODM-DIC-003,基础设置 · 产品管理,产品DI变更 · 查询条件展开
PRODM-DIC-004,基础设置 · 产品管理,产品DI变更 · 列表,列表核心列完整,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange),"1. 确认已加载 storageState(test_1) PRODM-DIC-004,基础设置 · 产品管理,产品DI变更 · 列表,列表核心列完整,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange),"1. 确认已加载 storageState(test_1)
2. 断言表头含核心列(抽样):DI 变更单号、状态、产品等(以现网为准) 2. 断言表头含核心列(抽样):DI 变更单号、状态、产品等(以现网为准)
3. 若有数据,抽样首行关键字段非空",核心列完整;列表结构正确。, 3. 若有数据,抽样首行关键字段非空",核心列完整;列表结构正确。,
PRODM-DIC-005,基础设置 · 产品管理,产品DI变更 · 行内操作,行内「详情」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange);列表至少 1 条,"1. 确认已加载 storageState(test_1) PRODM-DIC-005,基础设置 · 产品管理,产品DI变更 · 行内操作,详情页变更单号与主列表一致,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange);列表至少 1 条,"1. 确认已加载 storageState(test_1)
2. 首行有数据时点击「详情」 2. 记录首行「变更单号」(如 PS20261029000002)
3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回 3. 首行有数据时点击「详情」
4. 关闭弹层或点返回,列表仍可见",行内「详情」可达;关闭后回到列表。, 4. 断言进入详情页/弹层,可见取消/关闭/返回
PRODM-DIC-006,基础设置 · 产品管理,产品DI变更 · 行内操作,行内「编辑」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange);列表至少 1 条,"1. 确认已加载 storageState(test_1) 5. 断言详情页「变更单号」与主列表该行一致
2. 首行有数据时点击「编辑」 6. 关闭弹层或点返回,列表仍可见",详情页变更单号与主列表一致;关闭后回到列表。,
3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回 PRODM-DIC-006,基础设置 · 产品管理,产品DI变更 · 列表,列设置隐藏后再恢复,低,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange);列表表头已加载,"1. 确认已加载 storageState(test_1)
4. 关闭弹层或点返回,列表仍可见",行内「编辑」可达;关闭后回到列表。,
PRODM-DIC-007,基础设置 · 产品管理,产品DI变更 · 行内操作,行内「日志」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange);列表至少 1 条,"1. 确认已加载 storageState(test_1)
2. 首行有数据时点击「日志」
3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回
4. 关闭弹层或点返回,列表仍可见",行内「日志」可达;关闭后回到列表。,
PRODM-DIC-008,基础设置 · 产品管理,产品DI变更 · 列表,列设置隐藏后再恢复,低,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange);列表表头已加载,"1. 确认已加载 storageState(test_1)
2. 默认查询后点击表格工具栏列设置(齿轮) 2. 默认查询后点击表格工具栏列设置(齿轮)
3. 取消勾选一列非核心字段并关闭面板,断言表头隐藏 3. 取消勾选一列非核心字段并关闭面板,断言表头隐藏
4. 再次打开列设置勾选恢复并关闭,断言表头恢复",勾选状态与表头一致。, 4. 再次打开列设置勾选恢复并关闭,断言表头恢复",勾选状态与表头一致。,
PRODM-DIC-009,基础设置 · 产品管理,产品DI变更 · 列表,横向滚动与列对齐,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange);列表有数据且列超出视口,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-DIC-007,基础设置 · 产品管理,产品DI变更 · 列表,横向滚动与列对齐,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange);列表有数据且列超出视口,否则 skip,"1. 确认已加载 storageState(test_1)
2. 宽表场景下横向滚动使远侧列进入视口 2. 宽表场景下横向滚动使远侧列进入视口
3. 断言表头列数与数据列数一致、对齐无重叠",远侧列可进入视口;表头与数据列对齐。, 3. 断言表头列数与数据列数一致、对齐无重叠",远侧列可进入视口;表头与数据列对齐。,
PRODM-DIC-010,基础设置 · 产品管理,产品DI变更 · 列表,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange),"1. 确认已加载 storageState(test_1) PRODM-DIC-008,基础设置 · 产品管理,产品DI变更 · 列表,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange),"1. 确认已加载 storageState(test_1)
2. 名称/编号类关键字填入 `__NOT_EXIST_PRODM__99999` 后查询 2. 名称/编号类关键字填入 `__NOT_EXIST_PRODM__99999` 后查询
3. 断言表头仍在,0 行或空态,分页总数为 0",无匹配不报错;不展示过期成功态数据。, 3. 断言表头仍在,0 行或空态,分页总数为 0",无匹配不报错;不展示过期成功态数据。,
PRODM-DIC-011,基础设置 · 产品管理,产品DI变更 · 分页,有数据时总条数,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange),"1. 确认已加载 storageState(test_1) PRODM-DIC-009,基础设置 · 产品管理,产品DI变更 · 分页,有数据时总条数,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange),"1. 确认已加载 storageState(test_1)
2. 默认查询后读取「共 N 条」 2. 默认查询后读取「共 N 条」
3. 断言 N 与本次查询匹配条数规则一致(有数据或 0)",「共 N 条」与查询结果一致。, 3. 断言 N 与本次查询匹配条数规则一致(有数据或 0)",「共 N 条」与查询结果一致。,
PRODM-DIC-012,基础设置 · 产品管理,产品DI变更 · 分页,切换至第 2 页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange);总条数大于一页,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-DIC-010,基础设置 · 产品管理,产品DI变更 · 分页,切换至第 2 页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange);总条数大于一页,否则 skip,"1. 确认已加载 storageState(test_1)
2. 记录第 1 页首行关键字段 2. 记录第 1 页首行关键字段
3. 点击页码「2」或「下一页」 3. 点击页码「2」或「下一页」
4. 断言页码高亮正确,首行数据与第 1 页不同",翻页成功;数据更换。, 4. 断言页码高亮正确,首行数据与第 1 页不同",翻页成功;数据更换。,
PRODM-DIC-013,基础设置 · 产品管理,产品DI变更 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange);N>10 且切换前可见行数 > min(N,20),否则 skip(见 3.7)","1. 确认已加载 storageState(test_1) PRODM-DIC-011,基础设置 · 产品管理,产品DI变更 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange);N>10 且切换前可见行数 > min(N,20),否则 skip(见 3.7)","1. 确认已加载 storageState(test_1)
2. 记录「共 N 条」与当前页可见行数 2. 记录「共 N 条」与当前页可见行数
3. 打开每页条数下拉,选择「20 条/页」 3. 打开每页条数下拉,选择「20 条/页」
4. 断言分页器显示 20 条/页;N 不变;当前页行数=min(N,20) 且与切换前不同",每页条数切换生效;总数不变;列表同步。, 4. 断言分页器显示 20 条/页;N 不变;当前页行数=min(N,20) 且与切换前不同",每页条数切换生效;总数不变;列表同步。,
PRODM-DIC-012,基础设置 · 产品管理,产品DI变更 · 状态 Tab,状态 Tab 切换且计数与列表一致,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI变更」权限;已进入产品DI变更页(/spd/basic/goods/platform/dichange),"1. 确认已加载 storageState(test_1)
2. 依次切换状态 Tab:全部、待提交、待审核、已完成、已驳回
3. 每次切换前读取 Tab 括号数量(如全部(12))
4. 切换后断言列表刷新且无报错;分页「共 N 条」等于该 Tab 显示数量(0 时为空态)",各状态 Tab 可正常切换;Tab 显示数量与列表「共 N 条」一致。,
用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果 用例编号,功能模块,功能测试点,用例标题,优先级,前置条件,测试步骤,预期结果,实际结果
PRODM-DIQ-001,基础设置 · 产品管理,产品DI查询 · 快捷筛选查询,快捷区重置恢复默认,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch),"1. 确认已加载 storageState(test_1) PRODM-DIQ-001,基础设置 · 产品管理,产品DI查询 · 快捷筛选查询,快捷区重置恢复默认,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch),"1. 确认已加载 storageState(test_1)
2. 任意填写快捷区可见字段后点击「重 置」 2. 任意填写快捷区可见字段后点击「重 置」
3. 断言条件恢复默认;列表是否刷新按产品规则(未定则记待评审)",重置后条件恢复默认;页面无报错。, 3. 断言条件恢复默认;列表是否刷新按产品规则(未定则记待评审)",重置后条件恢复默认;页面无报错。,
PRODM-DIQ-002,基础设置 · 产品管理,产品DI查询 · 快捷筛选查询,全量可见条件组合查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch);默认查询至少 1 条,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-DIQ-002,基础设置 · 产品管理,产品DI查询 · 快捷筛选查询,全量可见条件组合查询,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch);默认查询至少 1 条,否则 skip,"1. 确认已加载 storageState(test_1)
2. 默认查询 → 从首行提取全量样本(只填当前结果里有值的条件) 2. 默认查询 → 从首行提取全量样本(只填当前结果里有值的条件)
3. 快捷区可见非空条件全部填入后查询 3. 快捷区可见非空条件全部填入后查询
4. 抽样首行同时满足全部已填条件;无未捕获异常",查询正常无报错;查询结果与查询条件一致。, 4. 抽样首行同时满足全部已填条件;无未捕获异常",查询正常无报错;查询结果与查询条件一致。,
PRODM-DIQ-003,基础设置 · 产品管理,产品DI查询 · 查询条件展开收起,展开后更多条件可见,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch),"1. 确认已加载 storageState(test_1) PRODM-DIQ-003,基础设置 · 产品管理,产品DI查询 · 查询条件展开收起,展开后更多条件可见,中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch),"1. 确认已加载 storageState(test_1)
2. 点击「展开」 2. 点击「展开」
3. 断言额外筛选字段进入可视区且可填写 3. 断言额外筛选字段进入可视区且可填写
4. 按钮文案变为「收起」",展开后更多条件可见并可交互。, 4. 按钮文案变为「收起」",展开后更多条件可见并可交互。,
PRODM-DIQ-004,基础设置 · 产品管理,产品DI查询 · 列表,列表核心列完整,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch),"1. 确认已加载 storageState(test_1) PRODM-DIQ-004,基础设置 · 产品管理,产品DI查询 · 列表,列表核心列完整,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch),"1. 确认已加载 storageState(test_1)
2. 断言表头含核心列(抽样):DI、产品、注册相关列(以现网为准) 2. 断言表头含核心列(抽样):DI、产品、注册相关列(以现网为准)
3. 若有数据,抽样首行关键字段非空",核心列完整;列表结构正确。, 3. 若有数据,抽样首行关键字段非空",核心列完整;列表结构正确。,
PRODM-DIQ-005,基础设置 · 产品管理,产品DI查询 · 列表,行内「详情」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch);列表至少 1 条,"1. 确认已加载 storageState(test_1) PRODM-DIQ-005,基础设置 · 产品管理,产品DI查询 · 列表,行内「详情」可达(只读冒烟),高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch);列表至少 1 条,"1. 确认已加载 storageState(test_1)
2. 首行有数据时点击「详情」 2. 记录首行「物料编号」(或「物料编码」)
3. 断言进入预期弹窗/抽屉/详情,可见取消/关闭/返回 3. 首行有数据时点击「详情」
4. 关闭弹层或点返回,列表仍可见",行内操作可达;关闭后回到列表。, 4. 断言进入详情,可见返回/关闭
5. 断言详情页「物料编号/物料编码」与列表首行一致
6. 关闭弹层或点返回,列表仍可见",行内操作可达;详情物料编码与列表一致;关闭后回到列表。,
PRODM-DIQ-006,基础设置 · 产品管理,产品DI查询 · 列表,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch),"1. 确认已加载 storageState(test_1) PRODM-DIQ-006,基础设置 · 产品管理,产品DI查询 · 列表,无匹配空态(mock反例),中,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch),"1. 确认已加载 storageState(test_1)
2. 名称/编号类关键字填入 `__NOT_EXIST_PRODM__99999` 后查询 2. 名称/编号类关键字填入 `__NOT_EXIST_PRODM__99999` 后查询
3. 断言表头仍在,0 行或空态,分页总数为 0",无匹配不报错;不展示过期成功态数据。, 3. 断言表头仍在,0 行或空态,分页总数为 0",无匹配不报错;不展示过期成功态数据。,
PRODM-DIQ-007,基础设置 · 产品管理,产品DI查询 · 分页,有数据时总条数,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch),"1. 确认已加载 storageState(test_1) PRODM-DIQ-007,基础设置 · 产品管理,产品DI查询 · 分页,有数据时总条数,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch),"1. 确认已加载 storageState(test_1)
2. 默认查询后读取「共 N 条」 2. 默认查询后读取「共 N 条」
3. 断言 N 与本次查询匹配条数规则一致(有数据或 0)",「共 N 条」与查询结果一致。, 3. 断言 N 与本次查询匹配条数规则一致(有数据或 0)",「共 N 条」与查询结果一致。,
PRODM-DIQ-008,基础设置 · 产品管理,产品DI查询 · 分页,切换至第 2 页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch);总条数大于一页,否则 skip,"1. 确认已加载 storageState(test_1) PRODM-DIQ-008,基础设置 · 产品管理,产品DI查询 · 分页,切换至第 2 页,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch);总条数大于一页,否则 skip,"1. 确认已加载 storageState(test_1)
2. 记录第 1 页首行关键字段 2. 记录第 1 页首行关键字段
3. 点击页码「2」或「下一页」 3. 点击页码「2」或「下一页」
4. 断言页码高亮正确,首行数据与第 1 页不同",翻页成功;数据更换。, 4. 断言页码高亮正确,首行数据与第 1 页不同",翻页成功;数据更换。,
PRODM-DIQ-009,基础设置 · 产品管理,产品DI查询 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch);N>10 且切换前可见行数 > min(N,20),否则 skip(见 3.7)","1. 确认已加载 storageState(test_1) PRODM-DIQ-009,基础设置 · 产品管理,产品DI查询 · 分页,切换20条每页页面正常且列表同步,中,"测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch);N>10 且切换前可见行数 > min(N,20),否则 skip(见 3.7)","1. 确认已加载 storageState(test_1)
2. 记录「共 N 条」与当前页可见行数 2. 记录「共 N 条」与当前页可见行数
3. 打开每页条数下拉,选择「20 条/页」 3. 打开每页条数下拉,选择「20 条/页」
4. 断言分页器显示 20 条/页;N 不变;当前页行数=min(N,20) 且与切换前不同",每页条数切换生效;总数不变;列表同步。, 4. 断言分页器显示 20 条/页;N 不变;当前页行数=min(N,20) 且与切换前不同",每页条数切换生效;总数不变;列表同步。,
PRODM-DIQ-010,基础设置 · 产品管理,产品DI查询 · 导出,当前查询条件下导出,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch),"1. 确认已加载 storageState(test_1) PRODM-DIQ-010,基础设置 · 产品管理,产品DI查询 · 导出,首行样本查询后导出,高,测试环境可访问;正例账号 test_1 已通过 auth.setup 写入 storageState;具备「基础设置 → 产品管理 → 产品DI查询」权限;已进入产品DI查询页(/spd/basic/goods/platform/diSearch),"1. 确认已加载 storageState(test_1)
2. 先执行一次查询 2. 记录首行样本字段(优先物料编号/编码)
3. 点击「导 出」或「导出」或「导出Excel」 3. 将样本填入筛选区后点击「查 询」
4. 断言无未捕获异常(不校验文件内容)",导出流程可启动。, 4. 查询后列表无数据则 skip
5. 点击「导 出」或「导出」或「导出Excel」
6. 断言无未捕获异常(不校验文件内容)",按首行样本查询后导出流程可启动;无数据则跳过。,
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