Files
workspace/code/fms-workflow-refactor-backup-20260923/workflow-domain.spec.js
T
2026-09-23 16:54:54 +08:00

1167 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'])
})
})