This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/api/src/modules/process/__tests__/closure.test.ts
T
ChaiHaoandClaude Fable 5 281f4ef084 三面拆分⑬:流程包纯编排化 —— 编排与传递契约归包,实现方式归库,激活看双判据
模型(用户定):流程包只决定活动编排 + 活动传递的契约,不决定活动的实现方式;
节点只分活动点与审批点;图只定名称与顺序,属性在卡上填,链接好实现即可运行;
激活 = ①Mastra 式图编排闭合完善 + ②活动装载,两面判据。

schema(库经外科手术清空,无历史包袱,网关彻底出局):
- contracts:删 gateway 节点变体 / GATEWAY_KINDS / GATEWAY_KIND_LABEL /
  'gateway-deprecated' 违规项 —— 节点四种:start/end(结构件)+ activity/approval
- routing:joinReady 整个删除(多入边即异或汇合,没有「等回齐」;AND 待并行组);
  selectEdges 精简;closure 删 checkGateway;process-service 删汇合等待与网关直传,
  节点一律只收装配后载荷(verdict/outcome 判据由系统注入,不走映射)

编排面(画布 = 名称 + 顺序,其余在属性卡):
- 元素盘五件:活动点(单一,不再分标准/案例 —— 那是实现方式)/审批点/起止/卡点
- 属性卡:节点类型二分(活动点/审批点);新增「链接活动实现」选择器(装载面,
  FlowNode.act 经 merge 成为语义 activityId);范式/版本降为只读装载信息
- 边属性可填:条件(S-FEEL)+ 映射(上游输出口/起单载荷/常量三种来源;
  workflow-var/expression 归系统隐性,不进设计者面)—— FlowEdge.condition/mapping
  工作副本,pkg-to-graph 回填、graph-to-pkg 合并,清空条件即变默认兜底边
- 异常看板 → 激活双判据看板:图编排(结构+属性+契约)与活动装载(unknown-activity)
  分面亮灯,两面清零激活才亮
- pkg-to-graph 去 paradigms 绿化与网关分支;图例/文案全线「活动点/审批点」

测试:closure 27(joinReady 两组 describe 随机制摘除)、advance-order 按真图形状重写
(去 as never 捏造的网关节点)、graph-to-pkg 9(新增 act 合并 + 边契约合并/清空用例)、
seed 8、端到端八例真库全绿;五闸退出码验过(api:check 提交后转绿)。

库:purge 后重种,唯一 unified-quote@v1 生效(10 节点 14 边,双面判据齐)。
债:M5-5 清账(兼容路径连 schema 一起摘);M5-4(并行组)仍挂。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-28 18:29:32 +08:00

327 lines
12 KiB
TypeScript

/** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */
import { describe, expect, it } from 'vitest';
import type { ProcessPackage } from '@contracts';
import { checkClosure } from '../internal/domain/closure.ts';
import type { ActivityContractFace } from '../internal/domain/closure.ts';
import {
aggregateWorkflowState,
isDigested,
nextInstanceNumber,
resolveOwner,
} from '../internal/domain/owner-resolution.ts';
import type { Candidate, NodeInstance } from '../internal/domain/owner-resolution.ts';
const face = (over: Partial<ActivityContractFace> = {}): ActivityContractFace => ({
activityId: 'a4',
requiredInputs: ['pn', 'basePrice'],
inputTypes: { pn: 'string', basePrice: 'number' },
outputs: { done: { pn: 'string', quote: 'number' } },
...over,
});
/** 统一报价的一小段:起点 → A4 成本定价 → 终点 */
const pkg = (over: Partial<ProcessPackage> = {}): ProcessPackage =>
({
id: 'quote' as never,
name: '统一报价',
version: 1,
state: 'active',
callInterface: { input: {}, output: {} },
nodes: [
{ id: 'start' as never, kind: 'start', label: '起点' },
{
id: 'A4' as never,
kind: 'activity',
label: 'A4 成本定价',
activityId: 'a4' as never,
owner: { position: 'BD', strategy: 'sole' },
failure: { kind: 'terminate' },
},
{ id: 'end' as never, kind: 'end', label: '终点' },
],
edges: [
{
id: 'e1',
from: 'start' as never,
to: 'A4' as never,
mapping: [
{ target: 'pn', source: { from: 'initiation', path: 'pn' } },
{ target: 'basePrice', source: { from: 'initiation', path: 'basePrice' } },
],
reopen: false,
},
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
],
variables: [],
...over,
}) as ProcessPackage;
const contracts = new Map([['a4', face()]]);
const positions = new Set(['BD', 'PM', 'FAE', 'PRM', 'OP']);
describe('checkClosure —— 不闭合不得激活', () => {
it('必填全映射、岗位存在即可激活', () => {
const report = checkClosure({ pkg: pkg(), contracts, positions });
expect(report.violations).toEqual([]);
expect(report.closed).toBe(true);
});
it('边未完成映射即拦', () => {
const broken = pkg({
edges: [
{ id: 'e1', from: 'start' as never, to: 'A4' as never, mapping: [], reopen: false },
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const report = checkClosure({ pkg: broken, contracts, positions });
expect(report.closed).toBe(false);
expect(report.violations.map((v) => v.kind)).toContain('edge-unmapped');
expect(report.violations.map((v) => v.kind)).toContain('required-unmapped');
});
it('必填未齐即拦', () => {
const partial = pkg({
edges: [
{
id: 'e1',
from: 'start' as never,
to: 'A4' as never,
mapping: [{ target: 'pn', source: { from: 'initiation', path: 'pn' } }],
reopen: false,
},
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: partial, contracts, positions }).violations;
expect(violations.filter((v) => v.kind === 'required-unmapped').map((v) => v.field)).toEqual(['basePrice']);
});
it('引用未安装的活动即拦', () => {
const report = checkClosure({ pkg: pkg(), contracts: new Map(), positions });
expect(report.violations.map((v) => v.kind)).toContain('unknown-activity');
});
it('岗位不存在即拦', () => {
const report = checkClosure({ pkg: pkg(), contracts, positions: new Set(['PM']) });
expect(report.violations.map((v) => v.kind)).toContain('missing-owner-position');
});
it('孤立节点即拦', () => {
const orphan = pkg({
nodes: [
...pkg().nodes,
{
id: 'A9' as never,
kind: 'activity',
label: '游离节点',
activityId: 'a4' as never,
owner: { position: 'BD', strategy: 'sole' },
failure: { kind: 'terminate' },
},
],
});
const violations = checkClosure({ pkg: orphan, contracts, positions }).violations;
expect(violations.filter((v) => v.kind === 'dangling-node').length).toBeGreaterThan(0);
});
it('多出边异或分支:两条无条件出边 = 都想当默认 —— multi-default-edge', () => {
const twoDefaults = pkg({
nodes: [...pkg().nodes, { id: 'end2' as never, kind: 'end', label: '另一终点' }],
edges: [
...pkg().edges,
// A4 已有一条无条件出边 A4→end;再加一条无条件出边即违规
{ id: 'e9', from: 'A4' as never, to: 'end2' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: twoDefaults, contracts, positions }).violations;
expect(violations.filter((v) => v.kind === 'multi-default-edge').map((v) => v.nodeId)).toEqual(['A4']);
// 其中一条补上条件即闭合 —— 条件命中优先,无条件边只作默认兜底
const oneDefault = pkg({
nodes: twoDefaults.nodes,
edges: twoDefaults.edges.map((e) => (e.id === 'e9' ? { ...e, condition: 'ok = true' } : e)),
});
expect(
checkClosure({ pkg: oneDefault, contracts, positions }).violations.filter((v) => v.kind === 'multi-default-edge'),
).toEqual([]);
});
it('容忍分支的输出不得被下游必填依赖', () => {
const tolerant = pkg({
nodes: [
{ id: 'start' as never, kind: 'start', label: '起点' },
{
id: 'A3' as never,
kind: 'activity',
label: 'A3 渠道寻源',
activityId: 'a4' as never,
owner: { position: 'PRM', strategy: 'sole' },
failure: { kind: 'tolerate' },
},
{
id: 'A4' as never,
kind: 'activity',
label: 'A4 成本定价',
activityId: 'a4' as never,
owner: { position: 'BD', strategy: 'sole' },
failure: { kind: 'terminate' },
},
{ id: 'end' as never, kind: 'end', label: '终点' },
],
edges: [
{ id: 'e0', from: 'start' as never, to: 'A3' as never, mapping: [{ target: 'pn', source: { from: 'initiation', path: 'pn' } }, { target: 'basePrice', source: { from: 'initiation', path: 'b' } }], reopen: false },
{
id: 'e1',
from: 'A3' as never,
to: 'A4' as never,
mapping: [
{ target: 'pn', source: { from: 'output-port', port: 'done', path: 'pn' } },
{ target: 'basePrice', source: { from: 'output-port', port: 'done', path: 'quote' } },
],
reopen: false,
},
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: tolerant, contracts, positions }).violations;
expect(violations.map((v) => v.kind)).toContain('tolerate-required-dependency');
});
});
describe('resolveOwner —— 四策略确定性解析', () => {
const many: Candidate[] = [
{ staffId: 's2', name: '乙', openTasks: 3 },
{ staffId: 's1', name: '甲', openTasks: 1 },
{ staffId: 's3', name: '丙', openTasks: 1 },
];
it('岗位唯一人', () => {
const r = resolveOwner({ position: 'BD', strategy: 'sole' }, { candidates: [many[1]!] });
expect(r).toMatchObject({ kind: 'resolved', staffId: 's1', by: 'sole' });
});
it('岗上多人时按轮值 —— 在途最少者优先', () => {
const r = resolveOwner({ position: 'BD', strategy: 'sole' }, { candidates: many });
expect(r).toMatchObject({ kind: 'resolved', staffId: 's1', by: 'rotation' });
});
it('轮值并列时按 staffId 稳定排序 —— 解析必须确定性', () => {
const a = resolveOwner({ position: 'BD', strategy: 'rotation' }, { candidates: many });
const b = resolveOwner({ position: 'BD', strategy: 'rotation' }, { candidates: [...many].reverse() });
expect(a).toEqual(b);
});
it('指定人:不在候选内即空缺', () => {
expect(
resolveOwner({ position: 'BD', strategy: 'assigned', staffId: 'sX' as never }, { candidates: many }),
).toEqual({ kind: 'vacant', position: 'BD' });
});
it('来源跟随:复用同流内已解析出的人', () => {
const r = resolveOwner({ position: 'BD', strategy: 'follow-source' }, { candidates: many, alreadyResolved: 's2' });
expect(r).toMatchObject({ kind: 'resolved', staffId: 's2', by: 'follow-source' });
});
it('来源跟随:此前没解析过即退到轮值', () => {
const r = resolveOwner({ position: 'BD', strategy: 'follow-source' }, { candidates: many });
expect(r).toMatchObject({ by: 'rotation' });
});
it('岗位空缺', () => {
expect(resolveOwner({ position: 'BD', strategy: 'sole' }, { candidates: [] })).toEqual({
kind: 'vacant',
position: 'BD',
});
});
});
describe('aggregateWorkflowState —— 聚合投影,流程层不另设状态机', () => {
const inst = (over: Partial<NodeInstance> = {}): NodeInstance => ({
nodeId: 'A1',
instance: 1,
running: false,
waiting: false,
activated: true,
...over,
});
it('任一实例运行中 → 运行中', () => {
expect(aggregateWorkflowState([inst({ running: true }), inst({ terminal: 'released' })], false)).toBe('running');
});
it('无运行实例但有待人 / 监听 → 等待中', () => {
expect(aggregateWorkflowState([inst({ waiting: true })], false)).toBe('waiting');
});
it('全部收敛 → 已完成', () => {
expect(aggregateWorkflowState([inst({ terminal: 'released' }), inst({ terminal: 'released' })], false)).toBe(
'completed',
);
});
it('非完成终态且失败走向未消化 → 异常', () => {
expect(aggregateWorkflowState([inst({ terminal: 'blocked' })], false)).toBe('abnormal');
});
it('改道 / 容忍 / 升审批已消化即不入异常', () => {
expect(aggregateWorkflowState([inst({ terminal: 'blocked', failureDigested: true })], false)).toBe('completed');
});
it('XOR 未选分支从未激活,不入集合', () => {
expect(
aggregateWorkflowState([inst({ terminal: 'released' }), inst({ activated: false, terminal: 'blocked' })], false),
).toBe('completed');
});
it('控制事件终止 → 已终止', () => {
expect(aggregateWorkflowState([inst({ running: true })], true)).toBe('terminated');
});
/*
* 「完成」凭到过终点,不凭没有在跑的了。
* 实测过一次假成功:A1 办结、出口少了必填去向旗、G1 异或出边条件全不成立,
* 流无声停在 A1 —— 只有一个节点实例,状态却报 completed,界面上是一单「已完成」的报价。
*/
it('传了终点节点:到过终点才算完成', () => {
expect(
aggregateWorkflowState([inst({ nodeId: 'A1', terminal: 'released' }), inst({ nodeId: 'end', terminal: 'released' })], false, ['end']),
).toBe('completed');
});
it('传了终点节点:收敛了但没到终点 → 异常(卡住了,不是完成)', () => {
expect(aggregateWorkflowState([inst({ nodeId: 'A1', terminal: 'released' })], false, ['end'])).toBe('abnormal');
});
it('终点自己没 released 也不算完成', () => {
expect(
aggregateWorkflowState([inst({ nodeId: 'end', terminal: 'blocked', failureDigested: true })], false, ['end']),
).toBe('abnormal');
});
});
describe('失败走向的消化与重开', () => {
it('终止流待人工处置中仍算异常', () => {
expect(isDigested('terminate')).toBe(false);
expect(isDigested('reroute')).toBe(true);
expect(isDigested('tolerate')).toBe(true);
expect(isDigested('escalate')).toBe(true);
});
it('重开产生新实例号 —— 不复活旧任务', () => {
const instances: NodeInstance[] = [
{ nodeId: 'A4', instance: 1, running: false, waiting: false, activated: true, terminal: 'released' },
{ nodeId: 'A4', instance: 2, running: true, waiting: false, activated: true },
{ nodeId: 'A6', instance: 1, running: true, waiting: false, activated: true },
];
expect(nextInstanceNumber(instances, 'A4')).toBe(3);
expect(nextInstanceNumber(instances, 'A1')).toBe(1);
});
});
/*
* joinReady 已随网关一并摘除(2026-07-28 第二轮):多入边即异或汇合 —— 上游互斥,
* 只会来一条,**没有「等回齐」这回事**;AND 并行待「并行组」声明时再带自己的汇合语义。
* 从前它守的坑(异或汇合等一个不可能到来的分支、流无声停住)从机制上不存在了。
*/