aps-agent/apps/web/e2e/drawing-upload-flow.spec.ts

249 lines
11 KiB
TypeScript
Raw Normal View History

// ============================================================
// 工程图纸 DXF Web E2E(R71.1):上传 → 解析 → 候选 → P2 暂存 → 确认落库
// 依赖隔离后端(docs/development/e2e.md 的 8100 启动方式)与 JMS 登录。
// APS_E2E_SKIP_DRAWING=1 时跳过,供无图纸样例/无外部环境的 CI 场景使用。
// ============================================================
import { test, expect, type Page } from '@playwright/test';
const SKIP_DRAWING = process.env.APS_E2E_SKIP_DRAWING === '1';
const JMS = {
tenant: process.env.E2E_JMS_TENANT?.trim() || '',
username: process.env.E2E_JMS_USER?.trim() || '',
password: process.env.E2E_JMS_PASSWORD || '',
};
const missingJmsConfig = Object.entries(JMS).filter(([, value]) => !value).map(([key]) => key);
if (!SKIP_DRAWING && missingJmsConfig.length) {
throw new Error(
`[e2e] JMS 登录参数未配置:${missingJmsConfig.join(', ')};` +
'请设置 E2E_JMS_TENANT/E2E_JMS_USER/E2E_JMS_PASSWORD,或设置 APS_E2E_SKIP_DRAWING=1。',
);
}
function minimalDxf(text: string): string {
return [
'0', 'SECTION', '2', 'HEADER', '0', 'ENDSEC',
'0', 'SECTION', '2', 'ENTITIES',
'0', 'TEXT', '8', '0', '10', '0.0', '20', '0.0', '40', '2.5', '1', text,
'0', 'ENDSEC', '0', 'EOF', '',
].join('\n');
}
function minimalPdf(text: string): Buffer {
const lines = [text];
const content = `BT /F1 10 Tf 72 760 Td 14 TL\n${lines.map(line => `(${line}) Tj T*\n`).join('')}ET`;
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
`<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
];
let body = '%PDF-1.4\n';
const offsets: number[] = [];
objects.forEach((obj, index) => {
offsets.push(Buffer.byteLength(body, 'latin1'));
body += `${index + 1} 0 obj\n${obj}\nendobj\n`;
});
const xrefPos = Buffer.byteLength(body, 'latin1');
body += `xref\n0 ${objects.length + 1}\n`;
body += '0000000000 65535 f \n';
offsets.forEach(offset => { body += `${String(offset).padStart(10, '0')} 00000 n \n`; });
body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefPos}\n%%EOF\n`;
return Buffer.from(body, 'latin1');
}
async function login(page: Page): Promise<void> {
await page.goto('/');
const trigger = page.locator('.guest-login-trigger');
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
const dialog = page.locator('.auth-login-dialog');
await expect(dialog).toBeVisible();
await dialog.locator('input[placeholder="请输入企业名称"]').fill(JMS.tenant);
await dialog.locator('input[placeholder="请输入用户名"]').fill(JMS.username);
await dialog.locator('input[placeholder="请输入密码"]').fill(JMS.password);
await dialog.locator('.login-submit').click();
await expect(dialog).toBeHidden({ timeout: 30_000 }).catch(async (error) => {
if (await dialog.locator('.login-captcha-field').count()) {
throw new Error('登录要求图形验证码,E2E 冒烟无法自动处理(请用预置测试账号或关闭验证码)');
}
throw error;
});
await expect(page.locator('textarea[placeholder^="输入排产指令"]')).toBeVisible({ timeout: 60_000 });
}
async function createProjectViaUi(page: Page, name: string): Promise<string> {
await page.locator('.sidebar-mini-btn[title="新建项目"]').click();
const dialog = page.locator('[role="dialog"][aria-label="新建项目"]');
await expect(dialog).toBeVisible();
await dialog.locator('.dlg-input').first().fill(name);
const createRespPromise = page.waitForResponse(
resp => resp.url().endsWith('/api/projects') && resp.request().method() === 'POST',
);
await dialog.locator('.btn-primary').click();
await expect(dialog).toBeHidden({ timeout: 30_000 });
const createResp = await createRespPromise;
expect(createResp.ok()).toBeTruthy();
const created = await createResp.json() as { project: { id: string } };
const projectId = created.project.id;
const workspace = await (await page.request.get('/api/workspace')).json();
const projectSession = workspace.sessions.find(
(row: { projectId: string }) => row.projectId === projectId,
);
expect(projectSession).toBeTruthy();
const selected = await page.request.put('/api/workspace', {
data: {
projects: [],
sessions: [],
files: [],
activeProjectId: projectId,
activeSessionId: projectSession.id,
},
});
expect(selected.ok()).toBeTruthy();
await page.reload();
await expect(page.locator('textarea[placeholder^="输入排产指令"]')).toBeVisible({ timeout: 60_000 });
const projectItem = page.locator('.sidebar-project', { hasText: name }).first();
await expect(projectItem).toBeVisible();
await expect(projectItem).toHaveClass(/active/);
await expect.poll(async () => {
const snap = await (await page.request.get('/api/workspace')).json();
return snap.activeProjectId;
}).toBe(projectId);
return projectId;
}
async function uploadViaApi(
page: Page,
projectId: string,
files: { name: string; text: string }[],
): Promise<void> {
const form = new FormData();
for (const file of files) {
form.append(
'files',
new Blob([Buffer.from(minimalDxf(file.text), 'utf8')], { type: 'image/vnd.dxf' }),
file.name,
);
}
const resp = await page.request.post(`/api/projects/${projectId}/files/upload`, {
multipart: form,
});
expect(resp.ok()).toBeTruthy();
}
async function openDrawingPanel(page: Page): Promise<void> {
await page.locator('.right-rail button[data-tip="工程图纸"]').click();
await expect(page.getByText('工程图纸工作区')).toBeVisible({ timeout: 30_000 });
}
test.describe('工程图纸 DXF 链路', () => {
test.skip(SKIP_DRAWING, 'APS_E2E_SKIP_DRAWING=1:跳过图纸 E2E');
test('浏览器上传 DXF → 自动解析 → 候选块出现', async ({ page }) => {
await login(page);
const projectId = await createProjectViaUi(page, `E2E 图纸上传 ${Date.now()}`);
await openDrawingPanel(page);
await expect(page.locator('button[aria-label="全屏查看图纸"]')).toBeVisible();
const batchRespPromise = page.waitForResponse(
resp => resp.url().includes('/api/drawings/inspect-batch'),
);
await page.locator('.drawing-upload-input').setInputFiles({
name: 'E2E-001-a.dxf',
mimeType: 'image/vnd.dxf',
buffer: Buffer.from(minimalDxf('E2E-DXF-ONE'), 'utf8'),
});
const batchResp = await batchRespPromise;
expect(batchResp.ok()).toBeTruthy();
await expect(page.locator('.drawing-list-item', { hasText: 'E2E-001' })).toBeVisible({ timeout: 60_000 });
await expect(page.locator('.ant-tabs-tab', { hasText: '物料 1' }).first()).toBeVisible({ timeout: 30_000 });
const listResp = await page.request.get(`/api/projects/${projectId}/drawings`);
expect(listResp.ok()).toBeTruthy();
const drawings = (await listResp.json()).drawings;
expect(drawings.some(row => row.fileName === 'E2E-001-a.dxf' && row.status === 'PARSED')).toBeTruthy();
});
test('浏览器上传 PDF → 自动解析 → 候选块出现', async ({ page }) => {
await login(page);
const projectId = await createProjectViaUi(page, `E2E PDF 上传 ${Date.now()}`);
await openDrawingPanel(page);
const batchRespPromise = page.waitForResponse(
resp => resp.url().includes('/api/drawings/inspect-batch'),
);
await page.locator('.drawing-upload-input').setInputFiles({
name: 'PDF-001-a.pdf',
mimeType: 'application/pdf',
buffer: minimalPdf('PDF-001-a\nMATERIAL: STEEL'),
});
const batchResp = await batchRespPromise;
expect(batchResp.ok()).toBeTruthy();
await expect(page.locator('.drawing-list-item', { hasText: 'PDF-001' })).toBeVisible({ timeout: 60_000 });
const listResp = await page.request.get(`/api/projects/${projectId}/drawings`);
expect(listResp.ok()).toBeTruthy();
const drawings = (await listResp.json()).drawings;
expect(drawings.some(row => row.fileName === 'PDF-001-a.pdf' && row.status === 'PARSED')).toBeTruthy();
});
test('项目文件批量解析 → P2 暂存 → 确认落库', async ({ page }) => {
await login(page);
const projectId = await createProjectViaUi(page, `E2E 图纸批量确认 ${Date.now()}`);
const first = 'E2E-001-a.dxf';
const second = 'E2E-002-a.dxf';
await uploadViaApi(page, projectId, [
{ name: first, text: 'E2E-DXF-ONE' },
{ name: second, text: 'E2E-DXF-TWO' },
]);
await openDrawingPanel(page);
await page.locator('.drawing-file-select').click();
for (const name of [first, second]) {
await expect(page.locator('.ant-select-dropdown .ant-select-item-option', { hasText: name }).first()).toBeVisible();
}
await page.keyboard.press('Escape');
const batchRespPromise = page.waitForResponse(
resp => resp.url().includes('/api/drawings/inspect-batch'),
);
await page.locator('.drawing-parse-all').click();
const batchResp = await batchRespPromise;
expect(batchResp.ok()).toBeTruthy();
await expect(page.locator('.drawing-list-item', { hasText: 'E2E-001' })).toBeVisible({ timeout: 60_000 });
await expect(page.locator('.drawing-list-item', { hasText: 'E2E-002' })).toBeVisible({ timeout: 30_000 });
await page.locator('.ant-tabs-tab', { hasText: '物料 1' }).first().click();
const stageRespPromise = page.waitForResponse(
resp => {
const path = new URL(resp.url()).pathname;
return path.includes('/api/drawings/') && path.endsWith('/stage');
},
);
await page.locator('.ant-table-tbody .ant-checkbox-input').first().check();
await page.locator('button:has-text("P2 暂存")').first().click();
const stageResp = await stageRespPromise;
expect(stageResp.ok()).toBeTruthy();
const staged = await stageResp.json();
expect(staged.confirmId).toBeTruthy();
expect(staged.masterCommitted).toBe(false);
const confirmResp = await page.request.post('/api/actions/confirm', {
data: { confirmId: staged.confirmId, approve: true },
});
expect(confirmResp.ok()).toBeTruthy();
const confirmBody = await confirmResp.json();
expect(confirmBody.message).toContain('Drawing review applied');
const listResp = await page.request.get(`/api/projects/${projectId}/drawings`);
expect(listResp.ok()).toBeTruthy();
const drawings = (await listResp.json()).drawings;
expect(drawings.some(row => row.fileName === first && row.status === 'COMMITTED')).toBeTruthy();
});
});