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

249 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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