212 lines
9.9 KiB
TypeScript
212 lines
9.9 KiB
TypeScript
// ============================================================
|
||
// 计划员七个视口的真实数据 E2E(离线桩:payload 来自现场 world.json)
|
||
//
|
||
// 现场截图反馈(2026-09-16):
|
||
// 1 已排产但「设备负荷」整屏"休" / 2 已排产甘特报"尚无排产版本"
|
||
// 3 方案对比"加载失败" / 4 版本对比全是内部字段名
|
||
// 5 绩效指标口径不清 / 6 时段计划"日产能 0 件"却显示超载
|
||
// 7 资源利用率整屏 0%
|
||
//
|
||
// 桩数据不是手写的:由 scripts/gen_planner_e2e_fixtures.py 调用同一批域函数
|
||
// 从现场 world.json 生成(只读,不写回项目)。未设置 E2E_PLANNER_FIXTURES 时整组跳过。
|
||
// ============================================================
|
||
import { test, expect, type Page } from '@playwright/test';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
|
||
const FIX_DIR = process.env.E2E_PLANNER_FIXTURES || '';
|
||
const SHOT_DIR = process.env.E2E_SHOT_DIR || os.tmpdir();
|
||
|
||
test.skip(!FIX_DIR, 'set E2E_PLANNER_FIXTURES to a directory of real payload fixtures');
|
||
|
||
function fix(name: string): Record<string, unknown> {
|
||
return JSON.parse(fs.readFileSync(path.join(FIX_DIR, name), 'utf8')) as Record<string, unknown>;
|
||
}
|
||
|
||
const json = (body: unknown, status = 200) => ({
|
||
status, contentType: 'application/json', body: JSON.stringify(body),
|
||
});
|
||
|
||
async function stubApi(page: Page): Promise<void> {
|
||
await page.route((url: URL) => url.pathname.startsWith('/api/'), route => {
|
||
const path = new URL(route.request().url()).pathname;
|
||
switch (path) {
|
||
case '/api/health':
|
||
return route.fulfill(json({ interfaceVersion: '1.0', authProvider: 'local', licenseProvider: 'local' }));
|
||
case '/api/auth/me':
|
||
return route.fulfill(json({
|
||
user: {
|
||
user_id: 'user-1', username: 'e2e', fullname: 'E2E', tenant_uuid: 'tenant-1',
|
||
roles: ['admin'], auth_kind: 'user',
|
||
},
|
||
provider: 'local',
|
||
license: null,
|
||
}));
|
||
case '/api/features':
|
||
return route.fulfill(json({ version: 1, source: 'default', path: '', error: null, unknown: [], features: {} }));
|
||
case '/api/workspace':
|
||
return route.fulfill(json({
|
||
projects: [{ id: 'proj_real', name: '现场项目' }],
|
||
sessions: [{
|
||
id: 'session-real', projectId: 'proj_real', title: '现场排产', status: 'running',
|
||
updatedAt: '2026-09-16 10:00:00', scope: 'project',
|
||
}],
|
||
files: [], messages: { 'session-real': [] },
|
||
activeProjectId: 'proj_real', activeSessionId: 'session-real',
|
||
}));
|
||
case '/api/projects':
|
||
return route.fulfill(json({ projects: [{ id: 'proj_real', name: '现场项目' }] }));
|
||
case '/api/skills':
|
||
return route.fulfill(json({ skills: [], skillsDir: '', mode: 'web' }));
|
||
case '/api/skills/health':
|
||
return route.fulfill(json({ health: [] }));
|
||
case '/api/world/summary':
|
||
return route.fulfill(json(fix('world-summary.json')));
|
||
case '/api/world/gantt':
|
||
return route.fulfill(json(fix('world-gantt.json')));
|
||
case '/api/world/load':
|
||
return route.fulfill(json(fix('world-load.json')));
|
||
case '/api/world/due':
|
||
return route.fulfill(json(fix('world-due.json')));
|
||
case '/api/flex/gantt':
|
||
return route.fulfill(json(fix('flex-gantt.json')));
|
||
case '/api/flex/capacity':
|
||
return route.fulfill(json(fix('flex-capacity.json')));
|
||
case '/api/flex/world':
|
||
return route.fulfill(json(fix('flex-world.json')));
|
||
case '/api/analytics/kpi':
|
||
return route.fulfill(json(fix('analytics-kpi.json')));
|
||
case '/api/analytics/utilization':
|
||
return route.fulfill(json(fix('analytics-util.json')));
|
||
case '/api/analytics/compare':
|
||
return route.fulfill(json(fix('analytics-compare.json')));
|
||
case '/api/plan/buckets':
|
||
return route.fulfill(json(fix('plan-buckets.json')));
|
||
case '/api/timeline':
|
||
return route.fulfill(json(fix('timeline.json')));
|
||
default:
|
||
return route.fulfill(json({ detail: 'e2e stub' }, 404));
|
||
}
|
||
});
|
||
}
|
||
|
||
/** 打开排产结果视口(真实 payload 里只有柔性试排版本) */
|
||
async function openViewport(page: Page): Promise<void> {
|
||
await page.goto('/');
|
||
const resultButton = page.locator('.right-rail button[data-tip="排产结果"]');
|
||
await expect(resultButton).toBeEnabled({ timeout: 30_000 });
|
||
await resultButton.click();
|
||
await expect(page.locator('.vp-toolbar')).toBeVisible({ timeout: 30_000 });
|
||
}
|
||
|
||
/** 从「更多分析」菜单切换二级视图 */
|
||
async function moreAnalysis(page: Page, label: string): Promise<void> {
|
||
await page.locator('.vp-more > summary').click();
|
||
await page.locator('.vp-more-menu button').filter({ hasText: label }).first().click();
|
||
}
|
||
|
||
test('设备负荷:反映柔性试排的真实占用,不再整屏"休"', async ({ page }) => {
|
||
await stubApi(page);
|
||
await openViewport(page);
|
||
await page.locator('.seg .tab', { hasText: '设备负荷' }).click();
|
||
|
||
const area = page.locator('.vp-area');
|
||
// 数据来源写清楚,计划员知道看的是哪一版
|
||
await expect(area).toContainText('柔性试排 FV20261004-001');
|
||
await expect(area).toContainText('未下发车间');
|
||
|
||
// 真实设备行 + 真实负荷(现场:数控折弯机 10-05 占用 373/432 分钟 = 86%)
|
||
const flexRow = area.locator('table.grid tbody tr', { hasText: '数控折弯机' }).first();
|
||
await expect(flexRow).toBeVisible();
|
||
await expect(flexRow).toContainText('86%');
|
||
await expect(area.locator('table.grid tbody tr').first()).not.toContainText('尚无排产');
|
||
|
||
// 列头跟着排产窗口走,而不是从今天开始的两周空窗
|
||
await expect(area.locator('thead')).toContainText('10-05');
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'issue1-load.png'), fullPage: false });
|
||
});
|
||
|
||
test('排产甘特图:有柔性试排时显示真实计划,不再报"尚无排产版本"', async ({ page }) => {
|
||
await stubApi(page);
|
||
await openViewport(page);
|
||
const area = page.locator('.vp-area');
|
||
await expect(area).not.toContainText('尚无排产版本');
|
||
await expect(area).toContainText('FV20261004-001');
|
||
await expect(area).toContainText('数控折弯机');
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'issue2-gantt.png'), fullPage: false });
|
||
});
|
||
|
||
test('排序方案对比:不再"加载失败",缺固定产线数据时说明原因', async ({ page }) => {
|
||
await stubApi(page);
|
||
await openViewport(page);
|
||
await moreAnalysis(page, '排序方案对比');
|
||
const area = page.locator('.vp-area');
|
||
await expect(area).not.toContainText('方案对比加载失败');
|
||
await expect(area).toContainText('排序方案对比');
|
||
// 固定产线没有数据 → 如实说明,不编一排 0
|
||
await expect(area).toContainText('还没有固定产线');
|
||
await expect(area).toContainText('柔性排产');
|
||
// 试排口径写清楚:按真实交期,不是压缩交期的压力测试结果
|
||
await expect(area).toContainText('按订单真实交期试排');
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'issue3-compare.png'), fullPage: false });
|
||
});
|
||
|
||
test('排产版本对比:只有计划员看得懂的说法,不出现内部字段名', async ({ page }) => {
|
||
await stubApi(page);
|
||
await openViewport(page);
|
||
await moreAnalysis(page, '排产版本对比');
|
||
const area = page.locator('.vp-area');
|
||
await expect(area).toContainText('排产版本对比');
|
||
await expect(area).toContainText('排序方式');
|
||
await expect(area).toContainText('交期从早到晚');
|
||
await expect(area).toContainText('柔性试排');
|
||
const text = await area.innerText();
|
||
for (const jargon of ['ASC', 'vlCount', '总延误(h)', '产能池数', '瓶颈日产能', 'DRAFT', '柔性版本']) {
|
||
expect(text, `版本对比不应出现内部字段名 ${jargon}`).not.toContain(jargon);
|
||
}
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'issue4-diff.png'), fullPage: false });
|
||
});
|
||
|
||
test('绩效指标:标出数据来源与口径,没数据的写没有', async ({ page }) => {
|
||
await stubApi(page);
|
||
await openViewport(page);
|
||
await moreAnalysis(page, '交付与绩效');
|
||
const area = page.locator('.vp-area');
|
||
await expect(area).toContainText('数据来源:柔性试排 FV20261004-001');
|
||
await expect(area).toContainText('设备实际占用时间');
|
||
await expect(area).toContainText('能在客户交期前完工的订单占比');
|
||
// 没有车间回传时不能编完工率
|
||
await expect(area).toContainText('还没有工序下发到车间');
|
||
await expect(area).not.toContainText('固定轨');
|
||
await expect(area).not.toContainText('MES 完工率');
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'issue5-kpi.png'), fullPage: false });
|
||
});
|
||
|
||
test('时段计划:日产能按登记口径显示,负荷与状态不再自相矛盾', async ({ page }) => {
|
||
await stubApi(page);
|
||
await openViewport(page);
|
||
await moreAnalysis(page, '时段计划');
|
||
const area = page.locator('.vp-area');
|
||
await expect(area).toContainText('日产能 456');
|
||
await expect(area).toContainText('柔性能力池瓶颈工序');
|
||
await expect(area).toContainText('总负荷');
|
||
const text = await area.innerText();
|
||
expect(text).not.toContain('日产能 0 件');
|
||
expect(text).not.toContain('产能未登记');
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'issue6-plan.png'), fullPage: false });
|
||
});
|
||
|
||
test('设备与班组负荷:占用来自真实排产窗口,不是整屏 0%', async ({ page }) => {
|
||
await stubApi(page);
|
||
await openViewport(page);
|
||
await moreAnalysis(page, '设备与班组负荷');
|
||
const area = page.locator('.vp-area');
|
||
await expect(area).toContainText('数据来源:柔性试排 FV20261004-001');
|
||
await expect(area).toContainText('平均负荷');
|
||
await expect(area.locator('table.grid tbody tr').first()).toBeVisible();
|
||
const text = await area.innerText();
|
||
expect(text).not.toContain('均值 0%');
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'issue7-util.png'), fullPage: false });
|
||
});
|