1167 lines
43 KiB
JavaScript
1167 lines
43 KiB
JavaScript
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'])
|
||
})
|
||
})
|