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

212 lines
9.9 KiB
TypeScript
Raw Permalink 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.

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