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

239 lines
11 KiB
TypeScript
Raw 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
// 计划员反馈(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();
});