// ============================================================ // 约束规则库(设置 → 约束配置)离线 E2E // 计划员反馈(2026-09-17):约束配置要有默认通用规则,后面靠对话里的一句话 // 不断扩充,并按类别 / 按项目归档;没接入排产引擎的规则必须如实标注。 // 全部离线:/api/** 一律本地桩,不连现场后端,不写任何真实数据。 // ============================================================ import { test, expect, type Page } from '@playwright/test'; import os from 'node:os'; import path from 'node:path'; const SHOT_DIR = process.env.E2E_SHOT_DIR || os.tmpdir(); const json = (body: unknown, status = 200) => ({ status, contentType: 'application/json', body: JSON.stringify(body), }); const PROFILE = { profileId: 'default', name: '默认约束剖面', constraints: [ { id: 'C6_material_kit', code: 'C6', name: '物料齐套', group: 'material', kind: 'soft', enabled: true, configurable: true, weight: 0.8, description: '缺料:硬=阻断发布;软=带风险排入;关闭=不检查(硬/软可配)', }, { id: 'C8_due_date', code: 'C8', name: '订单交期', group: 'order', kind: 'soft', enabled: true, configurable: true, weight: 0.6, description: '延期进冲突与 KPI,默认不拦发布', }, ], groups: [ { id: 'material', label: '物料', items: ['C6_material_kit'] }, { id: 'order', label: '订单', items: ['C8_due_date'] }, ], }; const BOUND_RULE = { id: 'R-001', name: '缺料必须挡住发布', category: 'process', categoryLabel: '工艺', scope: 'project', scopeLabel: '仅本项目', kindLabel: '必须满足', enabled: true, boundConstraintId: 'C6_material_kit', effect: { bound: true, enforced: true, text: '由内置规则《物料齐套》执行(已启用/硬约束)' }, }; const RECORD_ONLY_RULE = { id: 'R-002', name: '喷涂线每天最多八小时', category: 'capacity', categoryLabel: '产能', scope: 'tenant', scopeLabel: '全厂', kindLabel: '尽量满足', enabled: false, effect: { bound: false, enforced: false, text: '已记录,暂未接入排产引擎' }, }; const LIBRARY = { builtin: { total: 15, enabled: 14, configurable: 10 }, custom: [BOUND_RULE, RECORD_ONLY_RULE], groups: [ { id: 'process', label: '工艺', items: [BOUND_RULE] }, { id: 'capacity', label: '产能', items: [RECORD_ONLY_RULE] }, ], categories: [ { id: 'process', label: '工艺' }, { id: 'capacity', label: '产能' }, { id: 'order', label: '订单' }, ], scopes: [{ id: 'project', label: '仅本项目' }, { id: 'tenant', label: '全厂' }], summary: { builtinTotal: 15, customTotal: 2, engineBound: 1, recordOnly: 1 }, }; const SOP_ITEMS = [{ assetId: 'sop-changeover-1', title: '换线标准SOP', tags: ['换线'], effectCount: 3, compilable: true, summary: 'C10_changeover→开/soft;跨族换型默认60.0分;建议策略CHANGEOVER_MIN;C13_sop→开/soft', plannerSummary: '将换型顺序纳入排产控制;跨产品族换型预留 60 分钟;优先连续生产同一产品族', }]; 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: [], sessions: [{ id: 'session-1', projectId: '__personal__', title: 'E2E', status: 'running', updatedAt: '2026-09-17 10:00:00', scope: 'personal', }], files: [], messages: { 'session-1': [] }, activeProjectId: '__personal__', activeSessionId: 'session-1', })); case '/api/sessions/session-1/messages': return route.fulfill(json({ sessionId: 'session-1', messages: [] })); case '/api/skills': return route.fulfill(json({ skills: [], skillsDir: '', mode: 'web' })); case '/api/skills/health': return route.fulfill(json({ health: [] })); case '/api/constraints': return route.fulfill(json(PROFILE)); case '/api/constraints/library': return route.fulfill(json(LIBRARY)); case '/api/sop/compilable': return route.fulfill(json({ items: SOP_ITEMS, rulePacks: [] })); default: return route.fulfill(json({ detail: 'e2e stub' }, 404)); } }); } async function openSettingsSection(page: Page, label: string): Promise { await page.locator('.app-menubar > button').filter({ hasText: '设置' }).first().click(); await expect(page.locator('.gov-shell')).toBeVisible(); await page.locator('.gov-nav-item').filter({ hasText: label }).first().click(); } test('约束配置显示默认通用规则与已扩充规则,并如实标注是否接入引擎', async ({ page }) => { await stubApi(page); await page.goto('/'); await openSettingsSection(page, '约束配置'); const card = page.locator('.gov-card').filter({ hasText: '约束规则库' }).first(); await expect(card).toBeVisible(); await expect(card).toContainText('通用规则 15 项'); await expect(card).toContainText('扩展规则 2 项'); await expect(card).toContainText('已用于排产 1 项'); await expect(card).toContainText('暂未用于排产 1 项'); // 按类别归档 await expect(card).toContainText('工艺'); await expect(card).toContainText('产能'); // 按范围归档 + 一句计划员语言 await expect(card).toContainText('缺料必须挡住发布'); await expect(card).toContainText('仅本项目'); await expect(card).toContainText('由内置规则《物料齐套》执行'); await expect(card).toContainText('喷涂线每天最多八小时'); await expect(card).toContainText('全厂'); await expect(card).toContainText('已纳入规则库,暂未用于排产'); // 不给计划员看内部标识 / 配置语言 await expect(card).not.toContainText('C6_material_kit'); await expect(card).not.toContainText('JSON'); await expect(card).not.toContainText('G0'); const sopCard = page.locator('.gov-card').filter({ hasText: '作业标准规则' }).first(); await expect(sopCard).toBeVisible(); await expect(sopCard).toContainText('跨产品族换型预留 60 分钟'); await expect(sopCard).toContainText('应用到排产'); await expect(sopCard).not.toContainText('C10_changeover'); await expect(sopCard).not.toContainText('C13_sop'); await expect(sopCard).not.toContainText('soft'); // 停用状态如实回显 const recordOnlyRow = card.locator('.constraint-row').filter({ hasText: '喷涂线每天最多八小时' }); await expect(recordOnlyRow.locator('input[type=checkbox]')).not.toBeChecked(); await expect(recordOnlyRow).toContainText('已停用'); await page.screenshot({ path: path.join(SHOT_DIR, 'planner-constraint-rules.png'), fullPage: true }); await sopCard.scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(SHOT_DIR, 'planner-constraint-sop-rules.png'), fullPage: true }); }); test('启停扩充规则走 P2 确认卡,不直接改库', async ({ page }) => { await stubApi(page); const staged: Array> = []; await page.route('**/api/constraints/rules/stage', route => { staged.push(route.request().postDataJSON() as Record); return route.fulfill(json({ message: '启用约束规则:喷涂线每天最多八小时 已进入 P2 确认队列。', block: { blockId: 'blk-1', type: 'confirm-card', interfaceVersion: '1.0', props: { title: '启用约束规则:喷涂线每天最多八小时', power: 'P2', confirmId: 'confirm-1', summary: ['该操作只调整规则库里的状态,不改动内置约束的开关'], }, actions: [], evidenceRefs: [], }, })); }); await page.goto('/'); await openSettingsSection(page, '约束配置'); const card = page.locator('.gov-card').filter({ hasText: '约束规则库' }).first(); const recordOnlyRow = card.locator('.constraint-row').filter({ hasText: '喷涂线每天最多八小时' }); // 受控组件:勾选状态由服务端规则库决定,这里只发出暂存请求(不期待本地立刻勾上) await recordOnlyRow.locator('input[type=checkbox]').click(); await expect.poll(() => staged.length).toBe(1); expect(staged[0].payload).toMatchObject({ op: 'enable', ruleId: 'R-002' }); const confirm = page.locator('.order-confirm').first(); await expect(confirm).toBeVisible(); await expect(confirm).toContainText('启用约束规则:喷涂线每天最多八小时'); await expect(confirm).toContainText('需人工确认'); await expect(confirm).toContainText('该操作只调整规则库里的状态'); await page.screenshot({ path: path.join(SHOT_DIR, 'planner-constraint-rule-confirm.png'), fullPage: true }); }); test('计划员可提交新的约束要求,由主对话调用 Pi 生成确认项', async ({ page }) => { await stubApi(page); const commands: string[] = []; await page.route('**/api/chat', async route => { commands.push((route.request().postDataJSON() as { text: string }).text); return route.fulfill({ status: 200, contentType: 'text/event-stream', body: 'data: {"type":"done"}\n\n', }); }); await page.goto('/'); await openSettingsSection(page, '约束配置'); const card = page.locator('.gov-card').filter({ hasText: '约束规则库' }).first(); await card.getByLabel('约束要求').fill('关键客户订单延期前两个工作日提醒'); await card.locator('.constraint-form-field').filter({ hasText: '业务类别' }).locator('select').selectOption('order'); await card.locator('.constraint-form-field').filter({ hasText: '适用范围' }).locator('select').selectOption('tenant'); await card.locator('.constraint-form-field').filter({ hasText: '约束级别' }).locator('select').selectOption('hard'); await card.getByRole('button', { name: '提交约束要求' }).click(); await expect.poll(() => commands.length).toBe(1); expect(commands[0]).toContain('约束内容:关键客户订单延期前两个工作日提醒'); expect(commands[0]).toContain('业务类别:订单'); expect(commands[0]).toContain('适用范围:全厂'); expect(commands[0]).toContain('约束级别:必须满足'); await expect(page.locator('.gov-shell')).toBeHidden(); });