import { describe, expect, it } from 'vitest' import { createBinding, createDefinitionDraft, createEmptyDraft, defaultNodeConfig, describeActorRules, describeApprovalMode, describeCondition, draftToCanvasJson, draftToSavePayload, duplicateLinearStep, insertLinearStep, inspectLinear, listLinearSteps, logicFlowToWorkflowDraft, moveLinearStep, normalizeCondition, normalizeEdgePriorities, outgoingEdgesOf, removeLinearStep, rowsToWorkflowDraft, treeSignature, validateWorkflowDomain, workflowDraftToTree, workflowDomainToLogicFlow, workflowTreeToDraft, } from '@/views/system/workflow/designer/domain' /** 造一份「开始 → 人工审批 → 结束」的线性草稿 */ function linearDraft() { return createDefinitionDraft({ id: 'expense_approval', name: '费用报销审批', moduleId: 'cw_expense', event: 'submit', }) } /** 取校验结果里的错误文案,便于断言 */ function errorsOf(draft, options) { return validateWorkflowDomain(draft, options) .filter((issue) => issue.level === 'error') .map((issue) => issue.message) } describe('领域草稿(schemaVersion 2)', () => { it('空草稿自带开始与结束节点,并用一条默认连线串起来', () => { const draft = createEmptyDraft() expect(draft.schemaVersion).toBe(2) expect(draft.nodes.map((node) => node.nodeKey)).toEqual(['start', 'end']) expect(draft.edges).toHaveLength(1) expect(draft.edges[0]).toMatchObject({ from: 'start', to: 'end', condition: null }) expect(draft.definition).toEqual({ id: '', name: '', i18n: null, enabled: true, remark: '' }) expect(draft.bindings).toEqual([]) }) it('新建向导的默认模板是「开始 → 人工审批 → 结束」,审批人留空', () => { const draft = linearDraft() expect(draft.definition.id).toBe('expense_approval') expect(draft.nodes.map((node) => node.nodeType)).toEqual(['start', 'user_task', 'end']) expect(draft.nodes[1].actorRules).toEqual([]) expect(draft.edges.map((edge) => [edge.from, edge.to])).toEqual([ ['start', 'user_task_1'], ['user_task_1', 'end'], ]) expect(draft.bindings[0]).toMatchObject({ moduleId: 'cw_expense', event: 'submit', enabled: true }) }) it('不用模板时只给空白高级流程', () => { const draft = createDefinitionDraft({ id: 'x', name: 'X', moduleId: 'm', useTemplate: false }) expect(draft.nodes.map((node) => node.nodeType)).toEqual(['start', 'end']) }) it('抄送节点不带审批设置,只配抄送人规则(§5.3)', () => { const result = insertLinearStep(linearDraft(), 'start', 'cc_task') const cc = result.draft.nodes.find((node) => node.nodeKey === result.nodeKey) expect(cc.config).toEqual({}) }) }) describe('条件 AST 的空值归一', () => { it('空逻辑组会被归一成 null(默认出口),但带一条条件行的组会保留', () => { expect(normalizeCondition({ all: [] })).toBeNull() expect(normalizeCondition(null)).toBeNull() const group = { all: [{ field: '', operator: 'like', value: undefined }] } expect(normalizeCondition(group)).toEqual(group) }) it('抄送发起人自己不算自审风险,只有人工审批节点才算(§9.2)', () => { const draft = linearDraft() const withCc = insertLinearStep(draft, 'start', 'cc_task') const ccKey = withCc.nodeKey const ccDraft = { ...withCc.draft, nodes: withCc.draft.nodes.map((node) => node.nodeKey === ccKey ? { ...node, actorRules: [{ actorType: 'initiator', actorRef: '' }] } : node, ), } const warnings = validateWorkflowDomain(ccDraft) .filter((issue) => issue.level === 'warn') .map((issue) => issue.message) expect(warnings.some((message) => message.includes('允许申请人自审'))).toBe(false) }) }) describe('数据库行 → 领域草稿', () => { const rows = { definition: { b_id: 'expense_approval', b_name: '费用报销审批', b_canuse: 1, b_bz: '备注' }, version: { b_id: 'expense_approval_v3', b_definition_id: 'expense_approval', b_version_no: 3, b_status: 'draft', b_canvas_json: '{"zoom":1,"nodes":{"start":{"x":100,"y":200}}}', }, bindings: [ { b_id: 'bind_1', b_module_id: 'cw_expense', b_event: 'submit', b_priority: 5, b_canuse: 1, b_match_json: '{"all":[]}', }, ], nodes: [ { b_node_key: 'start', b_node_type: 'start', b_name: '开始', b_config_json: null, }, { b_node_key: 'dept_manager', b_node_type: 'user_task', b_name: '部门负责人审批', b_approval_mode: 'all', b_reject_mode: 'return_initiator', b_due_hours: 24, b_config_json: '{"requiredCount":2,"allowClaim":true,"commentRequired":true}', }, ], edges: [ { b_id: 'e1', b_from_node_key: 'start', b_to_node_key: 'dept_manager', b_name: null, b_condition_json: '{"all":[{"field":"b_amount","operator":"gt","value":10000}]}', }, ], actorRules: [ { b_node_key: 'dept_manager', b_actor_type: 'user', b_actor_ref: 'u1', b_priority: 10 }, { b_node_key: 'dept_manager', b_actor_type: 'dept_manager', b_actor_ref: '', b_priority: 20 }, ], } it('拆出定义、版本与绑定', () => { const draft = rowsToWorkflowDraft(rows) expect(draft.schemaVersion).toBe(2) expect(draft.definition).toMatchObject({ id: 'expense_approval', name: '费用报销审批', enabled: true }) expect(draft.version).toMatchObject({ id: 'expense_approval_v3', versionNo: 3, status: 'draft' }) expect(draft.bindings[0]).toMatchObject({ id: 'bind_1', moduleId: 'cw_expense', event: 'submit', priority: 5 }) }) it('坐标从 b_canvas_json 读回,缺失的节点用默认落点', () => { const draft = rowsToWorkflowDraft(rows) const start = draft.nodes.find((node) => node.nodeKey === 'start') const task = draft.nodes.find((node) => node.nodeKey === 'dept_manager') expect([start.x, start.y]).toEqual([100, 200]) expect(Number.isFinite(task.x)).toBe(true) expect(task.y).toBe(240) }) it('列上的审批设置优先于 b_config_json,其余低频配置保留在 config 里', () => { const task = rowsToWorkflowDraft(rows).nodes.find((node) => node.nodeKey === 'dept_manager') expect(task.config).toMatchObject({ approvalMode: 'all', rejectMode: 'return_initiator', dueHours: 24, requiredCount: 2, allowClaim: true, commentRequired: true, }) }) it('审批人规则按节点分组并保留顺序(串行会签顺序来源)', () => { const task = rowsToWorkflowDraft(rows).nodes.find((node) => node.nodeKey === 'dept_manager') expect(task.actorRules.map((rule) => rule.actorType)).toEqual(['user', 'dept_manager']) expect(task.actorRules[0].actorRef).toBe('u1') expect(task.actorRules[0].fallbackType).toBe('none') }) it('连线条件解析为结构化 AST', () => { const edge = rowsToWorkflowDraft(rows).edges[0] expect(edge.edgeKey).toBe('e1') expect(edge.condition).toEqual({ all: [{ field: 'b_amount', operator: 'gt', value: 10000 }] }) }) it('脏 JSON 不让设计器打不开', () => { const draft = rowsToWorkflowDraft({ ...rows, version: { ...rows.version, b_canvas_json: '{坏数据' }, nodes: [{ ...rows.nodes[1], b_config_json: 'not-json' }], }) expect(draft.nodes[0].config.approvalMode).toBe('all') }) }) describe('领域草稿 → 落库载荷', () => { it('坐标只进 canvas,不写进节点行', () => { const draft = linearDraft() draft.nodes[1].x = 333 draft.nodes[1].y = 444 const payload = draftToSavePayload(draft) const taskRow = payload.nodes.find((row) => row.b_node_key === 'user_task_1') expect(taskRow).not.toHaveProperty('x') expect(taskRow).not.toHaveProperty('b_canvas_json') expect(payload.canvas.nodes.user_task_1).toEqual({ x: 333, y: 444 }) }) it('审批方式、驳回策略、时限走列,低频配置走 b_config_json 且不重复', () => { const draft = linearDraft() const task = draft.nodes[1] task.config.approvalMode = 'n_of_m' task.config.requiredCount = 2 task.config.allowClaim = true const row = draftToSavePayload(draft).nodes.find((item) => item.b_node_key === 'user_task_1') expect(row.b_approval_mode).toBe('n_of_m') expect(row.b_reject_mode).toBe('terminate') expect(row.b_due_hours).toBeNull() const config = JSON.parse(row.b_config_json) expect(config.requiredCount).toBe(2) expect(config.allowClaim).toBe(true) expect(config).not.toHaveProperty('approvalMode') expect(config).not.toHaveProperty('rejectMode') expect(config).not.toHaveProperty('dueHours') }) it('审批人规则拆平为 wf_actor_rule 行并用 b_priority 保存顺序', () => { const draft = linearDraft() draft.nodes[1].actorRules = [ { actorType: 'dept_manager', actorRef: 'initiator_dept', fallbackType: 'none' }, { actorType: 'user', actorRef: 'u9', fallbackType: 'admin' }, ] const rules = draftToSavePayload(draft).actor_rules expect(rules).toHaveLength(2) expect(rules[0]).toMatchObject({ b_node_key: 'user_task_1', b_actor_type: 'dept_manager', b_priority: 10 }) expect(rules[1]).toMatchObject({ b_actor_type: 'user', b_actor_ref: 'u9', b_priority: 20 }) }) it('画布布局只保留坐标', () => { const draft = linearDraft() const canvas = draftToCanvasJson(draft) expect(canvas.zoom).toBe(1) expect(Object.keys(canvas.nodes)).toEqual(['start', 'user_task_1', 'end']) }) }) describe('领域草稿 ↔ 画布模型', () => { it('渲染到画布时带上领域类型与条件摘要标签', () => { const draft = linearDraft() draft.edges[0].condition = { all: [{ field: 'b_amount', operator: 'gt', value: 10000 }] } const graph = workflowDomainToLogicFlow(draft) expect(graph.nodes.map((node) => node.type)).toEqual(['fms-start', 'fms-user-task', 'fms-end']) expect(graph.nodes[1].id).toBe('user_task_1') expect(graph.edges[0].text).toBe('b_amount 大于 10000') }) it('从画布合并回草稿时保留业务配置(画布不承载审批设置)', () => { const draft = linearDraft() draft.nodes[1].config.approvalMode = 'serial' draft.nodes[1].actorRules = [{ actorType: 'user', actorRef: 'u1' }] // 画布只回传结构:多了一个节点、节点被移动、文本被改 const graph = { nodes: [ { id: 'start', type: 'fms-start', x: 10, y: 20, text: '开始' }, { id: 'user_task_1', type: 'fms-user-task', x: 300, y: 120, text: '部门负责人' }, { id: 'user_task_2', type: 'fms-user-task', x: 500, y: 120, text: '人工审批' }, { id: 'end', type: 'fms-end', x: 700, y: 120, text: '结束' }, ], edges: [ { id: 'edge_1', sourceNodeId: 'start', targetNodeId: 'user_task_1' }, { id: 'edge_2', sourceNodeId: 'user_task_1', targetNodeId: 'user_task_2' }, { id: 'edge_3', sourceNodeId: 'user_task_2', targetNodeId: 'end' }, ], } const merged = logicFlowToWorkflowDraft(graph, draft) const kept = merged.nodes.find((node) => node.nodeKey === 'user_task_1') expect(kept.config.approvalMode).toBe('serial') expect(kept.actorRules).toEqual([{ actorType: 'user', actorRef: 'u1' }]) expect([kept.x, kept.y]).toEqual([300, 120]) expect(kept.name).toBe('部门负责人') // 新节点拿到默认配置,不会继承别人的审批人 const added = merged.nodes.find((node) => node.nodeKey === 'user_task_2') expect(added.actorRules).toEqual([]) expect(merged.edges).toHaveLength(3) expect(merged.definition.id).toBe('expense_approval') }) }) /** 字段编码 → 显示名,模拟从 s_field 取 b_name */ const fieldNameOf = (code) => (code === 'b_amount' ? '金额' : code) describe('条件 AST 摘要', () => { it('无条件时不给标签(默认出口)', () => { expect(describeCondition(null)).toBe('') }) it('叶子条件用操作符文案拼一句话', () => { expect(describeCondition({ field: 'b_amount', operator: 'gt', value: 10000 })).toBe( 'b_amount 大于 10000', ) }) it('无值操作符不带比较值', () => { expect(describeCondition({ all: [{ field: 'b_reason', operator: 'is_null' }] })).toBe( 'b_reason 为空', ) }) it('区间与多值操作符按各自形态展示', () => { expect( describeCondition({ all: [{ field: 'b_amount', operator: 'between', value: [1, 9] }] }), ).toBe('b_amount 介于 1 ~ 9') expect( describeCondition({ all: [{ field: 'b_status', operator: 'in', value: ['a', 'b'] }] }), ).toBe('b_status 属于 a、b') }) it('all / any 用且或连接,嵌套组加括号', () => { expect( describeCondition({ all: [ { field: 'b_amount', operator: 'gt', value: 10000 }, { field: 'b_company', operator: 'eq', value: '华东' }, ], }), ).toBe('b_amount 大于 10000 且 b_company 等于 华东') expect( describeCondition({ any: [ { field: 'a', operator: 'eq', value: 1 }, { any: [{ field: 'b', operator: 'eq', value: 2 }, { field: 'c', operator: 'eq', value: 3 }] }, ], }), ).toBe('a 等于 1 或 (b 等于 2 或 c 等于 3)') }) it('可以按 s_field 把字段编码换成显示名', () => { expect( describeCondition({ all: [{ field: 'b_amount', operator: 'gt', value: 1 }] }, { fieldName: fieldNameOf }), ).toBe('金额 大于 1') }) }) describe('单链路判定与卡片模式图操作', () => { it('线性草稿可以进卡片模式,并给出主链路顺序', () => { const info = inspectLinear(linearDraft()) expect(info.linear).toBe(true) expect(info.order).toEqual(['start', 'user_task_1', 'end']) }) it('含条件分支的流程不能进卡片模式,并说明原因', () => { const draft = linearDraft() draft.nodes.push({ nodeKey: 'gw_1', nodeType: 'exclusive_gateway', name: '条件分支', x: 0, y: 0, config: {}, actorRules: [], }) const info = inspectLinear(draft) expect(info.linear).toBe(false) expect(info.reason).toContain('条件分支') expect(listLinearSteps(draft)).toEqual([]) }) it('插入节点:A → B 变成 A → 新节点 → B', () => { const result = insertLinearStep(linearDraft(), 'start', 'cc_task') expect(result.ok).toBe(true) const info = inspectLinear(result.draft) expect(info.order).toEqual(['start', result.nodeKey, 'user_task_1', 'end']) expect(result.nodeKey).toBe('cc_task_1') }) it('插入节点的落点取前后节点的中点,不会叠在画布同一处', () => { const draft = linearDraft() draft.nodes[0].x = 100 draft.nodes[0].y = 100 draft.nodes[1].x = 300 draft.nodes[1].y = 200 const result = insertLinearStep(draft, 'start', 'user_task') const node = result.draft.nodes.find((item) => item.nodeKey === result.nodeKey) expect(node.x).toBe(200) expect(node.y).toBe(190) }) it('删除节点:A → B → C 变成 A → C,开始与结束不可删', () => { const result = removeLinearStep(linearDraft(), 'user_task_1') expect(result.ok).toBe(true) expect(result.draft.nodes.map((node) => node.nodeKey)).toEqual(['start', 'end']) expect(result.draft.edges).toHaveLength(1) expect(result.draft.edges[0]).toMatchObject({ from: 'start', to: 'end' }) expect(removeLinearStep(linearDraft(), 'start').ok).toBe(false) expect(removeLinearStep(linearDraft(), 'end').ok).toBe(false) }) it('上移下移只改主链路顺序,连线数量不变', () => { const draft = linearDraft() const added = insertLinearStep(draft, 'start', 'cc_task').draft const info = inspectLinear(added) const moved = moveLinearStep(added, info.order[1], 1) expect(moved.ok).toBe(true) expect(inspectLinear(moved.draft).order).toEqual(['start', 'user_task_1', 'cc_task_1', 'end']) expect(moved.draft.edges).toHaveLength(3) }) it('开始与结束节点不能换位', () => { expect(moveLinearStep(linearDraft(), 'start', 1).ok).toBe(false) expect(moveLinearStep(linearDraft(), 'end', -1).ok).toBe(false) }) it('复制节点带上配置与审批人规则,编码重新生成', () => { const draft = linearDraft() draft.nodes[1].actorRules = [{ actorType: 'user', actorRef: 'u1' }] draft.nodes[1].config.commentRequired = true const result = duplicateLinearStep(draft, 'user_task_1') expect(result.ok).toBe(true) const clone = result.draft.nodes.find((node) => node.nodeKey === result.nodeKey) expect(clone.actorRules).toEqual([{ actorType: 'user', actorRef: 'u1' }]) expect(clone.config.commentRequired).toBe(true) expect(clone.name).toContain('副本') // 两份规则不是同一个引用,改副本不影响原节点 expect(clone.actorRules).not.toBe(draft.nodes[1].actorRules) expect(inspectLinear(result.draft).order).toEqual(['start', 'user_task_1', result.nodeKey, 'end']) }) it('非单链路时所有图操作都拒绝并给出原因', () => { const draft = linearDraft() draft.nodes.push({ nodeKey: 'gw_1', nodeType: 'exclusive_gateway', name: '条件分支', x: 0, y: 0, config: {}, actorRules: [], }) for (const result of [ insertLinearStep(draft, 'start', 'cc_task'), removeLinearStep(draft, 'user_task_1'), moveLinearStep(draft, 'user_task_1', 1), duplicateLinearStep(draft, 'user_task_1'), ]) { expect(result.ok).toBe(false) expect(result.reason).toBeTruthy() } }) }) describe('节点摘要(卡片模式)', () => { it('审批方式带 N 人通过 / 按比例的参数', () => { expect(describeApprovalMode({ config: { approvalMode: 'single' } })).toBe('单人审批') expect(describeApprovalMode({ config: { approvalMode: 'n_of_m', requiredCount: 2 } })).toBe( 'N 人通过 · 2 人', ) expect(describeApprovalMode({ config: { approvalMode: 'percentage', requiredRatio: 0.6 } })).toBe( '按比例通过 · 60%', ) }) it('审批人摘要:无参类型只显示类型,有参类型显示对象', () => { const node = { actorRules: [ { actorType: 'dept_manager', actorRef: 'initiator_dept' }, { actorType: 'user', actorRef: 'u1' }, { actorType: 'initiator', actorRef: '' }, ], } expect(describeActorRules(node, { nameOf: () => '张三' })).toBe( '部门负责人(发起人所属部门)、指定用户:张三、发起人', ) }) it('未选择对象时明确提示,不显示空值', () => { expect(describeActorRules({ actorRules: [{ actorType: 'user', actorRef: '' }] })).toBe( '指定用户:未选择', ) }) }) describe('发布前校验', () => { it('默认模板缺审批人、缺绑定信息时逐条报错', () => { const draft = linearDraft() const errors = errorsOf(draft) expect(errors).toContain('「审批」未配置审批人规则') expect(errors.some((message) => message.includes('绑定 1'))).toBe(false) }) it('没有绑定就不能发布', () => { const draft = linearDraft() draft.bindings = [] expect(errorsOf(draft)).toContain('未配置流程绑定:流程无法被任何业务单据触发') }) it('流程编码与名称必填', () => { const draft = linearDraft() draft.definition.id = '' draft.definition.name = ' ' const errors = errorsOf(draft) expect(errors).toContain('流程编码为空') expect(errors).toContain('流程名称为空') }) it('N 人通过与按比例通过必须给合法参数', () => { const draft = linearDraft() const task = draft.nodes[1] task.actorRules = [{ actorType: 'initiator', actorRef: '' }] task.config.allowInitiator = true task.config.approvalMode = 'n_of_m' expect(errorsOf(draft)).toContain('「审批」选择 N 人通过但未填写通过人数') task.config.approvalMode = 'percentage' task.config.requiredRatio = 1.5 expect(errorsOf(draft)).toContain('「审批」的通过比例必须在 0 到 1 之间') }) it('把发起人当审批人但未开自审时给警告(§9.2)', () => { const draft = linearDraft() draft.nodes[1].actorRules = [{ actorType: 'initiator', actorRef: '' }] const warnings = validateWorkflowDomain(draft) .filter((issue) => issue.level === 'warn') .map((issue) => issue.message) expect(warnings.some((message) => message.includes('允许申请人自审'))).toBe(true) }) it('条件分支必须有默认出口', () => { const draft = linearDraft() draft.nodes = [ draft.nodes[0], { nodeKey: 'gw_1', nodeType: 'exclusive_gateway', name: '金额判断', x: 0, y: 0, config: {}, actorRules: [] }, draft.nodes[1], draft.nodes[2], ] draft.edges = [ { edgeKey: 'e1', from: 'start', to: 'gw_1', name: '', condition: null }, { edgeKey: 'e2', from: 'gw_1', to: 'user_task_1', name: '', condition: { all: [{ field: 'b_amount', operator: 'gt', value: 1 }] }, }, { edgeKey: 'e3', from: 'user_task_1', to: 'end', name: '', condition: null }, ] expect(errorsOf(draft)).toContain('条件分支「金额判断」缺少默认出口(需且仅需一条无条件连线)') }) it('条件字段必须来自绑定模块的 s_field', () => { const draft = linearDraft() draft.nodes[1].actorRules = [{ actorType: 'initiator', actorRef: '' }] draft.nodes[1].config.allowInitiator = true draft.edges[1].condition = { all: [{ field: 'b_unknown', operator: 'eq', value: 1 }] } expect(errorsOf(draft, { fieldCodes: ['b_amount'] })).toContain( '连线条件引用的字段 b_unknown 不在绑定模块的字段里', ) // 不传字段清单时跳过字段存在性校验(前端拿不到 s_field 的场景) expect(errorsOf(draft)).not.toContain('连线条件引用的字段 b_unknown 不在绑定模块的字段里') }) it('条件缺少比较值时报错', () => { const draft = linearDraft() draft.nodes[1].actorRules = [{ actorType: 'initiator', actorRef: '' }] draft.nodes[1].config.allowInitiator = true draft.edges[1].condition = { all: [{ field: 'b_amount', operator: 'gt', value: '' }] } expect(errorsOf(draft)).toContain('条件字段 b_amount 未填写比较值') }) it('环形路径与无出口节点都报错', () => { const draft = linearDraft() draft.nodes[1].actorRules = [{ actorType: 'initiator', actorRef: '' }] draft.nodes[1].config.allowInitiator = true // 把结束节点摘掉,人工审批指向自己形成环 draft.nodes = draft.nodes.slice(0, 2) draft.edges = [{ edgeKey: 'e1', from: 'user_task_1', to: 'user_task_1', name: '', condition: null }] const errors = errorsOf(draft) expect(errors).toContain('缺少结束节点') expect(errors.some((message) => message.includes('环形路径'))).toBe(true) }) it('不可达节点给警告', () => { const draft = linearDraft() draft.nodes[1].actorRules = [{ actorType: 'initiator', actorRef: '' }] draft.nodes[1].config.allowInitiator = true draft.nodes.push({ nodeKey: 'user_task_9', nodeType: 'user_task', name: '孤立节点', x: 0, y: 0, config: {}, actorRules: [], }) const warnings = validateWorkflowDomain(draft) .filter((issue) => issue.level === 'warn') .map((issue) => issue.message) expect(warnings).toContain('节点「孤立节点」从开始节点不可达') }) it('同一模块同一事件多条启用绑定给出复核提示', () => { const draft = linearDraft() draft.nodes[1].actorRules = [{ actorType: 'initiator', actorRef: '' }] draft.nodes[1].config.allowInitiator = true draft.bindings.push({ ...draft.bindings[0], id: 'bind_2', priority: 1 }) const warnings = validateWorkflowDomain(draft) .filter((issue) => issue.level === 'warn') .map((issue) => issue.message) expect(warnings.some((message) => message.includes('条启用绑定'))).toBe(true) }) it('完全配置好的线性流程没有错误', () => { const draft = linearDraft() draft.nodes[1].actorRules = [{ actorType: 'dept_manager', actorRef: 'initiator_dept' }] expect(errorsOf(draft)).toEqual([]) }) }) /* ============================================================================ 流程树投影与编译(《FMS审批流程设置器交互重构设计》§5.1 / §5.2) 阶段 1 的 fixtures:线性、两个条件、三条件排序、分支后公共审批、嵌套条件, 以及并行 / 缺默认出口 / 重复默认出口 / 环 / 交叉边这些必须被保护掉的拓扑。 ============================================================================ */ /** 节点构造器:树投影只关心拓扑与条件,坐标与配置给默认值 */ const N = (nodeKey, nodeType, name) => ({ nodeKey, nodeType, name: name || nodeKey, x: 0, y: 0, config: defaultNodeConfig(nodeType), actorRules: [], }) /** 连线构造器 */ const E = (edgeKey, from, to, condition = null, priority = 1) => ({ edgeKey, from, to, name: '', condition, priority, }) const AMOUNT_GT_1W = { all: [{ field: 'b_amount', operator: 'gt', value: 10000 }] } const AMOUNT_GT_5W = { all: [{ field: 'b_amount', operator: 'gt', value: 50000 }] } const REGION_EAST = { all: [{ field: 'b_region', operator: 'eq', value: 'east' }] } /** 组装一份可直接投影的草稿 */ function draftOf(nodes, edges) { const draft = createEmptyDraft() draft.definition = { id: 'expense_approval', name: '费用报销审批', i18n: null, enabled: true, remark: '' } draft.version = { id: 'expense_approval_v3', versionNo: 3, status: 'draft' } draft.bindings = [createBinding('cw_expense', 'submit')] draft.nodes = nodes draft.edges = edges return draft } /** 开始 → 审批 → 结束 */ function linearFixture() { return draftOf( [N('start', 'start'), N('user_task_1', 'user_task', '部门负责人审批'), N('end', 'end')], [E('e1', 'start', 'user_task_1'), E('e2', 'user_task_1', 'end')], ) } /** 两个条件:金额大于 1 万走财务,其他情况抄送归档,两者在结束节点汇合 */ function branchFixture() { return draftOf( [ N('start', 'start'), N('user_task_1', 'user_task', '部门负责人审批'), N('gw_1', 'exclusive_gateway', '金额判断'), N('user_task_2', 'user_task', '财务审批'), N('cc_1', 'cc_task', '抄送归档'), N('end', 'end'), ], [ E('e1', 'start', 'user_task_1'), E('e2', 'user_task_1', 'gw_1'), E('e3', 'gw_1', 'user_task_2', AMOUNT_GT_1W, 1), E('e4', 'gw_1', 'cc_1', null, 2), E('e5', 'user_task_2', 'end'), E('e6', 'cc_1', 'end'), ], ) } /** 三条分支汇入公共审批节点 join,再走结束 */ function mergeFixture() { return draftOf( [ N('start', 'start'), N('gw_1', 'exclusive_gateway', '金额判断'), N('user_task_a', 'user_task', '财务审批'), N('user_task_b', 'user_task', '法务审批'), N('cc_c', 'cc_task', '抄送财务'), N('user_task_join', 'user_task', '总经理审批'), N('end', 'end'), ], [ E('e1', 'start', 'gw_1'), E('e2', 'gw_1', 'user_task_a', AMOUNT_GT_1W, 1), E('e3', 'gw_1', 'user_task_b', AMOUNT_GT_5W, 2), E('e4', 'gw_1', 'cc_c', null, 3), E('e5', 'user_task_a', 'user_task_join'), E('e6', 'user_task_b', 'user_task_join'), E('e7', 'cc_c', 'user_task_join'), E('e8', 'user_task_join', 'end'), ], ) } /** 嵌套条件:外层分支里再开一层条件 */ function nestedFixture() { return draftOf( [ N('start', 'start'), N('gw_1', 'exclusive_gateway', '金额判断'), N('gw_2', 'exclusive_gateway', '区域判断'), N('user_task_x', 'user_task', '华东区审批'), N('user_task_y', 'user_task', '其他区审批'), N('end', 'end'), ], [ E('e1', 'start', 'gw_1'), E('e2', 'gw_1', 'gw_2', AMOUNT_GT_1W, 1), E('e3', 'gw_1', 'user_task_y', null, 2), E('e4', 'gw_2', 'user_task_x', REGION_EAST, 1), E('e5', 'gw_2', 'end', null, 2), E('e6', 'user_task_x', 'end'), E('e7', 'user_task_y', 'end'), ], ) } /** 并行网关:树形编辑器表达不了(§3.2) */ function parallelFixture() { return draftOf( [ N('start', 'start'), N('pg_1', 'parallel_gateway', '并行分支'), N('user_task_a', 'user_task', '财务审批'), N('user_task_b', 'user_task', '法务审批'), N('end', 'end'), ], [ E('e1', 'start', 'pg_1'), E('e2', 'pg_1', 'user_task_a'), E('e3', 'pg_1', 'user_task_b', null, 2), E('e4', 'user_task_a', 'end'), E('e5', 'user_task_b', 'end'), ], ) } /** 缺少默认出口 */ function missingDefaultFixture() { return draftOf( [ N('start', 'start'), N('gw_1', 'exclusive_gateway', '金额判断'), N('user_task_a', 'user_task', '财务审批'), N('user_task_b', 'user_task', '法务审批'), N('end', 'end'), ], [ E('e1', 'start', 'gw_1'), E('e2', 'gw_1', 'user_task_a', AMOUNT_GT_1W, 1), E('e3', 'gw_1', 'user_task_b', AMOUNT_GT_5W, 2), E('e4', 'user_task_a', 'end'), E('e5', 'user_task_b', 'end'), ], ) } /** 两条默认出口:显式条件全被清成 null */ function duplicateDefaultFixture() { const draft = missingDefaultFixture() draft.edges = draft.edges.map((edge) => edge.edgeKey === 'e2' || edge.edgeKey === 'e3' ? { ...edge, condition: null } : edge, ) return draft } /** 环:审批节点回连网关,网关因此有两条入线 */ function cycleFixture() { return draftOf( [ N('start', 'start'), N('gw_1', 'exclusive_gateway', '金额判断'), N('user_task_a', 'user_task', '财务审批'), N('end', 'end'), ], [ E('e1', 'start', 'gw_1'), E('e2', 'gw_1', 'user_task_a', AMOUNT_GT_1W, 1), E('e3', 'gw_1', 'end', null, 2), E('e4', 'user_task_a', 'gw_1'), ], ) } /** 交叉边:B 落在 A 的专属路径里,又不是汇合点 */ function crossFixture() { return draftOf( [ N('start', 'start'), N('gw_1', 'exclusive_gateway', '金额判断'), N('user_task_a', 'user_task', '财务审批'), N('user_task_b', 'user_task', '法务审批'), N('cc_c', 'cc_task', '抄送财务'), N('end', 'end'), ], [ E('e1', 'start', 'gw_1'), E('e2', 'gw_1', 'user_task_a', AMOUNT_GT_1W, 1), E('e3', 'gw_1', 'user_task_b', AMOUNT_GT_5W, 2), E('e4', 'gw_1', 'cc_c', null, 3), E('e5', 'user_task_a', 'user_task_b'), E('e6', 'user_task_b', 'end'), E('e7', 'cc_c', 'end'), ], ) } /** 校验结果里的全部文案 */ const messagesOf = (draft) => validateWorkflowDomain(draft).map((item) => item.message) /** 取树的路径,便于断言 */ function pathOf(tree) { const keys = [] let item = tree while (item) { keys.push(item.kind === 'branch' ? item.gatewayKey : item.nodeKey) item = item.next } return keys } /** 连线按编码建索引:编译产出的数组是后序,顺序与草稿不同但语义无差别 */ function edgeMapOf(draft) { return Object.fromEntries( normalizeEdgePriorities(draft.edges).map((edge) => [ edge.edgeKey, { from: edge.from, to: edge.to, priority: edge.priority, condition: edge.condition }, ]), ) } describe('流程树投影:可表达的拓扑', () => { it('线性流程投影成一条 step 链', () => { const result = workflowDraftToTree(linearFixture()) expect(result.ok).toBe(true) expect(pathOf(result.tree)).toEqual(['start', 'user_task_1', 'end']) expect(result.tree.next.next.next).toBeNull() }) it('两个条件:显式条件在前,「其他情况」固定最后,child 与 next 各归各位', () => { const result = workflowDraftToTree(branchFixture()) expect(result.ok).toBe(true) const branch = result.tree.next.next expect(branch.kind).toBe('branch') expect(branch.gatewayKey).toBe('gw_1') expect(branch.branches.map((item) => item.edgeKey)).toEqual(['e3', 'e4']) expect(branch.branches.map((item) => item.priority)).toEqual([1, 2]) expect(branch.branches[0].condition).toEqual(AMOUNT_GT_1W) expect(branch.branches[1].condition).toBeNull() expect(branch.branches[0].child).toEqual({ kind: 'step', nodeKey: 'user_task_2', next: null }) expect(branch.branches[1].child).toEqual({ kind: 'step', nodeKey: 'cc_1', next: null }) // 两条分支在结束节点汇合,结束只渲染一次 expect(pathOf(branch.next)).toEqual(['end']) }) it('三条件按 b_priority 排序,默认出口即使优先级最大也固定最后', () => { const draft = mergeFixture() // 打乱优先级:法务 1、抄送 2、财务 3 draft.edges = draft.edges.map((edge) => { if (edge.edgeKey === 'e2') return { ...edge, priority: 3 } if (edge.edgeKey === 'e3') return { ...edge, priority: 1 } if (edge.edgeKey === 'e4') return { ...edge, priority: 2 } return edge }) const result = workflowDraftToTree(draft) expect(result.ok).toBe(true) const branch = result.tree.next expect(branch.branches.map((item) => item.edgeKey)).toEqual(['e3', 'e2', 'e4']) // 投影里的优先级归一化成显示顺序 expect(branch.branches.map((item) => item.priority)).toEqual([1, 2, 3]) expect(branch.branches[2].condition).toBeNull() }) it('分支后公共审批:三条分支各自独立,next 指向汇合点', () => { const result = workflowDraftToTree(mergeFixture()) expect(result.ok).toBe(true) const branch = result.tree.next expect(branch.kind).toBe('branch') expect(branch.branches.map((item) => item.child?.nodeKey)).toEqual([ 'user_task_a', 'user_task_b', 'cc_c', ]) expect(pathOf(branch.next)).toEqual(['user_task_join', 'end']) }) it('嵌套条件:内层条件块成为外层分支的 child', () => { const result = workflowDraftToTree(nestedFixture()) expect(result.ok).toBe(true) const outer = result.tree.next expect(outer.kind).toBe('branch') expect(outer.branches.map((item) => item.edgeKey)).toEqual(['e2', 'e3']) const inner = outer.branches[0].child expect(inner.kind).toBe('branch') expect(inner.gatewayKey).toBe('gw_2') expect(inner.branches.map((item) => item.edgeKey)).toEqual(['e4', 'e5']) expect(inner.branches[0].child).toEqual({ kind: 'step', nodeKey: 'user_task_x', next: null }) // 内层的汇合点就是外层分支的出口(结束节点),内层不再重复渲染 next expect(inner.next).toBeNull() expect(outer.branches[1].child).toEqual({ kind: 'step', nodeKey: 'user_task_y', next: null }) expect(pathOf(outer.next)).toEqual(['end']) }) }) describe('流程树编译:写回领域草稿', () => { const fixtures = [ ['线性流程', linearFixture], ['两个条件', branchFixture], ['分支后公共审批', mergeFixture], ['嵌套条件', nestedFixture], ] it.each(fixtures)('%s:加载 → 构树 → 编译,拓扑与编码完全一致', (_name, make) => { const draft = make() const result = workflowDraftToTree(draft) expect(result.ok).toBe(true) const compiled = workflowTreeToDraft(result.tree, draft) expect(treeSignature(workflowDraftToTree(compiled).tree)).toBe(result.signature) expect(compiled.nodes.map((node) => node.nodeKey).toSorted()).toEqual( draft.nodes.map((node) => node.nodeKey).toSorted(), ) // 连线与条件按编码比对:顺序无关,但拓扑、优先级、AST 必须逐项一致 expect(edgeMapOf(compiled)).toEqual(edgeMapOf(draft)) }) it('编译保留节点配置与审批人规则,不会因为过一趟树就丢配置', () => { const draft = linearFixture() draft.nodes[1].config.approvalMode = 'all' draft.nodes[1].config.commentRequired = true draft.nodes[1].actorRules = [{ actorType: 'dept_manager', actorRef: 'initiator_dept' }] const compiled = workflowTreeToDraft(workflowDraftToTree(draft).tree, draft) const kept = compiled.nodes.find((node) => node.nodeKey === 'user_task_1') expect(kept.config.approvalMode).toBe('all') expect(kept.config.commentRequired).toBe(true) expect(kept.actorRules).toEqual([{ actorType: 'dept_manager', actorRef: 'initiator_dept' }]) }) it('从树里删掉节点后编译:节点与它的两条连线一起消失,前后自动接上', () => { const draft = linearFixture() const result = workflowDraftToTree(draft) // start → end:把中间那个审批节点摘掉 result.tree.next = result.tree.next.next const compiled = workflowTreeToDraft(result.tree, draft) expect(compiled.nodes.map((node) => node.nodeKey)).toEqual(['start', 'end']) expect(compiled.edges).toHaveLength(1) expect(compiled.edges[0]).toMatchObject({ from: 'start', to: 'end', condition: null }) }) it('新插入的节点通过 extraNodes 提供定义后能编译进草稿', () => { const draft = linearFixture() const result = workflowDraftToTree(draft) const inserted = { ...N('user_task_9', 'user_task', '新增审批'), config: { ...defaultNodeConfig('user_task'), approvalMode: 'any' }, } // 插到 start 与 user_task_1 之间 result.tree.next = { kind: 'step', nodeKey: 'user_task_9', next: result.tree.next } const compiled = workflowTreeToDraft(result.tree, draft, [inserted]) expect(compiled.nodes.map((node) => node.nodeKey)).toEqual(['start', 'user_task_9', 'user_task_1', 'end']) expect(compiled.nodes.find((node) => node.nodeKey === 'user_task_9').config.approvalMode).toBe('any') expect( compiled.edges .map((edge) => [edge.from, edge.to]) .toSorted((a, b) => a[0].localeCompare(b[0])), ).toEqual([ ['start', 'user_task_9'], ['user_task_1', 'end'], ['user_task_9', 'user_task_1'], ]) }) it('分支顺序变化会改写成 b_priority,默认出口始终最后', () => { const draft = mergeFixture() const result = workflowDraftToTree(draft) // 把第 2 条显式条件换到第 1 位 const branches = result.tree.next.branches result.tree.next.branches = [branches[1], branches[0], branches[2]] const compiled = workflowTreeToDraft(result.tree, draft) const gatewayEdges = outgoingEdgesOf(compiled, 'gw_1') expect(gatewayEdges.map((edge) => edge.edgeKey)).toEqual(['e3', 'e2', 'e4']) expect(gatewayEdges.map((edge) => edge.priority)).toEqual([1, 2, 3]) expect(gatewayEdges[2].condition).toBeNull() }) }) describe('流程树投影:必须保护的拓扑', () => { const unsupportedCases = [ ['并行网关', parallelFixture, '并行分支'], ['缺少默认出口', missingDefaultFixture, '其他情况'], ['两条默认出口', duplicateDefaultFixture, '其他情况'], ['环(审批节点回连网关)', cycleFixture, '环形路径'], ['交叉连线', crossFixture, '交叉'], ] it.each(unsupportedCases)('%s:返回只读保护而不是压平后的树', (_name, make, keyword) => { const result = workflowDraftToTree(make()) expect(result.ok).toBe(false) expect(result.reason).toContain(keyword) expect(result.tree).toBeUndefined() }) it('服务节点同样受保护', () => { const draft = linearFixture() draft.nodes.splice(1, 0, N('service_1', 'service_task', '回调服务')) draft.edges = [ E('e1', 'start', 'service_1'), E('e2', 'service_1', 'user_task_1'), E('e3', 'user_task_1', 'end'), ] const result = workflowDraftToTree(draft) expect(result.ok).toBe(false) expect(result.reason).toContain('服务节点') }) it('存在不可达节点时不构树', () => { const draft = linearFixture() draft.nodes.push(N('user_task_9', 'user_task', '孤立节点')) const result = workflowDraftToTree(draft) expect(result.ok).toBe(false) expect(result.reason).toContain('孤立节点') }) }) describe('校验扩展(§5.2.6)', () => { it('结束节点有出线是错误,不再是警告', () => { const draft = linearFixture() draft.edges.push(E('e3', 'end', 'start')) const issues = validateWorkflowDomain(draft) expect(issues.some((item) => item.level === 'error' && item.message.includes('结束节点'))).toBe(true) }) it('普通节点有多条出线是错误', () => { const draft = linearFixture() draft.edges.push(E('e3', 'user_task_1', 'start')) expect(messagesOf(draft)).toContain('节点「部门负责人审批」有多条出线,只有条件分支可以分支') }) it('普通连线带条件是错误,不再是警告', () => { const draft = linearFixture() draft.edges[1] = { ...draft.edges[1], condition: AMOUNT_GT_1W } expect(messagesOf(draft)).toContain('「部门负责人审批」不是条件分支,其出线不应带条件') }) it('条件分支缺少默认出口报错', () => { expect(messagesOf(missingDefaultFixture())).toContain( '条件分支「金额判断」缺少默认出口(需且仅需一条无条件连线)', ) }) it('条件分支有两条默认出口报错', () => { expect(messagesOf(duplicateDefaultFixture())).toContain('条件分支「金额判断」有 2 条默认出口,只允许一条') }) it('条件分支只有一条出线报错', () => { const draft = branchFixture() draft.edges = draft.edges.filter((edge) => edge.edgeKey !== 'e4') expect(messagesOf(draft)).toContain('条件分支「金额判断」至少要有两条分支') }) it('分支优先级重复时提示复核(判断顺序会退化成连线顺序)', () => { const draft = branchFixture() draft.edges = draft.edges.map((edge) => (edge.edgeKey === 'e4' ? { ...edge, priority: 1 } : edge)) const warnings = validateWorkflowDomain(draft) .filter((item) => item.level === 'warn') .map((item) => item.message) expect(warnings).toContain('条件分支「金额判断」的分支优先级有重复,将按连线顺序确定判断顺序') }) it('「其他情况」不在最后时给出可自愈的警告(保存时会重排)', () => { const draft = branchFixture() draft.edges = draft.edges.map((edge) => { if (edge.edgeKey === 'e3') return { ...edge, priority: 2 } if (edge.edgeKey === 'e4') return { ...edge, priority: 1 } return edge }) const warnings = validateWorkflowDomain(draft) .filter((item) => item.level === 'warn') .map((item) => item.message) expect(warnings).toContain('条件分支「金额判断」的「其他情况」不在最后,保存时会按规范重排') }) }) describe('边优先级归一化', () => { it('按来源节点归一化成 1..n,跳号与全局序号都会被收敛', () => { const edges = [ E('a1', 'gw', 'x', AMOUNT_GT_1W, 30), E('a2', 'gw', 'y', null, 10), E('b1', 'start', 'gw', null, 999), ] const normalized = normalizeEdgePriorities(edges) expect(normalized.map((edge) => [edge.edgeKey, edge.priority])).toEqual([ ['a1', 2], ['a2', 1], ['b1', 1], ]) }) it('落库载荷写的是归一化后的优先级,不再是全局序号', () => { const draft = branchFixture() const payload = draftToSavePayload(draft) const gatewayEdges = payload.edges.filter((edge) => edge.b_from_node_key === 'gw_1') expect(gatewayEdges.map((edge) => [edge.b_id, edge.b_priority])).toEqual([ ['e3', 10], ['e4', 20], ]) }) it('outgoingEdgesOf 按判断顺序返回出线', () => { const draft = mergeFixture() expect(outgoingEdgesOf(draft, 'gw_1').map((edge) => edge.edgeKey)).toEqual(['e2', 'e3', 'e4']) }) })