// ============================================================ // 计划员七个视口的真实数据 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 { return JSON.parse(fs.readFileSync(path.join(FIX_DIR, name), 'utf8')) as Record; } const json = (body: unknown, status = 200) => ({ status, contentType: 'application/json', body: JSON.stringify(body), }); async function stubApi(page: Page): Promise { 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 { 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 { 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 }); });