aps-agent/apps/web/e2e/planner-real-data-views.spe...

212 lines
9.9 KiB
TypeScript
Raw Permalink Normal View History

// ============================================================
// 计划员七个视口的真实数据 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 });
});