diff --git a/packages/workflow-dashboard/package.json b/packages/workflow-dashboard/package.json index daae1d5..ffa5288 100644 --- a/packages/workflow-dashboard/package.json +++ b/packages/workflow-dashboard/package.json @@ -31,8 +31,10 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", + "@vitest/ui": "^4.1.7", "tailwindcss": "^4.2.4", "typescript": "^5.8.3", - "vite": "^8.0.13" + "vite": "^8.0.13", + "vitest": "^4.1.7" } } diff --git a/packages/workflow-dashboard/src/editor/layout/__tests__/layout.test.ts b/packages/workflow-dashboard/src/editor/layout/__tests__/layout.test.ts new file mode 100644 index 0000000..9c6e610 --- /dev/null +++ b/packages/workflow-dashboard/src/editor/layout/__tests__/layout.test.ts @@ -0,0 +1,83 @@ +import type { Edge, Node } from "@xyflow/react"; +import { describe, expect, it } from "vitest"; +import { LayoutLR } from "../index.js"; + +function makeNode(id: string): Node { + return { id, type: "role", data: {}, position: { x: 0, y: 0 } } as Node; +} + +function makeEdge(source: string, target: string): Edge { + return { id: `${source}-${target}`, source, target } as Edge; +} + +describe("LayoutLR / assignLayers", () => { + it("1.1 Empty graph: start gets layer 0, end gets higher layer", () => { + const nodes = [makeNode("start"), makeNode("end")]; + const result = LayoutLR(nodes, []); + const start = result.find((n) => n.id === "start"); + const end = result.find((n) => n.id === "end"); + // start has no position change necessarily, but positions should be assigned + expect(start).toBeDefined(); + expect(end).toBeDefined(); + // end should be to the right of start + expect((end?.position.x ?? 0) > (start?.position.x ?? 0)).toBe(true); + }); + + it("1.2 Linear chain: start → A → B → end — layers assigned in order", () => { + const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("end")]; + const edges = [makeEdge("start", "A"), makeEdge("A", "B"), makeEdge("B", "end")]; + const result = LayoutLR(nodes, edges); + const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0; + expect(xOf("start") < xOf("A")).toBe(true); + expect(xOf("A") < xOf("B")).toBe(true); + expect(xOf("B") < xOf("end")).toBe(true); + }); + + it("1.3 Diamond: A and B share same layer", () => { + const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("C"), makeNode("end")]; + const edges = [ + makeEdge("start", "A"), + makeEdge("start", "B"), + makeEdge("A", "C"), + makeEdge("B", "C"), + makeEdge("C", "end"), + ]; + const result = LayoutLR(nodes, edges); + const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0; + expect(xOf("A")).toBe(xOf("B")); // same layer + expect(xOf("A") < xOf("C")).toBe(true); + expect(xOf("C") < xOf("end")).toBe(true); + }); + + it("1.4 Isolated node placed in middle layer (not layer 0, not end layer)", () => { + const nodes = [makeNode("start"), makeNode("A"), makeNode("isolated"), makeNode("end")]; + const edges = [makeEdge("start", "A"), makeEdge("A", "end")]; + const result = LayoutLR(nodes, edges); + const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0; + const xIsolated = xOf("isolated"); + expect(xIsolated > xOf("start")).toBe(true); + expect(xIsolated < xOf("end")).toBe(true); + }); + + it("1.5 end node is always last (highest x)", () => { + const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("end")]; + const edges = [makeEdge("start", "A"), makeEdge("A", "B"), makeEdge("B", "end")]; + const result = LayoutLR(nodes, edges); + const endX = result.find((n) => n.id === "end")?.position.x ?? 0; + for (const node of result) { + if (node.id !== "end") { + expect(node.position.x < endX).toBe(true); + } + } + }); + + it("1.6 start node is always first (x = 0 or smallest x)", () => { + const nodes = [makeNode("start"), makeNode("A"), makeNode("end")]; + const edges = [makeEdge("start", "A"), makeEdge("A", "end")]; + const result = LayoutLR(nodes, edges); + const startX = result.find((n) => n.id === "start")?.position.x ?? 0; + for (const node of result) { + expect(node.position.x >= startX).toBe(true); + } + }); +}); diff --git a/packages/workflow-dashboard/src/editor/layout/index.ts b/packages/workflow-dashboard/src/editor/layout/index.ts index 00e989d..7bd9d54 100644 --- a/packages/workflow-dashboard/src/editor/layout/index.ts +++ b/packages/workflow-dashboard/src/editor/layout/index.ts @@ -43,6 +43,65 @@ function buildGraph(nodes: Node[], edges: Edge[]) { return { outgoing, incoming, inDegree }; } +function processTarget( + target: string, + newLayer: number, + layers: Map, + inDegree: Map, + queue: string[], +): void { + const existingLayer = layers.get(target); + if (existingLayer === undefined) { + layers.set(target, newLayer); + inDegree.set(target, (inDegree.get(target) ?? 1) - 1); + if (inDegree.get(target) === 0) queue.push(target); + } else { + layers.set(target, Math.max(existingLayer, newLayer)); + } +} + +/** + * BFS 分层(排除 end 节点,稍后单独处理) + */ +function bfsLayers( + outgoing: Map, + inDegree: Map, + layers: Map, +): void { + const queue: string[] = ["start"]; + while (queue.length > 0) { + const current = queue.shift() ?? ""; + const currentLayer = layers.get(current) ?? 0; + for (const target of outgoing.get(current) ?? []) { + if (target === "end") continue; + processTarget(target, currentLayer + 1, layers, inDegree, queue); + } + } +} + +/** + * 处理孤立节点(没有被分配层级的非 start/end 节点),放在中间层 + */ +function placeIsolatedNodes(nodes: Node[], layers: Map, maxLayer: number): void { + const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2)); + for (const node of nodes) { + if (node.id !== "start" && node.id !== "end" && !layers.has(node.id)) { + layers.set(node.id, middleLayer); + } + } +} + +/** + * 计算最大层级(排除 end 节点) + */ +function maxLayerExcludingEnd(layers: Map): number { + let max = 0; + for (const [id, layer] of layers) { + if (id !== "end") max = Math.max(max, layer); + } + return max; +} + /** * 使用拓扑排序将节点分层 * - 'start' 节点固定在第 0 层 @@ -52,62 +111,15 @@ function buildGraph(nodes: Node[], edges: Edge[]) { function assignLayers(nodes: Node[], edges: Edge[]): Map { const { outgoing, inDegree } = buildGraph(nodes, edges); const layers = new Map(); - const queue: string[] = []; - // 1. start 节点固定在第 0 层 layers.set("start", 0); - queue.push("start"); + bfsLayers(outgoing, inDegree, layers); - // 2. BFS 分层(排除 end 节点,稍后单独处理) - while (queue.length > 0) { - const current = queue.shift() ?? ""; - const currentLayer = layers.get(current) ?? 0; + const afterBfsMax = maxLayerExcludingEnd(layers); + placeIsolatedNodes(nodes, layers, afterBfsMax); - for (const target of outgoing.get(current) ?? []) { - // 跳过 end 节点,稍后处理 - if (target === "end") continue; - - const newLayer = currentLayer + 1; - const existingLayer = layers.get(target); - - if (existingLayer === undefined) { - layers.set(target, newLayer); - inDegree.set(target, (inDegree.get(target) ?? 1) - 1); - if (inDegree.get(target) === 0) { - queue.push(target); - } - } else { - // 如果已有层级,取更大的值(确保所有前驱都在前面) - layers.set(target, Math.max(existingLayer, newLayer)); - } - } - } - - // 3. 找到当前最大层级 - let maxLayer = 0; - for (const layer of layers.values()) { - maxLayer = Math.max(maxLayer, layer); - } - - // 4. 处理孤立节点(没有被分配层级的非 start/end 节点) - // 把它们放在中间层 - const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2)); - for (const node of nodes) { - if (node.id !== "start" && node.id !== "end" && !layers.has(node.id)) { - layers.set(node.id, middleLayer); - } - } - - // 5. 重新计算最大层级(可能因为孤立节点而变化) - maxLayer = 0; - for (const [id, layer] of layers) { - if (id !== "end") { - maxLayer = Math.max(maxLayer, layer); - } - } - - // 6. end 节点固定在最后一层 - layers.set("end", maxLayer + 1); + const finalMax = maxLayerExcludingEnd(layers); + layers.set("end", finalMax + 1); return layers; } diff --git a/packages/workflow-dashboard/src/editor/model/handlers.ts b/packages/workflow-dashboard/src/editor/model/handlers.ts index bd2a4fb..c29410d 100644 --- a/packages/workflow-dashboard/src/editor/model/handlers.ts +++ b/packages/workflow-dashboard/src/editor/model/handlers.ts @@ -30,23 +30,24 @@ export const handlers = define.memoize((use, model) => { }); }; + function isProtectedNode(node: AnyWorkNode): boolean { + return node.type === "start" || node.type === "end"; + } + + function isFirstConditionalSibling( + edge: { id: string; source: string; type: string | null }, + allEdges: { id: string; source: string; type: string | null }[], + ): boolean { + if (edge.type !== "conditional") return false; + const siblings = allEdges.filter((e) => e.source === edge.source && e.type === "conditional"); + return siblings.length >= 2 && siblings[0].id === edge.id; + } + const onBeforeDelete: OnBeforeDelete = async ({ nodes, edges }) => { - for (const node of nodes) { - if (node.type === "start" || node.type === "end") { - return false; - } - } + if (nodes.some(isProtectedNode)) return false; if (edges.length > 0) { const allEdges = use(edgesModel)[0]; - for (const edge of edges) { - if (edge.type !== "conditional") continue; - const siblings = allEdges.filter( - (e) => e.source === edge.source && e.type === "conditional", - ); - if (siblings.length >= 2 && siblings[0].id === edge.id) { - return false; - } - } + if (edges.some((e) => isFirstConditionalSibling(e, allEdges))) return false; } model.startTransaction(); return true; @@ -96,25 +97,28 @@ export const handlers = define.memoize((use, model) => { use(editNodeViewModel)[1].cancel(); } + function handleEscape() { + const [addView, addViewActions] = use(addNodeViewModel); + const [editView, editViewActions] = use(editNodeViewModel); + if (addView) addViewActions.cancel(); + if (editView) editViewActions.cancel(); + } + + function handleUndoRedo(event: React.KeyboardEvent) { + if (event.code === "KeyZ" && (event.ctrlKey || event.metaKey)) { + if (event.shiftKey) model.redo(); + else model.undo(); + } else if (event.code === "KeyY" && (event.ctrlKey || event.metaKey)) { + model.redo(); + } + } + function handleKeyDown(event: React.KeyboardEvent) { if (event.code === "Escape") { - const [addView, addViewActions] = use(addNodeViewModel); - const [editView, editViewActions] = use(editNodeViewModel); - if (addView) addViewActions.cancel(); - if (editView) editViewActions.cancel(); + handleEscape(); return; } - - if (event.code === "KeyZ") { - if (event.ctrlKey || event.metaKey) { - if (event.shiftKey) model.redo(); - else model.undo(); - } - } else if (event.code === "KeyY") { - if (event.ctrlKey || event.metaKey) { - model.redo(); - } - } + handleUndoRedo(event); } function loadSteps(steps: WorkFlowSteps) { diff --git a/packages/workflow-dashboard/src/editor/panel/add-node.tsx b/packages/workflow-dashboard/src/editor/panel/add-node.tsx index e64b5e2..abc9437 100644 --- a/packages/workflow-dashboard/src/editor/panel/add-node.tsx +++ b/packages/workflow-dashboard/src/editor/panel/add-node.tsx @@ -10,16 +10,15 @@ import { import { Input } from "../../components/ui/input.tsx"; import { Label } from "../../components/ui/label.tsx"; import { Textarea } from "../../components/ui/textarea.tsx"; -import { type AddNodeState, addNodeViewModel } from "../model/index.ts"; +import { addNodeViewModel } from "../model/index.ts"; import type { RoleNodeData } from "../type.ts"; type FormProps = { - state: AddNodeState; onSubmit: (params: { data: RoleNodeData }) => void; onCancel: () => void; }; -function Form({ state, onSubmit, onCancel }: FormProps): ReactNode { +function Form({ onSubmit, onCancel }: FormProps): ReactNode { const [name, setName] = useState("新角色"); const [description, setDescription] = useState(""); const [identity, setIdentity] = useState(""); @@ -137,7 +136,7 @@ export function AddNodeDialog(): ReactNode { }} > - {state &&
} + {state && } ); diff --git a/packages/workflow-dashboard/src/editor/trans/__tests__/trans-in.test.ts b/packages/workflow-dashboard/src/editor/trans/__tests__/trans-in.test.ts new file mode 100644 index 0000000..fd14778 --- /dev/null +++ b/packages/workflow-dashboard/src/editor/trans/__tests__/trans-in.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { transIn } from "../trans-in.js"; +import type { WorkFlowStep } from "../type.js"; + +function makeStep(name: string, transitions: WorkFlowStep["transitions"]): WorkFlowStep { + return { + role: { + name, + description: "", + identity: "", + prepare: "", + execute: "", + report: "", + }, + transitions, + }; +} + +describe("transIn", () => { + it("4.1 Empty steps → start + end nodes, no edges", () => { + const { nodes, edges } = transIn([]); + expect(nodes).toHaveLength(2); + expect(nodes.find((n) => n.id === "start")).toBeDefined(); + expect(nodes.find((n) => n.id === "end")).toBeDefined(); + expect(edges).toHaveLength(0); + }); + + it("4.2 Single step with no END transition → start→role edge exists", () => { + const steps = [makeStep("A", [])]; + const { nodes, edges } = transIn(steps); + expect(nodes).toHaveLength(3); // start, end, role-A + const startEdge = edges.find((e) => e.source === "start"); + expect(startEdge).toBeDefined(); + const roleNode = nodes.find((n) => n.type === "role"); + expect(startEdge?.target).toBe(roleNode?.id); + }); + + it("4.3 Single step with END transition → edge to end node exists", () => { + const steps = [makeStep("A", [{ condition: null, target: "END" }])]; + const { edges } = transIn(steps); + const endEdge = edges.find((e) => e.target === "end"); + expect(endEdge).toBeDefined(); + }); + + it("4.4 Two steps with default transitions chain", () => { + const steps = [ + makeStep("A", [{ condition: null, target: "B" }]), + makeStep("B", [{ condition: null, target: "END" }]), + ]; + const { edges } = transIn(steps); + // Should have start→A, A→B, B→end + expect(edges.find((e) => e.source === "start")).toBeDefined(); + const nodeAId = edges.find((e) => e.source === "start")?.target; + expect(edges.find((e) => e.source === nodeAId && e.target !== "end")).toBeDefined(); + expect(edges.find((e) => e.target === "end")).toBeDefined(); + // No conditional edges + expect(edges.every((e) => e.type !== "conditional")).toBe(true); + }); + + it("4.5 Step with multiple transitions → conditional edges", () => { + const steps = [ + makeStep("A", [ + { condition: null, target: "B" }, + { condition: "x>0", target: "C" }, + ]), + makeStep("B", []), + makeStep("C", []), + ]; + const { edges } = transIn(steps); + const nodeAId = edges.find((e) => e.source === "start")?.target; + const outEdges = edges.filter((e) => e.source === nodeAId); + expect(outEdges.every((e) => e.type === "conditional")).toBe(true); + // else-branch has empty condition + const elseEdge = outEdges.find( + (e) => (e as { data?: { condition?: string } }).data?.condition === "", + ); + expect(elseEdge).toBeDefined(); + // if-branch has condition + const ifEdge = outEdges.find( + (e) => (e as { data?: { condition?: string } }).data?.condition === "x>0", + ); + expect(ifEdge).toBeDefined(); + }); + + it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => { + const steps = [ + makeStep("A", [{ condition: null, target: "END" }]), + makeStep("B", [{ condition: null, target: "END" }]), + ]; + const { edges } = transIn(steps); + // start→A and start→B; end has 2 incoming edges + const incomingToEnd = edges.filter((e) => e.target === "end"); + expect(incomingToEnd[0].targetHandle).toBe("input"); + }); + + it("4.7 Same role name maps to same node id across steps", () => { + const steps = [ + makeStep("A", [{ condition: null, target: "B" }]), + makeStep("B", [{ condition: null, target: "A" }]), + ]; + const { edges } = transIn(steps); + const aId = edges.find((e) => e.source === "start")?.target; + // B→A edge target should be same node as start→A edge target + const bToAEdge = edges.find( + (e) => e.source !== "start" && e.target === aId && e.target !== "end", + ); + expect(bToAEdge).toBeDefined(); + }); +}); diff --git a/packages/workflow-dashboard/src/editor/trans/__tests__/validate.test.ts b/packages/workflow-dashboard/src/editor/trans/__tests__/validate.test.ts new file mode 100644 index 0000000..0196dd3 --- /dev/null +++ b/packages/workflow-dashboard/src/editor/trans/__tests__/validate.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import type { AnyWorkEdge, AnyWorkNode } from "../../type.js"; +import { validate } from "../validate.js"; + +function roleNode(id: string): AnyWorkNode { + return { + id, + type: "role", + data: { name: id, description: "", identity: "", prepare: "", execute: "", report: "" }, + position: { x: 0, y: 0 }, + } as AnyWorkNode; +} + +function startNode(): AnyWorkNode { + return { + id: "start", + type: "start", + data: { label: "Start" }, + position: { x: 0, y: 0 }, + } as AnyWorkNode; +} + +function endNode(): AnyWorkNode { + return { + id: "end", + type: "end", + data: { label: "End" }, + position: { x: 0, y: 0 }, + } as AnyWorkNode; +} + +function defaultEdge(source: string, target: string): AnyWorkEdge { + return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge; +} + +function conditionalEdge(source: string, target: string, condition: string): AnyWorkEdge { + return { + id: `${source}-${target}-cond`, + source, + target, + type: "conditional" as const, + data: { condition }, + animated: true, + } as AnyWorkEdge; +} + +// Helper: build a minimal valid graph with 2 role nodes for validateRoleNodes tests +function baseNodes(...roles: AnyWorkNode[]): AnyWorkNode[] { + return [startNode(), ...roles, endNode()]; +} + +describe("validateRoleNodes (via validate)", () => { + it("5.1 Role node with no incoming edge → error about missing input", () => { + const n1 = roleNode("n1"); + const n2 = roleNode("n2"); + const nodes = baseNodes(n1, n2); + // n1 has no incoming, n2 has incoming+outgoing + const edges = [defaultEdge("start", "n2"), defaultEdge("n1", "end"), defaultEdge("n2", "end")]; + const result = validate(nodes, edges); + const nodeErrors = result.errors.filter((e) => e.nodeId === "n1"); + expect(nodeErrors.some((e) => e.message.includes("缺少输入连接"))).toBe(true); + }); + + it("5.2 Role node with no outgoing edge → error about missing output", () => { + const n1 = roleNode("n1"); + const n2 = roleNode("n2"); + const nodes = baseNodes(n1, n2); + const edges = [ + defaultEdge("start", "n1"), + defaultEdge("start", "n2"), + defaultEdge("n2", "end"), + // n1 has no outgoing + ]; + const result = validate(nodes, edges); + const nodeErrors = result.errors.filter((e) => e.nodeId === "n1"); + expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true); + }); + + it("5.3 Empty condition on non-first conditional edge → error", () => { + const n1 = roleNode("n1"); + const n2 = roleNode("n2"); + const n3 = roleNode("n3"); + const nodes = baseNodes(n1, n2, n3); + const edges = [ + defaultEdge("start", "n1"), + conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt + conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → error + defaultEdge("n2", "end"), + defaultEdge("n3", "end"), + ]; + const result = validate(nodes, edges); + expect(result.errors.some((e) => e.message.includes("条件表达式不能为空"))).toBe(true); + }); + + it("5.4 Mix of conditional and non-conditional outgoing → error", () => { + const n1 = roleNode("n1"); + const n2 = roleNode("n2"); + const n3 = roleNode("n3"); + const nodes = baseNodes(n1, n2, n3); + const edges = [ + defaultEdge("start", "n1"), + conditionalEdge("n1", "n2", "x>0"), + defaultEdge("n1", "n3"), // mix → error + defaultEdge("n2", "end"), + defaultEdge("n3", "end"), + ]; + const result = validate(nodes, edges); + expect(result.errors.some((e) => e.message.includes("所有出边必须附带条件"))).toBe(true); + }); + + it("5.5 Valid role node (1 in, 1 out default) → no errors for that node", () => { + const n1 = roleNode("n1"); + const n2 = roleNode("n2"); + const nodes = baseNodes(n1, n2); + const edges = [defaultEdge("start", "n1"), defaultEdge("n1", "n2"), defaultEdge("n2", "end")]; + const result = validate(nodes, edges); + const roleErrors = result.errors.filter((e) => e.nodeId === "n1" || e.nodeId === "n2"); + expect(roleErrors).toHaveLength(0); + }); + + it("5.6 Valid role node (1 in, 2 conditional out with conditions) → no errors", () => { + const n1 = roleNode("n1"); + const n2 = roleNode("n2"); + const n3 = roleNode("n3"); + const nodes = baseNodes(n1, n2, n3); + const edges = [ + defaultEdge("start", "n1"), + conditionalEdge("n1", "n2", ""), // else-branch + conditionalEdge("n1", "n3", "x>0"), // if-branch + defaultEdge("n2", "end"), + defaultEdge("n3", "end"), + ]; + const result = validate(nodes, edges); + const n1Errors = result.errors.filter((e) => e.nodeId === "n1"); + expect(n1Errors).toHaveLength(0); + }); +}); diff --git a/packages/workflow-dashboard/src/editor/trans/trans-in.ts b/packages/workflow-dashboard/src/editor/trans/trans-in.ts index 9793647..1d3807b 100644 --- a/packages/workflow-dashboard/src/editor/trans/trans-in.ts +++ b/packages/workflow-dashboard/src/editor/trans/trans-in.ts @@ -28,6 +28,109 @@ function assignHandles( } } +function buildNodeMap( + steps: WorkFlowStep[], + nodes: AnyWorkNode[], +): { nameToId: Map; idToOrder: Map } { + const nameToId = new Map(); + const idToOrder = new Map(); + nameToId.set("END", "end"); + idToOrder.set("start", -1); + idToOrder.set("end", steps.length); + for (let si = 0; si < steps.length; si++) { + const step = steps[si]; + const nodeId = `n${uuid()}`; + nameToId.set(step.role.name, nodeId); + idToOrder.set(nodeId, si); + nodes.push({ id: nodeId, type: "role", data: { ...step.role }, position: { x: 0, y: 0 } }); + } + return { nameToId, idToOrder }; +} + +function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] { + if (step.transitions.length <= 1) return step.transitions; + return [...step.transitions].sort((a, b) => { + if (a.condition === null && b.condition !== null) return -1; + if (a.condition !== null && b.condition === null) return 1; + return 0; + }); +} + +function buildStepEdges( + sourceId: string, + step: WorkFlowStep, + nameToId: Map, +): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } { + const hasMultiple = step.transitions.length > 1; + const sorted = sortTransitions(step); + const elseEdges: AnyWorkEdge[] = []; + const ifEdges: AnyWorkEdge[] = []; + + for (let i = 0; i < sorted.length; i++) { + const t = sorted[i]; + const targetId = nameToId.get(t.target); + if (!targetId) continue; + const edgeId = `e-${sourceId}-${targetId}-${i}`; + if (hasMultiple || t.condition !== null) { + const edge: ConditionalEdge = { + id: edgeId, + source: sourceId, + target: targetId, + sourceHandle: "output", + targetHandle: "input", + type: "conditional", + data: { condition: t.condition ?? "" }, + animated: true, + }; + if (hasMultiple && i === 0) elseEdges.push(edge); + else ifEdges.push(edge); + } else { + elseEdges.push({ + id: edgeId, + source: sourceId, + target: targetId, + sourceHandle: "output", + targetHandle: "input", + animated: true, + }); + } + } + return { elseEdges, ifEdges }; +} + +function pushStepEdges( + edges: AnyWorkEdge[], + elseEdges: AnyWorkEdge[], + ifEdges: AnyWorkEdge[], + idToOrder: Map, +): void { + for (const e of elseEdges) edges.push({ ...e, sourceHandle: "output" }); + if (ifEdges.length > 0) { + const ifHandles = ["output-top", "output-bottom"] as const; + const sorted = [...ifEdges].sort( + (a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0), + ); + for (let i = 0; i < sorted.length; i++) { + edges.push({ ...sorted[i], sourceHandle: ifHandles[i % ifHandles.length] }); + } + } +} + +function assignTargetHandles(edges: AnyWorkEdge[], idToOrder: Map): void { + const incomingByTarget = new Map(); + for (let i = 0; i < edges.length; i++) { + const target = edges[i].target; + if (!incomingByTarget.has(target)) incomingByTarget.set(target, []); + incomingByTarget.get(target)?.push(i); + } + for (const indices of incomingByTarget.values()) { + indices.sort( + (a, b) => (idToOrder.get(edges[a].source) ?? 0) - (idToOrder.get(edges[b].source) ?? 0), + ); + assignHandles(indices, edges, IN_HANDLES, "targetHandle"); + } +} + export function transIn(steps: WorkFlowStep[]): Result { const startNode: AnyWorkNode = { id: "start", @@ -42,30 +145,12 @@ export function transIn(steps: WorkFlowStep[]): Result { position: { x: 250, y: 0 }, }; - if (steps.length === 0) { - return { nodes: [startNode, endNode], edges: [] }; - } + if (steps.length === 0) return { nodes: [startNode, endNode], edges: [] }; const nodes: AnyWorkNode[] = [startNode, endNode]; const edges: AnyWorkEdge[] = []; - const nameToId = new Map(); - const idToOrder = new Map(); - nameToId.set("END", "end"); - idToOrder.set("start", -1); - idToOrder.set("end", steps.length); - for (let si = 0; si < steps.length; si++) { - const step = steps[si]; - const nodeId = `n${uuid()}`; - nameToId.set(step.role.name, nodeId); - idToOrder.set(nodeId, si); - nodes.push({ - id: nodeId, - type: "role", - data: { ...step.role }, - position: { x: 0, y: 0 }, - }); - } + const { nameToId, idToOrder } = buildNodeMap(steps, nodes); const firstStepId = nameToId.get(steps[0].role.name) ?? ""; edges.push({ @@ -79,88 +164,11 @@ export function transIn(steps: WorkFlowStep[]): Result { for (const step of steps) { const sourceId = nameToId.get(step.role.name) ?? ""; - const _sourceOrder = idToOrder.get(sourceId) ?? 0; - const hasMultipleTransitions = step.transitions.length > 1; - - const sorted = hasMultipleTransitions - ? [...step.transitions].sort((a, b) => { - if (a.condition === null && b.condition !== null) return -1; - if (a.condition !== null && b.condition === null) return 1; - return 0; - }) - : step.transitions; - - const elseEdges: AnyWorkEdge[] = []; - const ifEdges: AnyWorkEdge[] = []; - - for (let i = 0; i < sorted.length; i++) { - const t = sorted[i]; - const targetId = nameToId.get(t.target); - if (!targetId) continue; - - const edgeId = `e-${sourceId}-${targetId}-${i}`; - - if (hasMultipleTransitions || t.condition !== null) { - const edge: ConditionalEdge = { - id: edgeId, - source: sourceId, - target: targetId, - sourceHandle: "output", - targetHandle: "input", - type: "conditional", - data: { condition: t.condition ?? "" }, - animated: true, - }; - if (hasMultipleTransitions && i === 0) { - elseEdges.push(edge); - } else { - ifEdges.push(edge); - } - } else { - elseEdges.push({ - id: edgeId, - source: sourceId, - target: targetId, - sourceHandle: "output", - targetHandle: "input", - animated: true, - }); - } - } - - // out: else → output (right); if → sort by target order desc (rightmost first), then top/bottom - for (const e of elseEdges) { - edges.push({ ...e, sourceHandle: "output" }); - } - if (ifEdges.length > 0) { - const sortedIf = [...ifEdges].sort((a, b) => { - const oa = idToOrder.get(a.target) ?? 0; - const ob = idToOrder.get(b.target) ?? 0; - return ob - oa; - }); - const ifHandles = ["output-top", "output-bottom"] as const; - for (let i = 0; i < sortedIf.length; i++) { - edges.push({ ...sortedIf[i], sourceHandle: ifHandles[i % ifHandles.length] }); - } - } + const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId); + pushStepEdges(edges, elseEdges, ifEdges, idToOrder); } - // in: group by target, sort by source order asc (leftmost first), assign input > input-top > input-bottom - const incomingByTarget = new Map(); - for (let i = 0; i < edges.length; i++) { - const target = edges[i].target; - if (!incomingByTarget.has(target)) incomingByTarget.set(target, []); - incomingByTarget.get(target)?.push(i); - } - - for (const indices of incomingByTarget.values()) { - indices.sort((a, b) => { - const oa = idToOrder.get(edges[a].source) ?? 0; - const ob = idToOrder.get(edges[b].source) ?? 0; - return oa - ob; - }); - assignHandles(indices, edges, IN_HANDLES, "targetHandle"); - } + assignTargetHandles(edges, idToOrder); return { nodes, edges }; } diff --git a/packages/workflow-dashboard/src/editor/trans/validate.ts b/packages/workflow-dashboard/src/editor/trans/validate.ts index 9d929ea..0bf8c66 100644 --- a/packages/workflow-dashboard/src/editor/trans/validate.ts +++ b/packages/workflow-dashboard/src/editor/trans/validate.ts @@ -91,6 +91,36 @@ function validateEndNode( } } +function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean { + return conditionalEdges.slice(1).some((edge) => { + const cond = (edge as ConditionalEdge).data?.condition?.trim(); + return !cond; + }); +} + +function validateRoleNodeEdges( + node: AnyWorkNode, + outEdges: AnyWorkEdge[], + inEdges: AnyWorkEdge[], + errors: ValidationError[], +): void { + if (inEdges.length === 0) { + errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" }); + } + if (outEdges.length === 0) { + errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" }); + return; + } + if (outEdges.length <= 1) return; + + const conditionalEdges = outEdges.filter((e) => e.type === "conditional"); + if (conditionalEdges.length !== outEdges.length) { + errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" }); + } else if (hasEmptyConditionOnIfEdge(conditionalEdges)) { + errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" }); + } +} + function validateRoleNodes( roleNodes: AnyWorkNode[], outgoing: Map, @@ -98,31 +128,7 @@ function validateRoleNodes( errors: ValidationError[], ): void { for (const node of roleNodes) { - const inEdges = incoming.get(node.id) ?? []; - const outEdges = outgoing.get(node.id) ?? []; - - if (inEdges.length === 0) { - errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" }); - } - if (outEdges.length === 0) { - errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" }); - } - - if (outEdges.length > 1) { - const conditionalEdges = outEdges.filter((e) => e.type === "conditional"); - if (conditionalEdges.length !== outEdges.length) { - errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" }); - } else { - const ifEdges = conditionalEdges.slice(1); - for (const edge of ifEdges) { - const condEdge = edge as ConditionalEdge; - if (!condEdge.data?.condition?.trim()) { - errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" }); - break; - } - } - } - } + validateRoleNodeEdges(node, outgoing.get(node.id) ?? [], incoming.get(node.id) ?? [], errors); } } diff --git a/packages/workflow-dashboard/vitest.config.ts b/packages/workflow-dashboard/vitest.config.ts new file mode 100644 index 0000000..6d04e9b --- /dev/null +++ b/packages/workflow-dashboard/vitest.config.ts @@ -0,0 +1,15 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +// biome-ignore lint/style/noDefaultExport: Vitest loads config from default export. +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/__tests__/**/*.test.ts"], + }, + resolve: { + alias: { + "@": path.resolve(import.meta.dirname, "./src"), + }, + }, +});