aps-agent/apps/web/e2e/planner-constraint-rules.sp...

239 lines
11 KiB
TypeScript
Raw Permalink Normal View History

// ============================================================
// 约束规则库(设置 → 约束配置)离线 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<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: [],
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<void> {
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<Record<string, unknown>> = [];
await page.route('**/api/constraints/rules/stage', route => {
staged.push(route.request().postDataJSON() as Record<string, unknown>);
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();
});