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 Opus 5 03eb666ac2 三面拆分⑮:对齐 Mastra 数据流 —— 边只表达顺序,数据按输入/输出字段名流动
用户定的机制对齐:Mastra 的边不传数据,所有约束力靠输入/输出字段(schema 咬合)完成。
照此把「边映射」整个退役:

  · 契约:edge 去 mapping,审批 reject 去 mapping —— 边只剩 顺序 + 条件(读上游
    输出字段)+ 标签/回边。fieldMapping 只留给触发器的起单装配(系统边界)。
    存量包无需迁移:zod 解析时把旧 jsonb 里的 mapping 静默剥掉,图照常有效。
  · 运行时:assemble(边装配)→ flatScope(铺平作用域)——
    起单入参垫底 ← 沿途各输出口字段依次盖上 ← 刚发射的载荷(Mastra 语义:
    下一步的 input 首先是上一步的 output)。下游按自己的契约取字段;
    「__」前缀是运行时自留地,不入业务作用域。审批驳回重开、失败改道同一条路。
  · 闭合校验:边未映射/必填未映射/容忍依赖三条映射类检查退役
    (宽进之下必填有没有着落是运行时补全单的事,不拦激活);
    激活闸只留一条硬伤 —— **同名不同型**:名字即管道,名同型异必坏,
    容忍依赖改按名判。改名映射不存在:名不对改契约,不是加映射。
  · 种子:A2 的入参 material 更名 pn(它就是 PN;上游 A1 发的名就得是它收的名),
    契约 0.3.0、槽位 0.8.0;流程种子边全部去映射。
  · 前端:边属性卡砍掉「映射 · 载荷装配」整节,只剩 E号/方向/条件/标签;
    节点属性卡新增「数据接口 · 按字段名流动」——每个输入字段标出运行时从哪来
    (起单入参 / 上游 X / 补全单),输出口列出发什么名;沿边回溯推导,设计期看得见。
    画布保存与依赖文件导出同步去映射。

E2E 顺带把宽进接进真流:快速归档流的 A3 要 outcome 而流里没人产 ——
测试如实走补全路(等人 → 答卡 outcome=won → 开跑 → completed)。
实测走查:A2 节点卡上 pn←上游A1、rfqRef←起单入参、scenario←补全单,推导全对。

五闸绿;契约快照随本提交入库。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-29 14:25:27 +08:00

298 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,
failure: { kind: 'terminate' },
},
{ id: 'end' as never, kind: 'end', label: '终点' },
],
edges: [
// 边只表达顺序与条件(2026-07-30 对齐 Mastra)—— 数据按字段名流动,不带映射
{ id: 'e1', from: 'start' as never, to: 'A4' as never, reopen: false },
{ id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false },
],
variables: [],
...over,
}) as ProcessPackage;
const contracts = new Map([['a4', face()]]);
describe('checkClosure —— 不闭合不得激活', () => {
it('结构闭合、活动在库即可激活 —— 必填有没有着落是运行时补全单的事,不拦激活', () => {
const report = checkClosure({ pkg: pkg(), contracts });
expect(report.violations).toEqual([]);
expect(report.closed).toBe(true);
});
it('同名字段类型相抵即拦 —— 名字即管道,名同型异必坏', () => {
// 上游 done 口发 quote: number …下游若声明 quote: string 就是硬伤
const clash = new Map([
['a4', face({ inputTypes: { pn: 'string', quote: 'string' }, requiredInputs: ['pn'] })],
]);
const chain = pkg({
nodes: [
{ id: 'start' as never, kind: 'start', label: '起点' },
{ id: 'A3' as never, kind: 'activity', label: '上游', activityId: 'a4' as never, failure: { kind: 'terminate' } },
{ id: 'A4' as never, kind: 'activity', label: '下游', activityId: 'a4' as never, failure: { kind: 'terminate' } },
{ id: 'end' as never, kind: 'end', label: '终点' },
],
edges: [
{ id: 'e0', from: 'start' as never, to: 'A3' as never, reopen: false },
{ id: 'e1', from: 'A3' as never, to: 'A4' as never, reopen: false },
{ id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false },
],
});
const violations = checkClosure({ pkg: chain, contracts: clash }).violations;
expect(violations.filter((v) => v.kind === 'type-conflict').map((v) => v.field)).toEqual(['quote']);
});
it('引用未安装的活动即拦', () => {
const report = checkClosure({ pkg: pkg(), contracts: new Map() });
expect(report.violations.map((v) => v.kind)).toContain('unknown-activity');
});
it('孤立节点即拦', () => {
const orphan = pkg({
nodes: [
...pkg().nodes,
{
id: 'A9' as never,
kind: 'activity',
label: '游离节点',
activityId: 'a4' as never,
failure: { kind: 'terminate' },
},
],
});
const violations = checkClosure({ pkg: orphan, contracts }).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, reopen: false },
],
});
const violations = checkClosure({ pkg: twoDefaults, contracts }).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 }).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,
failure: { kind: 'tolerate' },
},
{
id: 'A4' as never,
kind: 'activity',
label: 'A4 成本定价',
activityId: 'a4' as never,
failure: { kind: 'terminate' },
},
{ id: 'end' as never, kind: 'end', label: '终点' },
],
edges: [
{ id: 'e0', from: 'start' as never, to: 'A3' as never, reopen: false },
{
id: 'e1',
from: 'A3' as never,
to: 'A4' as never,
reopen: false,
},
{ id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false },
],
});
const violations = checkClosure({ pkg: tolerant, contracts }).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 并行待「并行组」声明时再带自己的汇合语义。
* 从前它守的坑(异或汇合等一个不可能到来的分支、流无声停住)从机制上不存在了。
*/