/** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */ import { describe, expect, it } from 'vitest'; import { processPackageSchema } from '@contracts'; import type { ProcessPackage } from '@contracts'; import { joinReady } from '../internal/domain/routing.ts'; 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, 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('异或网关的出边必须有条件 —— 声明式、静态可检', () => { const withGateway = pkg({ nodes: [ ...pkg().nodes, { id: 'G2' as never, kind: 'gateway', label: 'G2 料号分流', gateway: 'exclusive', mode: 'fork' }, ], edges: [ ...pkg().edges, { id: 'g1', from: 'A4' as never, to: 'G2' as never, mapping: [], reopen: false }, { id: 'g2', from: 'G2' as never, to: 'end' as never, mapping: [], reopen: false }, { id: 'g3', from: 'G2' as never, to: 'end' as never, mapping: [], reopen: false }, ], }); const violations = checkClosure({ pkg: withGateway, contracts, positions }).violations; expect(violations.some((v) => v.message.includes('缺条件'))).toBe(true); }); 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 => ({ 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); }); }); describe('joinReady —— 网关直通道不参与等待', () => { const pkg = processPackageSchema.parse({ id: 'p-join', name: '汇合测试', version: 1, state: 'active', callInterface: { input: { type: 'object' }, output: { type: 'object' } }, nodes: [ { kind: 'start', id: 'S', label: '开始' }, { kind: 'gateway', id: 'F', label: '分叉', gateway: 'parallel', mode: 'fork' }, { kind: 'activity', id: 'X', label: 'X', activityId: 'a-x', owner: { kind: 'position', position: 'BD' } }, { kind: 'activity', id: 'Y', label: 'Y', activityId: 'a-y', owner: { kind: 'position', position: 'BD' } }, { kind: 'gateway', id: 'J', label: '汇合', gateway: 'parallel', mode: 'join' }, { kind: 'end', id: 'E', label: '结束' }, ], edges: [ { id: 'e1', from: 'S', to: 'F', mapping: [], reopen: false }, { id: 'e2', from: 'F', to: 'X', mapping: [], reopen: false }, { id: 'e3', from: 'F', to: 'Y', mapping: [], reopen: false }, // 分叉直连汇合的**直通道**(TYPE-A 快路):来源是网关,永远不会有实例 { id: 'e4', from: 'F', to: 'J', mapping: [], reopen: false }, { id: 'e5', from: 'X', to: 'J', mapping: [], reopen: false }, { id: 'e6', from: 'Y', to: 'J', mapping: [], reopen: false }, { id: 'e7', from: 'J', to: 'E', mapping: [], reopen: false }, ], variables: [], }); it('两条责任分支都收敛 → 回齐(网关直通道不算一票)', () => { expect(joinReady(pkg, 'J', new Set(['X', 'Y']), new Set())).toBe(true); }); it('一条容忍消化、一条收敛 → 回齐', () => { expect(joinReady(pkg, 'J', new Set(['X']), new Set(['Y']))).toBe(true); }); it('还差一条 → 不回齐', () => { expect(joinReady(pkg, 'J', new Set(['X']), new Set())).toBe(false); }); it('把网关来源也算进去就会永远等不到 —— 这是回归守卫', () => { // 若实现改回「全部入边都要收敛」,上面第一条断言必然转 false expect(joinReady(pkg, 'J', new Set(['X', 'Y']), new Set())).not.toBe(false); }); }); /* * 异或汇合不等 —— 这条曾经让流无声停住。 * * G2J(寻源汇合)是异或:「需要寻源」走 A3、「不需要」直通,两条互斥、只来一条。 * 而 `joinReady` 原本按并行汇合写(全部责任分支收敛才放行),于是在「不需要寻源」那条路上 * 永远在等 A3 —— 一个结构上不会激活的分支。症状最坑的地方是**连死路事件都没有**: * 边是选中了的,只是在汇合判定里被 continue 跳过,从外面看就是 A1 办结后什么都不再发生。 */ describe('joinReady —— 异或汇合只来一条,不等', () => { const xor = processPackageSchema.parse({ id: 'p-xor-join', name: '异或汇合测试', version: 1, state: 'active', callInterface: { input: { type: 'object' }, output: { type: 'object' } }, nodes: [ { kind: 'start', id: 'S', label: '开始' }, { kind: 'gateway', id: 'G', label: '要不要绕', gateway: 'exclusive', mode: 'fork', basis: '判据' }, { kind: 'activity', id: 'X', label: 'X', activityId: 'a-x', owner: { kind: 'position', position: 'BD' } }, { kind: 'gateway', id: 'J', label: '汇合', gateway: 'exclusive', mode: 'join' }, { kind: 'end', id: 'E', label: '结束' }, ], edges: [ { id: 'e1', from: 'S', to: 'G', mapping: [], reopen: false }, { id: 'e2', from: 'G', to: 'X', condition: 'go = true', mapping: [], reopen: false }, // 直通:不绕 X,从网关直接进汇合 { id: 'e3', from: 'G', to: 'J', condition: 'go = false', mapping: [], reopen: false }, { id: 'e4', from: 'X', to: 'J', mapping: [], reopen: false }, { id: 'e5', from: 'J', to: 'E', mapping: [], reopen: false }, ], variables: [], }); it('走直通那条:X 从未激活,汇合照样放行', () => { expect(joinReady(xor, 'J', new Set(), new Set())).toBe(true); }); it('走 X 那条:X 收敛后放行', () => { expect(joinReady(xor, 'J', new Set(['X']), new Set())).toBe(true); }); // 并行汇合「该等的还得等」由上一个 describe 的「还差一条 → 不回齐」守着,不在这儿重复 });