aps-agent/apps/web/e2e/planner-masterdata-round87....

287 lines
18 KiB
TypeScript

import { expect, test, type Page, type TestInfo } from '@playwright/test';
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Explicit opt-in to the exact source; ordinary E2E suites do not need a local
// customer workbook. The authorized Round 87 run MUST set ROUND87_SOURCE.
const SOURCE = process.env.ROUND87_SOURCE;
const API_TARGET = process.env.E2E_API_TARGET || 'http://127.0.0.1:18787';
const REQUIRED = process.env.ROUND87_REQUIRED === '1';
const expectationPath = process.env.ROUND87_EXPECTATIONS
|| fileURLToPath(new URL('../../../tests/fixtures/planning-workbook-acceptance.json', import.meta.url));
const EXPECTED = JSON.parse(readFileSync(expectationPath, 'utf-8'));
if (EXPECTED.schemaVersion !== 1 || typeof EXPECTED.sourceSha256 !== 'string') {
throw new Error('Invalid independent workbook acceptance manifest');
}
const SOURCE_HASH = EXPECTED.sourceSha256;
const apiUrl = new URL(API_TARGET);
if (!['127.0.0.1', 'localhost', '[::1]'].includes(apiUrl.hostname)
|| apiUrl.protocol !== 'http:' || !apiUrl.port
|| ['8000', '8003', '5173'].includes(apiUrl.port)) {
throw new Error('Round 87 browser acceptance requires an isolated loopback backend; live ports are refused.');
}
type Json = Record<string, any>;
async function browserApi(page: Page, apiPath: string): Promise<Json> {
const response = await page.request.get(apiPath);
expect(response.ok(), `GET ${apiPath}: ${await response.text()}`).toBeTruthy();
return await response.json() as Json;
}
async function checkIsolation(page: Page): Promise<void> {
const direct = await page.request.get(`${API_TARGET}/__e2e__/health`);
expect(direct.ok(), 'start tests/e2e/round87_masterdata_server.py first').toBeTruthy();
const backend = await direct.json() as Json;
expect(backend).toMatchObject({ isolated: true, sourceSha256: SOURCE_HASH,
sourceUnchanged: true, productionCode: true });
// This alias is implemented by the isolated test host, never by production.
// It proves Vite is pointing at the same host BEFORE any login or UI writes.
const proxied = await page.request.get('/api/__e2e__/health');
expect(proxied.ok(), 'frontend proxy must target the isolated Round 87 host').toBeTruthy();
const frontendBackend = await proxied.json() as Json;
expect(frontendBackend).toMatchObject({ isolated: true, sourceSha256: SOURCE_HASH,
sourceUnchanged: true, runtimeRoot: backend.runtimeRoot });
}
async function assertNoHorizontalOverflow(page: Page, context: string): Promise<void> {
const widths = await page.evaluate(() => ({
viewport: window.innerWidth,
document: document.documentElement.scrollWidth,
body: document.body.scrollWidth,
}));
expect(widths.document, `${context}: document spills horizontally`).toBeLessThanOrEqual(widths.viewport + 1);
expect(widths.body, `${context}: body spills horizontally`).toBeLessThanOrEqual(widths.viewport + 1);
}
async function screenshot(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const output = path.join(mkdtempSync(path.join(os.tmpdir(), 'aps-round87-ui-')), `${name}.png`);
await page.screenshot({ path: output, fullPage: true });
await testInfo.attach(name, { path: output, contentType: 'image/png' });
}
async function createProjectThroughUi(page: Page, mobile: boolean, projectName: string): Promise<string> {
if (mobile) await page.locator('.app-menu-icon[title="展开侧栏"]').click();
await page.locator('.workspace-sidebar:not(.guest-sidebar) button[title="新建项目"]').click();
const dialog = page.getByRole('dialog', { name: '新建项目', exact: true });
await expect(dialog).toBeVisible();
await dialog.locator('input').first().fill(projectName);
// No source directory: the file must reach this project through the browser.
await expect(dialog.locator('input').nth(1)).toHaveValue('');
const createdResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/projects' && response.request().method() === 'POST');
await dialog.getByRole('button', { name: '创建', exact: true }).click();
const created = await createdResponse;
expect(created.ok(), await created.text()).toBeTruthy();
const body = await created.json() as Json;
const projectId = body.project.id as string;
expect(body.project.name).toBe(projectName);
await expect(dialog).toBeHidden();
await expect.poll(async () => (await browserApi(page, '/api/workspace')).activeProjectId).toBe(projectId);
if (mobile) {
const backdrop = page.getByRole('button', { name: '关闭侧栏', exact: true });
if (await backdrop.isVisible()) {
const box = await backdrop.boundingBox();
expect(box).not.toBeNull();
// Backdrop covers the viewport; its centre is under the open sidebar on
// small screens. Click the actually exposed edge, as a user would.
await backdrop.click({ position: { x: box!.width - 8, y: Math.min(100, box!.height - 8) } });
}
}
await expect(page.locator('.composer textarea')).toBeVisible();
return projectId;
}
async function assertAdoptedData(page: Page): Promise<Json> {
const master = await browserApi(page, '/api/master');
const flex = await browserApi(page, '/api/flex/world');
expect(master.materials).toHaveLength(EXPECTED.entityCounts.materials);
expect(master.equipmentOptions).toHaveLength(EXPECTED.entityCounts.equipment);
expect(master.workstations.length).toBeGreaterThan(0);
expect(master.equipment).toHaveLength(EXPECTED.entityCounts.equipment);
expect(Array.isArray(master.calendarTemplates)).toBeTruthy();
expect(Array.isArray(master.calendarHolidays)).toBeTruthy();
expect(Array.isArray(master.masterdataVersions)).toBeTruthy();
expect(master.routings.reduce((n: number, route: Json) => n + route.steps.length, 0)).toBe(EXPECTED.entityCounts.routing);
expect(flex.materials).toHaveLength(EXPECTED.entityCounts.materials);
expect(flex.equipment).toHaveLength(EXPECTED.entityCounts.equipment);
expect(flex.orders.map((row: Json) => row.orderNo).sort()).toEqual([...EXPECTED.formalOrderNos].sort());
expect(flex.materials.reduce((n: number, row: Json) => n + Number(row.inTransit || 0), 0)).toBe(EXPECTED.inventory.inTransit);
expect(flex.latestVersion).toBeNull(); // Adopting data must not silently schedule it.
return master;
}
for (const viewport of [{ name: 'desktop', width: 1440, height: 900 }, { name: 'mobile', width: 390, height: 844 }]) {
test.describe(`Round 87 planner master data (${viewport.name})`, () => {
test.use({ viewport: { width: viewport.width, height: viewport.height } });
test.skip(!SOURCE && !REQUIRED, 'Set ROUND87_SOURCE to the explicitly authorized original workbook.');
test('upload full workbook, review/adopt, reload, edit imported routing and verify flexible input', async ({ page }, testInfo) => {
test.setTimeout(300_000);
page.setDefaultTimeout(25_000);
expect(SOURCE, 'ROUND87_SOURCE is required for explicit acceptance').toBeTruthy();
const raw = readFileSync(SOURCE!);
expect(createHash('sha256').update(raw).digest('hex')).toBe(SOURCE_HASH);
await checkIsolation(page);
const login = await page.request.post('/api/auth/login', {
data: { tenantName: 'round87-masterdata-acceptance', username: 'planner', password: 'test' },
});
expect(login.ok(), await login.text()).toBeTruthy();
const errors: string[] = [];
page.on('pageerror', error => errors.push(`pageerror: ${error.message}`));
page.on('console', message => {
if (message.type() === 'error') errors.push(`console: ${message.text()}`);
});
page.on('response', response => {
const responsePath = new URL(response.url()).pathname;
if (response.status() >= 500 && responsePath.startsWith('/api/')) {
errors.push(`HTTP ${response.status()}: ${response.url()}`);
}
if (response.status() === 401) errors.push(`HTTP 401: ${response.url()}`);
if (responsePath.startsWith('/api/masterdata')) {
errors.push(`legacy masterdata request: ${response.url()}`);
}
});
try {
await page.goto('/');
await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 });
const mobile = viewport.width < 600;
const projectName = `${EXPECTED.browser.projectNamePrefix}-${viewport.name}-${Date.now()}`;
const projectId = await createProjectThroughUi(page, mobile, projectName);
const empty = await browserApi(page, '/api/master');
expect(empty.materials).toHaveLength(0);
await page.locator('.composer-file-input').setInputFiles(SOURCE!);
await expect(page.locator('.material-strip')).toContainText(path.basename(SOURCE!));
await page.locator('.composer textarea').fill('分析一下数据文件');
const uploadResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === `/api/projects/${projectId}/files/upload`
&& response.request().method() === 'POST');
await page.locator('.composer button.send').click();
const uploaded = await uploadResponse;
expect(uploaded.ok(), await uploaded.text()).toBeTruthy();
const upload = await uploaded.json() as Json;
expect(upload.errors || []).toHaveLength(0);
expect(upload.saved).toHaveLength(1);
const card = page.locator('.planning-data-card').filter({ hasText: '排产资料核对' }).last();
await expect(card).toBeVisible({ timeout: 90_000 });
const metrics = card.locator('.planning-data-counts');
await expect(metrics.getByRole('button', { name: '查看产品和物料明细' }).locator('strong')).toHaveText(String(EXPECTED.entityCounts.materials));
await expect(metrics.getByRole('button', { name: '查看订单记录明细' }).locator('strong')).toHaveText(String(EXPECTED.entityCounts.orders));
await expect(metrics.getByRole('button', { name: '查看加工步骤明细' }).locator('strong')).toHaveText(String(EXPECTED.entityCounts.routing));
await expect(metrics.getByRole('button', { name: '查看设备记录明细' }).locator('strong')).toHaveText(String(EXPECTED.entityCounts.equipment));
// 首屏只给结论、关键数和前三条关注项,其余明细在折叠层。
await expect(card.locator('.planning-data-conclusion')).toHaveText('资料已读取,确认采用后才会写入项目主数据。');
await expect(card).toContainText(EXPECTED.browser.missingSkillLabel);
await expect(card).toContainText('尚未写入主数据');
await expect(card.getByRole('button', { name: '确认数据,生成方案' })).toHaveCount(0);
await expect(card.getByRole('button', { name: '开始排产' })).toBeDisabled();
await expect(page.locator('.planning-progress')).toContainText('等待你确认');
await expect(page.locator('.chips')).toHaveCount(0);
expect((await browserApi(page, '/api/master')).materials).toHaveLength(0);
await card.getByText('查看文件、订单与检查明细', { exact: true }).click();
await expect(card).toContainText(`人员 ${EXPECTED.entityCounts.personnel} 名`);
await expect(card).toContainText(`在制任务 ${EXPECTED.entityCounts.wip} 条`);
await expect(card).toContainText(`待评估插单 ${EXPECTED.entityCounts.sandboxOrders} 张`);
await expect(card).toContainText(EXPECTED.unknownWip.equipmentCode);
await expect(card).toContainText(SOURCE_HASH);
await expect(card.locator('.planning-file li')).toHaveCount(Object.keys(EXPECTED.sheets).length);
await card.getByText('查看文件、订单与检查明细', { exact: true }).click();
await card.scrollIntoViewIfNeeded();
const cardBox = await card.boundingBox();
expect(cardBox).not.toBeNull();
expect(cardBox!.x).toBeGreaterThanOrEqual(-1);
expect(cardBox!.x + cardBox!.width).toBeLessThanOrEqual(viewport.width + 1);
await assertNoHorizontalOverflow(page, 'workbook review');
await screenshot(page, testInfo, `${viewport.name}-review`);
const adoptionResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/actions/confirm' && response.request().method() === 'POST');
await page.getByRole('button', { name: '确认采用资料', exact: true }).last().click();
const adopted = await adoptionResponse;
expect(adopted.ok(), await adopted.text()).toBeTruthy();
expect((await adopted.json() as Json).refresh).toBe(true);
await expect.poll(async () => (await browserApi(page, '/api/master')).materials.length).toBe(EXPECTED.entityCounts.materials);
await page.reload();
await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 });
const master = await assertAdoptedData(page);
const route = master.routings.find((row: Json) => row.productCode === EXPECTED.browser.productCode) as Json;
expect(route).toBeTruthy();
const step = route.steps.find((row: Json) => row.sequenceNo === EXPECTED.browser.stepSeq) as Json;
const initialTimes = await browserApi(page, '/api/times');
const initialTime = initialTimes.rows.find((row: Json) => row.productCode === route.productCode && row.seq === EXPECTED.browser.stepSeq);
expect(initialTime.stdMin).toBe(EXPECTED.browser.initialMinutes);
const masterButtons = page.locator('.right-rail:not(.guest-right-rail) button[data-tip="主数据"]');
await expect(masterButtons).toHaveCount(1);
await expect(page.locator('.right-rail:not(.guest-right-rail) button[data-tip="主数据维护"]')).toHaveCount(0);
await masterButtons.click();
const panel = page.locator('.master-panel');
await expect(panel).toBeVisible();
await expect(panel.locator('.panel-head')).toContainText(`${EXPECTED.entityCounts.materials} 物料`);
await panel.getByRole('tab', { name: '工位·版本·日历', exact: true }).click();
await expect(panel.getByTestId('station-version-calendar')).toBeVisible();
await expect(panel.getByRole('tab', { name: '工位/设备', exact: true })).toBeVisible();
await expect(panel.getByRole('tab', { name: '版本发布/回滚', exact: true })).toBeVisible();
await expect(panel.getByRole('tab', { name: '班次日历', exact: true })).toBeVisible();
await expect(panel.getByTestId('master-station-list').locator('tbody tr')).not.toHaveCount(0);
await expect(panel.getByTestId('master-equipment-list').locator('tbody tr')).not.toHaveCount(0);
await assertNoHorizontalOverflow(page, 'merged master data panel');
await panel.getByRole('tab', { name: '工艺模型', exact: true }).click();
await panel.getByRole('tab', { name: '工艺路线', exact: true }).click();
await panel.locator('tr[data-row-key]').filter({ has: page.getByRole('cell', { name: route.productName, exact: true }) }).first().click();
const stepRow = panel.locator('tr[data-row-key]').filter({
has: page.getByRole('cell', { name: step.operationName, exact: true }),
}).filter({ has: page.getByRole('button', { name: '编辑', exact: true }) }).first();
await stepRow.getByRole('button', { name: '编辑', exact: true }).click();
const drawer = page.getByRole('dialog').filter({ hasText: '编辑步骤' });
await expect(drawer).toBeVisible();
const newMinutes = viewport.name === 'desktop' ? 7.25 : 8.5;
await drawer.locator('.ant-form-item').filter({ hasText: '单件(分)' }).getByRole('spinbutton').fill(String(newMinutes));
const stagedResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/master/stage' && response.request().method() === 'POST');
await drawer.getByRole('button', { name: '保存并确认', exact: true }).click();
const staged = await stagedResponse;
expect(staged.ok(), await staged.text()).toBeTruthy();
expect(staged.request().postDataJSON()).toMatchObject({ action: 'master.routing.upsert',
payload: { stepId: step.id, runTimePerUnit: newMinutes } });
expect((await browserApi(page, '/api/times')).rows.find((row: Json) =>
row.productCode === route.productCode && row.seq === EXPECTED.browser.stepSeq).stdMin).toBe(EXPECTED.browser.initialMinutes);
const modal = page.getByRole('dialog').filter({ has: page.getByRole('button', { name: '批准执行', exact: true }) });
await expect(modal).toBeVisible();
await assertNoHorizontalOverflow(page, 'routing change confirmation');
await screenshot(page, testInfo, `${viewport.name}-routing-confirm`);
const confirmedResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/actions/confirm' && response.request().method() === 'POST');
await modal.getByRole('button', { name: '批准执行', exact: true }).click();
const confirmed = await confirmedResponse;
expect(confirmed.ok(), await confirmed.text()).toBeTruthy();
expect((await confirmed.json() as Json).refresh).toBe(true);
await expect(modal).toBeHidden();
await expect.poll(async () => (await browserApi(page, '/api/times')).rows.find((row: Json) =>
row.productCode === route.productCode && row.seq === EXPECTED.browser.stepSeq).stdMin).toBe(newMinutes);
await page.reload();
await expect(page.locator('.composer textarea')).toBeVisible({ timeout: 60_000 });
const restored = await assertAdoptedData(page);
const restoredStep = restored.routings.find((row: Json) => row.id === route.id).steps.find((row: Json) => row.id === step.id);
expect(restoredStep.runTimePerUnit).toBe(newMinutes);
expect((await browserApi(page, '/api/times')).rows.find((row: Json) =>
row.productCode === route.productCode && row.seq === EXPECTED.browser.stepSeq).stdMin).toBe(newMinutes);
await assertNoHorizontalOverflow(page, 'restored planner workspace');
await screenshot(page, testInfo, `${viewport.name}-restored`);
await checkIsolation(page);
expect(createHash('sha256').update(readFileSync(SOURCE!)).digest('hex')).toBe(SOURCE_HASH);
expect(errors, 'browser errors collected during real business interactions').toEqual([]);
} finally {
await testInfo.attach('browser-errors', { body: JSON.stringify(errors, null, 2), contentType: 'application/json' });
}
});
});
}