/** 边映射闭合校验、负责人四策略、工作流状态聚合 —— 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, 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 => ({ 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 并行待「并行组」声明时再带自己的汇合语义。 * 从前它守的坑(异或汇合等一个不可能到来的分支、流无声停住)从机制上不存在了。 */