239 lines
11 KiB
TypeScript
239 lines
11 KiB
TypeScript
// ============================================================
|
||
// 约束规则库(设置 → 约束配置)离线 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();
|
||
});
|