/** 闭合校验(名流动版)、负责人四策略、工作流状态聚合 —— 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 => ({ activityId: 'a4', requiredInputs: ['pn', 'basePrice'], inputTypes: { pn: 'string', basePrice: 'number' }, outputs: { done: { pn: 'string', quote: 'number' } }, ...over, }); /** 统一报价的一小段:起点 → A4 成本定价 → 终点 */ const pkg = (over: Partial = {}): 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 => ({ 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 并行待「并行组」声明时再带自己的汇合语义。 * 从前它守的坑(异或汇合等一个不可能到来的分支、流无声停住)从机制上不存在了。 */