// ============================================================ // 工程图纸 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 { 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 { 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 { 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 { 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(); }); });