[F8] DAG JSON ↔ vue-flow 双向转换
- apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.ts - toCanvas / fromCanvas 对称转换 - newTempId (crypto.randomUUID) + isTempId (new_ 前缀) - RuleChainGraphVO/RuleChainNodeVO/RuleChainLinkVO 本地类型定义 - B2 后端 DTO 就绪后可平替类型,转换函数签名不变 - apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.spec.ts - 23 用例:roundtrip_simple / roundtrip_branch / newNode_tempId / emptyGraph - 对称性断言 toCanvas(fromCanvas(toCanvas(x))) ≡ toCanvas(x) - Known Pitfalls 落地: ⚠️ 临时 ID 字符串穿透 / Math.round 坐标 / relationType PascalCase / edge source-target 校验 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,621 @@
|
||||
/**
|
||||
* F8 — dag-converter 单元测试
|
||||
*
|
||||
* 覆盖任务卡 §6 四个测试用例:
|
||||
* 1. roundtrip_simple — 1 Trigger + 1 Action + 1 link
|
||||
* 2. roundtrip_branch — 1 Trigger + 1 Condition + 4 Action(True/False 分支)
|
||||
* 3. newNode_tempId — 新节点使用临时 ID (new_xxx)
|
||||
* 4. emptyGraph — 空 nodes + 空 links
|
||||
*
|
||||
* 对称性断言:toCanvas(fromCanvas(toCanvas(graph))) ≡ toCanvas(graph)
|
||||
*/
|
||||
|
||||
import type { DagEdge, DagNode } from '../../../../../components/iot-dag';
|
||||
import type { RuleChainGraphVO } from './dag-converter';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { fromCanvas, isTempId, newTempId, toCanvas } from './dag-converter';
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 测试夹具(Fixtures)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/** 最简规则链:1 Trigger + 1 Action + 1 link */
|
||||
const SIMPLE_GRAPH: RuleChainGraphVO = {
|
||||
id: 1,
|
||||
name: '简单场景',
|
||||
type: 'SCENE',
|
||||
status: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: 10,
|
||||
category: 'trigger',
|
||||
type: 'device_property',
|
||||
name: '属性触发',
|
||||
configuration: { productId: 100, propertyId: 'temp' },
|
||||
positionX: 100,
|
||||
positionY: 200,
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
category: 'action',
|
||||
type: 'notify',
|
||||
name: '发送通知',
|
||||
configuration: { channel: 'SMS' },
|
||||
positionX: 400,
|
||||
positionY: 200,
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 100,
|
||||
sourceNodeId: 10,
|
||||
targetNodeId: 20,
|
||||
relationType: 'Success',
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** 分支规则链:1 Trigger + 1 Condition + 4 Action(True×2 / False×2) */
|
||||
const BRANCH_GRAPH: RuleChainGraphVO = {
|
||||
id: 2,
|
||||
name: '分支场景',
|
||||
type: 'SCENE',
|
||||
status: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
category: 'trigger',
|
||||
type: 'timer',
|
||||
name: '定时触发',
|
||||
configuration: { cron: '0 * * * *' },
|
||||
positionX: 50,
|
||||
positionY: 50,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: 'condition',
|
||||
type: 'property_compare',
|
||||
name: '温度判断',
|
||||
configuration: { operator: '>', threshold: 30 },
|
||||
positionX: 250,
|
||||
positionY: 50,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category: 'action',
|
||||
type: 'notify',
|
||||
name: '高温告警',
|
||||
configuration: { msg: '温度过高' },
|
||||
positionX: 450,
|
||||
positionY: 0,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category: 'action',
|
||||
type: 'log',
|
||||
name: '高温日志',
|
||||
configuration: {},
|
||||
positionX: 450,
|
||||
positionY: 80,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
category: 'action',
|
||||
type: 'notify',
|
||||
name: '正常通知',
|
||||
configuration: { msg: '温度正常' },
|
||||
positionX: 450,
|
||||
positionY: 160,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
category: 'action',
|
||||
type: 'log',
|
||||
name: '正常日志',
|
||||
configuration: {},
|
||||
positionX: 450,
|
||||
positionY: 240,
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 10,
|
||||
sourceNodeId: 1,
|
||||
targetNodeId: 2,
|
||||
relationType: 'Success',
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
sourceNodeId: 2,
|
||||
targetNodeId: 3,
|
||||
relationType: 'True',
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
sourceNodeId: 2,
|
||||
targetNodeId: 4,
|
||||
relationType: 'True',
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
sourceNodeId: 2,
|
||||
targetNodeId: 5,
|
||||
relationType: 'False',
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
sourceNodeId: 2,
|
||||
targetNodeId: 6,
|
||||
relationType: 'False',
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 测试用例
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
describe('dag-converter', () => {
|
||||
// ── 1. roundtrip_simple ──────────────────────
|
||||
describe('roundtrip_simple', () => {
|
||||
it('toCanvas 输出节点数量正确', () => {
|
||||
const { nodes, edges } = toCanvas(SIMPLE_GRAPH);
|
||||
expect(nodes).toHaveLength(2);
|
||||
expect(edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('toCanvas 节点字段映射正确', () => {
|
||||
const { nodes } = toCanvas(SIMPLE_GRAPH);
|
||||
const triggerNode = nodes.find((n) => n.id === '10');
|
||||
|
||||
expect(triggerNode?.type).toBe('triggerNode');
|
||||
expect(triggerNode?.position).toEqual({ x: 100, y: 200 });
|
||||
expect(triggerNode?.data?.type).toBe('trigger');
|
||||
expect(triggerNode?.data?.providerType).toBe('device_property');
|
||||
expect(triggerNode?.data?.label).toBe('属性触发');
|
||||
expect(triggerNode?.data?.configuration).toEqual({
|
||||
productId: 100,
|
||||
propertyId: 'temp',
|
||||
});
|
||||
});
|
||||
|
||||
it('toCanvas 边字段映射正确', () => {
|
||||
const { edges } = toCanvas(SIMPLE_GRAPH);
|
||||
const edge = edges.at(0);
|
||||
|
||||
expect(edge?.id).toBe('100');
|
||||
expect(edge?.source).toBe('10');
|
||||
expect(edge?.target).toBe('20');
|
||||
expect(edge?.label).toBe('Success');
|
||||
expect(edge?.type).toBe('smoothstep');
|
||||
expect(edge?.data?.relationType).toBe('Success');
|
||||
});
|
||||
|
||||
it('fromCanvas(toCanvas(graph)) 对称还原(简单场景)', () => {
|
||||
const { nodes, edges } = toCanvas(SIMPLE_GRAPH);
|
||||
const restored = fromCanvas(nodes, edges, {
|
||||
id: SIMPLE_GRAPH.id,
|
||||
name: SIMPLE_GRAPH.name,
|
||||
type: SIMPLE_GRAPH.type,
|
||||
status: SIMPLE_GRAPH.status,
|
||||
});
|
||||
|
||||
expect(restored.nodes).toHaveLength(SIMPLE_GRAPH.nodes.length);
|
||||
expect(restored.links).toHaveLength(SIMPLE_GRAPH.links.length);
|
||||
|
||||
// 验证节点还原
|
||||
const restoredTrigger = restored.nodes.find((n) => Number(n.id) === 10);
|
||||
expect(restoredTrigger?.category).toBe('trigger');
|
||||
expect(restoredTrigger?.type).toBe('device_property');
|
||||
expect(restoredTrigger?.positionX).toBe(100);
|
||||
expect(restoredTrigger?.positionY).toBe(200);
|
||||
|
||||
// 验证边还原
|
||||
const restoredLink = restored.links.at(0);
|
||||
expect(Number(restoredLink?.sourceNodeId)).toBe(10);
|
||||
expect(Number(restoredLink?.targetNodeId)).toBe(20);
|
||||
expect(restoredLink?.relationType).toBe('Success');
|
||||
});
|
||||
|
||||
it('对称性:toCanvas(fromCanvas(toCanvas(graph))) ≡ toCanvas(graph)', () => {
|
||||
const first = toCanvas(SIMPLE_GRAPH);
|
||||
const restored = fromCanvas(first.nodes, first.edges, {
|
||||
id: SIMPLE_GRAPH.id,
|
||||
name: SIMPLE_GRAPH.name,
|
||||
type: SIMPLE_GRAPH.type,
|
||||
status: SIMPLE_GRAPH.status,
|
||||
});
|
||||
const second = toCanvas(restored);
|
||||
|
||||
// 节点 id / position / data 保持一致
|
||||
expect(second.nodes.map((n) => n.id)).toEqual(
|
||||
first.nodes.map((n) => n.id),
|
||||
);
|
||||
expect(second.nodes.map((n) => n.position)).toEqual(
|
||||
first.nodes.map((n) => n.position),
|
||||
);
|
||||
expect(second.nodes.map((n) => n.data?.type)).toEqual(
|
||||
first.nodes.map((n) => n.data?.type),
|
||||
);
|
||||
expect(second.nodes.map((n) => n.data?.providerType)).toEqual(
|
||||
first.nodes.map((n) => n.data?.providerType),
|
||||
);
|
||||
|
||||
// 边一致
|
||||
expect(second.edges.map((e) => e.source)).toEqual(
|
||||
first.edges.map((e) => e.source),
|
||||
);
|
||||
expect(second.edges.map((e) => e.target)).toEqual(
|
||||
first.edges.map((e) => e.target),
|
||||
);
|
||||
expect(second.edges.map((e) => e.data?.relationType)).toEqual(
|
||||
first.edges.map((e) => e.data?.relationType),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. roundtrip_branch ──────────────────────
|
||||
describe('roundtrip_branch', () => {
|
||||
it('toCanvas 分支场景节点/边数量正确', () => {
|
||||
const { nodes, edges } = toCanvas(BRANCH_GRAPH);
|
||||
expect(nodes).toHaveLength(6);
|
||||
expect(edges).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('condition 节点类型映射正确', () => {
|
||||
const { nodes } = toCanvas(BRANCH_GRAPH);
|
||||
const conditionNode = nodes.find((n) => n.id === '2');
|
||||
|
||||
expect(conditionNode?.type).toBe('conditionNode');
|
||||
expect(conditionNode?.data?.type).toBe('condition');
|
||||
expect(conditionNode?.data?.providerType).toBe('property_compare');
|
||||
});
|
||||
|
||||
it('true/false 边 relationType 保持 PascalCase', () => {
|
||||
const { edges } = toCanvas(BRANCH_GRAPH);
|
||||
const trueEdges = edges.filter((e) => e.data?.relationType === 'True');
|
||||
const falseEdges = edges.filter((e) => e.data?.relationType === 'False');
|
||||
|
||||
expect(trueEdges).toHaveLength(2);
|
||||
expect(falseEdges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('fromCanvas(toCanvas(graph)) 对称还原(分支场景)', () => {
|
||||
const { nodes, edges } = toCanvas(BRANCH_GRAPH);
|
||||
const restored = fromCanvas(nodes, edges, {
|
||||
id: BRANCH_GRAPH.id,
|
||||
name: BRANCH_GRAPH.name,
|
||||
type: BRANCH_GRAPH.type,
|
||||
status: BRANCH_GRAPH.status,
|
||||
});
|
||||
|
||||
expect(restored.nodes).toHaveLength(6);
|
||||
expect(restored.links).toHaveLength(5);
|
||||
|
||||
// condition 节点 category 还原
|
||||
const restoredCondition = restored.nodes.find((n) => Number(n.id) === 2);
|
||||
expect(restoredCondition?.category).toBe('condition');
|
||||
|
||||
// True 边还原
|
||||
const trueLinks = restored.links.filter((l) => l.relationType === 'True');
|
||||
expect(trueLinks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('对称性验证(分支场景)', () => {
|
||||
const first = toCanvas(BRANCH_GRAPH);
|
||||
const restored = fromCanvas(first.nodes, first.edges, {
|
||||
id: BRANCH_GRAPH.id,
|
||||
name: BRANCH_GRAPH.name,
|
||||
type: BRANCH_GRAPH.type,
|
||||
status: BRANCH_GRAPH.status,
|
||||
});
|
||||
const second = toCanvas(restored);
|
||||
|
||||
const sortById = (arr: DagNode[]) =>
|
||||
arr.toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
const sortEdgeById = (arr: DagEdge[]) =>
|
||||
arr.toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const firstNodesSorted = sortById(first.nodes);
|
||||
const secondNodesSorted = sortById(second.nodes);
|
||||
|
||||
expect(secondNodesSorted.map((n) => n.id)).toEqual(
|
||||
firstNodesSorted.map((n) => n.id),
|
||||
);
|
||||
expect(secondNodesSorted.map((n) => n.data?.type)).toEqual(
|
||||
firstNodesSorted.map((n) => n.data?.type),
|
||||
);
|
||||
|
||||
const firstEdgesSorted = sortEdgeById(first.edges);
|
||||
const secondEdgesSorted = sortEdgeById(second.edges);
|
||||
|
||||
expect(secondEdgesSorted.map((e) => e.data?.relationType)).toEqual(
|
||||
firstEdgesSorted.map((e) => e.data?.relationType),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. newNode_tempId ────────────────────────
|
||||
describe('newNode_tempId', () => {
|
||||
it('newTempId 生成 new_ 前缀的 ID', () => {
|
||||
const id = newTempId();
|
||||
expect(id).toMatch(/^new_/);
|
||||
expect(isTempId(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('isTempId 对正式 ID 返回 false', () => {
|
||||
expect(isTempId('123')).toBe(false);
|
||||
expect(isTempId('456')).toBe(false);
|
||||
});
|
||||
|
||||
it('连续生成的 tempId 不重复', () => {
|
||||
const ids = Array.from({ length: 10 }, () => newTempId());
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(10);
|
||||
});
|
||||
|
||||
it('fromCanvas 保留临时 ID 字符串', () => {
|
||||
const tempId = 'new_abc123';
|
||||
const nodes: DagNode[] = [
|
||||
{
|
||||
id: tempId,
|
||||
type: 'actionNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: 'action',
|
||||
providerType: 'notify',
|
||||
label: '新建动作',
|
||||
configuration: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const edges: DagEdge[] = [];
|
||||
const result = fromCanvas(nodes, edges, {
|
||||
id: 99,
|
||||
name: 'test',
|
||||
type: 'SCENE',
|
||||
status: 1,
|
||||
});
|
||||
|
||||
// 临时 ID 应被保留(强制转换为 number 位置上的字符串值)
|
||||
expect(String(result.nodes.at(0)?.id)).toBe(tempId);
|
||||
});
|
||||
|
||||
it('临时 ID 节点的 category 正确映射', () => {
|
||||
const nodes: DagNode[] = [
|
||||
{
|
||||
id: 'new_aaa',
|
||||
type: 'triggerNode',
|
||||
position: { x: 10, y: 20 },
|
||||
data: {
|
||||
type: 'trigger',
|
||||
providerType: 'timer',
|
||||
label: '定时',
|
||||
configuration: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'new_bbb',
|
||||
type: 'actionNode',
|
||||
position: { x: 100, y: 20 },
|
||||
data: {
|
||||
type: 'action',
|
||||
providerType: 'notify',
|
||||
label: '通知',
|
||||
configuration: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const edges: DagEdge[] = [
|
||||
{
|
||||
id: 'new_eee',
|
||||
source: 'new_aaa',
|
||||
target: 'new_bbb',
|
||||
label: 'Success',
|
||||
type: 'smoothstep',
|
||||
data: { relationType: 'Success' },
|
||||
},
|
||||
];
|
||||
|
||||
const result = fromCanvas(nodes, edges, {
|
||||
id: 0,
|
||||
name: 'temp',
|
||||
type: 'SCENE',
|
||||
status: 1,
|
||||
});
|
||||
expect(result.nodes.at(0)?.category).toBe('trigger');
|
||||
expect(result.nodes.at(1)?.category).toBe('action');
|
||||
expect(result.links.at(0)?.relationType).toBe('Success');
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. emptyGraph ────────────────────────────
|
||||
describe('emptyGraph', () => {
|
||||
const EMPTY_GRAPH: RuleChainGraphVO = {
|
||||
id: 99,
|
||||
name: '空规则链',
|
||||
type: 'CUSTOM',
|
||||
status: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
};
|
||||
|
||||
it('toCanvas 返回空 nodes 和 edges', () => {
|
||||
const { nodes, edges } = toCanvas(EMPTY_GRAPH);
|
||||
expect(nodes).toHaveLength(0);
|
||||
expect(edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fromCanvas([], [], meta) 返回空节点和边', () => {
|
||||
const result = fromCanvas([], [], {
|
||||
id: EMPTY_GRAPH.id,
|
||||
name: EMPTY_GRAPH.name,
|
||||
type: EMPTY_GRAPH.type,
|
||||
status: EMPTY_GRAPH.status,
|
||||
});
|
||||
|
||||
expect(result.nodes).toHaveLength(0);
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.id).toBe(99);
|
||||
expect(result.name).toBe('空规则链');
|
||||
expect(result.type).toBe('CUSTOM');
|
||||
});
|
||||
|
||||
it('toCanvas(fromCanvas(toCanvas(emptyGraph))) 对称性', () => {
|
||||
const first = toCanvas(EMPTY_GRAPH);
|
||||
const restored = fromCanvas(first.nodes, first.edges, EMPTY_GRAPH);
|
||||
const second = toCanvas(restored);
|
||||
|
||||
expect(second.nodes).toHaveLength(0);
|
||||
expect(second.edges).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 5. 边缘情况 ──────────────────────────────
|
||||
describe('edge cases', () => {
|
||||
it('positionX/Y 缺失时默认 {x:0, y:0}', () => {
|
||||
const graph: RuleChainGraphVO = {
|
||||
id: 1,
|
||||
name: 'test',
|
||||
type: 'SCENE',
|
||||
status: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
category: 'trigger',
|
||||
type: 'manual',
|
||||
configuration: {},
|
||||
// positionX / positionY 未定义
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
};
|
||||
const { nodes } = toCanvas(graph);
|
||||
expect(nodes.at(0)?.position).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('node.name 缺失时 label 回退为 node.type', () => {
|
||||
const graph: RuleChainGraphVO = {
|
||||
id: 1,
|
||||
name: 'test',
|
||||
type: 'SCENE',
|
||||
status: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
category: 'action',
|
||||
type: 'notify',
|
||||
configuration: {},
|
||||
// name 未定义
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
};
|
||||
const { nodes } = toCanvas(graph);
|
||||
expect(nodes.at(0)?.data?.label).toBe('notify');
|
||||
});
|
||||
|
||||
it('position 浮点坐标取整', () => {
|
||||
const nodes: DagNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'triggerNode',
|
||||
position: { x: 100.7, y: 200.3 },
|
||||
data: { type: 'trigger', label: 'T', configuration: {} },
|
||||
},
|
||||
];
|
||||
const result = fromCanvas(nodes, [], {
|
||||
id: 1,
|
||||
name: 'test',
|
||||
type: 'SCENE',
|
||||
status: 1,
|
||||
});
|
||||
expect(result.nodes.at(0)?.positionX).toBe(101);
|
||||
expect(result.nodes.at(0)?.positionY).toBe(200);
|
||||
});
|
||||
|
||||
it('fromCanvas 边引用不存在节点时抛错', () => {
|
||||
const nodes: DagNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'triggerNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { type: 'trigger', label: 'T', configuration: {} },
|
||||
},
|
||||
];
|
||||
const edges: DagEdge[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
source: '1',
|
||||
target: '999', // 不存在
|
||||
type: 'smoothstep',
|
||||
data: { relationType: 'Success' },
|
||||
},
|
||||
];
|
||||
expect(() => fromCanvas(nodes, edges, {})).toThrow(
|
||||
'does not reference an existing node',
|
||||
);
|
||||
});
|
||||
|
||||
it('relationType 保持 PascalCase(Timeout / Skip / Failure)', () => {
|
||||
const graph: RuleChainGraphVO = {
|
||||
id: 1,
|
||||
name: 'test',
|
||||
type: 'SCENE',
|
||||
status: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
category: 'trigger',
|
||||
type: 'timer',
|
||||
configuration: {},
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: 'action',
|
||||
type: 'notify',
|
||||
configuration: {},
|
||||
positionX: 100,
|
||||
positionY: 0,
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
sourceNodeId: 1,
|
||||
targetNodeId: 2,
|
||||
relationType: 'Timeout',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sourceNodeId: 1,
|
||||
targetNodeId: 2,
|
||||
relationType: 'Skip',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
sourceNodeId: 1,
|
||||
targetNodeId: 2,
|
||||
relationType: 'Failure',
|
||||
},
|
||||
],
|
||||
};
|
||||
const { edges } = toCanvas(graph);
|
||||
expect(edges.map((e) => e.data?.relationType)).toEqual([
|
||||
'Timeout',
|
||||
'Skip',
|
||||
'Failure',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
244
apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.ts
Normal file
244
apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* F8 — DAG JSON ↔ vue-flow 数据格式转换
|
||||
*
|
||||
* 职责:将后端 RuleChainGraphVO 与 vue-flow 的 DagNode[] / DagEdge[] 双向互转。
|
||||
*
|
||||
* 类型映射说明:
|
||||
* - 后端 node.category ('trigger'|'condition'|'action') → DagNodeData.type (DagNodeType)
|
||||
* - 后端 node.type (provider 标识, e.g. 'device_property') → DagNodeData.providerType
|
||||
* - 后端 node.name → DagNodeData.label(回退用 node.type 作为 label)
|
||||
* - 后端 link.relationType (PascalCase) → DagEdgeData.relationType
|
||||
* - 临时节点 id 形如 'new_<uuid>',fromCanvas 时原样保留字符串,后端通过前缀识别 INSERT
|
||||
*
|
||||
* @module dag-converter
|
||||
*/
|
||||
|
||||
import type {
|
||||
DagEdge,
|
||||
DagNode,
|
||||
DagNodeData,
|
||||
DagNodeType,
|
||||
} from '../../../../../components/iot-dag';
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 1. 后端数据模型(B2 就绪后以后端生成的 DTO 为准)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export interface RuleChainNodeVO {
|
||||
category: 'action' | 'condition' | 'trigger';
|
||||
configuration: Record<string, unknown>;
|
||||
id: number;
|
||||
name?: string;
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
/** Provider 类型标识,如 device_property / alarm_trigger / timer 等 */
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface RuleChainLinkVO {
|
||||
id: number;
|
||||
relationType:
|
||||
| 'Failure'
|
||||
| 'False'
|
||||
| 'Skip'
|
||||
| 'Success'
|
||||
| 'Timeout'
|
||||
| 'True'
|
||||
| string;
|
||||
sortOrder?: number;
|
||||
sourceNodeId: number;
|
||||
targetNodeId: number;
|
||||
}
|
||||
|
||||
/** 后端规则链图谱完整 VO(B2 就绪后以后端生成类型为准) */
|
||||
export interface RuleChainGraphVO {
|
||||
deviceId?: number;
|
||||
id: number;
|
||||
links: RuleChainLinkVO[];
|
||||
name: string;
|
||||
nodes: RuleChainNodeVO[];
|
||||
productId?: number;
|
||||
status: number;
|
||||
subsystemId?: number;
|
||||
type: 'CUSTOM' | 'DATA' | 'SCENE';
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 2. 辅助:生成临时 ID
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 为新建节点生成临时 ID(前端尚未经后端分配 BIGINT ID 时使用)。
|
||||
* 格式:`new_<uuid>`,保存时后端需按前缀判断 INSERT / UPDATE。
|
||||
*/
|
||||
export function newTempId(): string {
|
||||
// 使用 Web Crypto API(浏览器 & Node 18+ 均内置)
|
||||
const id =
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
return `new_${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为临时 ID(未经后端分配)。
|
||||
*/
|
||||
export function isTempId(id: string): boolean {
|
||||
return id.startsWith('new_');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 3. toCanvas — 后端 → vue-flow
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 将后端 RuleChainGraphVO 转换为 vue-flow 所需的 nodes + edges。
|
||||
*
|
||||
* - position 若后端未存储则默认 { x: 0, y: 0 }
|
||||
* - DagNode.type 设为 `${category}Node`(triggerNode / conditionNode / actionNode)
|
||||
* 用于匹配 F2 注册的 custom node 组件;category 超出已知枚举时降级为 customNode
|
||||
* - DagNodeData.type 映射自 category(业务类型枚举)
|
||||
* - DagNodeData.providerType 映射自 node.type(Provider 标识)
|
||||
*/
|
||||
export function toCanvas(graph: RuleChainGraphVO): {
|
||||
edges: DagEdge[];
|
||||
nodes: DagNode[];
|
||||
} {
|
||||
const nodes: DagNode[] = graph.nodes.map((n) => {
|
||||
const nodeType = categoryToDagNodeType(n.category);
|
||||
return {
|
||||
id: String(n.id),
|
||||
type: `${n.category}Node`,
|
||||
position: {
|
||||
x: n.positionX ?? 0,
|
||||
y: n.positionY ?? 0,
|
||||
},
|
||||
data: {
|
||||
type: nodeType,
|
||||
providerType: n.type,
|
||||
label: n.name ?? n.type,
|
||||
configuration: n.configuration,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const edges: DagEdge[] = graph.links.map((l) => ({
|
||||
id: String(l.id),
|
||||
source: String(l.sourceNodeId),
|
||||
target: String(l.targetNodeId),
|
||||
label: l.relationType,
|
||||
type: 'smoothstep',
|
||||
data: {
|
||||
relationType: l.relationType,
|
||||
sortOrder: l.sortOrder,
|
||||
},
|
||||
}));
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 4. fromCanvas — vue-flow → 后端
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 将 vue-flow 的 nodes + edges 还原为 RuleChainGraphVO(用于保存到后端)。
|
||||
*
|
||||
* - 节点 position 坐标取整(Math.round),后端以 INT 存储
|
||||
* - 临时 ID(new_xxx):保留原始字符串值写入后端字段(类型断言为 number),
|
||||
* F7(保存逻辑)或后端通过 isTempId 前缀区分 INSERT / UPDATE
|
||||
* - meta 参数用于传入 id/name/type/status 等画布无法感知的元信息
|
||||
*
|
||||
* @throws {Error} 如果 edge 引用了不存在的节点 ID
|
||||
*/
|
||||
export function fromCanvas(
|
||||
nodes: DagNode[],
|
||||
edges: DagEdge[],
|
||||
meta: Partial<RuleChainGraphVO>,
|
||||
): RuleChainGraphVO {
|
||||
// 校验:边的 source/target 必须引用已存在的节点
|
||||
const nodeIdSet = new Set(nodes.map((n) => n.id));
|
||||
for (const e of edges) {
|
||||
if (!nodeIdSet.has(e.source)) {
|
||||
throw new Error(
|
||||
`Edge "${e.id}" source "${e.source}" does not reference an existing node`,
|
||||
);
|
||||
}
|
||||
if (!nodeIdSet.has(e.target)) {
|
||||
throw new Error(
|
||||
`Edge "${e.id}" target "${e.target}" does not reference an existing node`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const backendNodes: RuleChainNodeVO[] = nodes.map((n) => {
|
||||
// n.data 在 vue-flow Node<T> 中为可选,运行时应始终存在(fromCanvas 由受控代码调用)
|
||||
const data = n.data ?? ({ type: 'action', label: '' } as DagNodeData);
|
||||
return {
|
||||
// 临时 ID 保留字符串(类型强制转换,后端通过前缀识别)
|
||||
id: isTempId(n.id) ? (n.id as unknown as number) : Number(n.id),
|
||||
category: dagNodeTypeToCategory(data.type),
|
||||
type: data.providerType ?? data.type,
|
||||
name: data.label,
|
||||
configuration: data.configuration ?? {},
|
||||
positionX: Math.round(n.position.x),
|
||||
positionY: Math.round(n.position.y),
|
||||
};
|
||||
});
|
||||
|
||||
const backendLinks: RuleChainLinkVO[] = edges.map((e) => ({
|
||||
id: isTempId(e.id) ? (e.id as unknown as number) : Number(e.id),
|
||||
sourceNodeId: isTempId(e.source)
|
||||
? (e.source as unknown as number)
|
||||
: Number(e.source),
|
||||
targetNodeId: isTempId(e.target)
|
||||
? (e.target as unknown as number)
|
||||
: Number(e.target),
|
||||
relationType: e.data?.relationType ?? String(e.label ?? ''),
|
||||
sortOrder: e.data?.sortOrder ?? 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: 0,
|
||||
name: '',
|
||||
type: 'SCENE',
|
||||
status: 1,
|
||||
...meta,
|
||||
nodes: backendNodes,
|
||||
links: backendLinks,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 5. 内部辅助:category ↔ DagNodeType 映射
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 后端 category → vue-flow DagNodeType
|
||||
* 未知 category 降级为 'custom'
|
||||
*/
|
||||
function categoryToDagNodeType(category: string): DagNodeType {
|
||||
const map: Record<string, DagNodeType> = {
|
||||
action: 'action',
|
||||
condition: 'condition',
|
||||
trigger: 'trigger',
|
||||
};
|
||||
return map[category] ?? 'custom';
|
||||
}
|
||||
|
||||
/**
|
||||
* vue-flow DagNodeType → 后端 category
|
||||
* branch/custom 在后端归类(branch→condition, custom→action)
|
||||
*/
|
||||
function dagNodeTypeToCategory(
|
||||
nodeType: DagNodeType,
|
||||
): 'action' | 'condition' | 'trigger' {
|
||||
const map: Record<DagNodeType, 'action' | 'condition' | 'trigger'> = {
|
||||
action: 'action',
|
||||
branch: 'condition',
|
||||
condition: 'condition',
|
||||
custom: 'action',
|
||||
trigger: 'trigger',
|
||||
};
|
||||
return map[nodeType] ?? 'action';
|
||||
}
|
||||
Reference in New Issue
Block a user