Merge pull request 'refactor(workflow-dashboard): reduce cyclomatic complexity in editor' (#455) from fix/449-reduce-dashboard-complexity into main
This commit is contained in:
@@ -31,8 +31,10 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"@vitest/ui": "^4.1.7",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^8.0.13"
|
"vite": "^8.0.13",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,6 +43,65 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
|
|||||||
return { outgoing, incoming, inDegree };
|
return { outgoing, incoming, inDegree };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function processTarget(
|
||||||
|
target: string,
|
||||||
|
newLayer: number,
|
||||||
|
layers: Map<string, number>,
|
||||||
|
inDegree: Map<string, number>,
|
||||||
|
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<string, string[]>,
|
||||||
|
inDegree: Map<string, number>,
|
||||||
|
layers: Map<string, number>,
|
||||||
|
): 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<string, number>, 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<string, number>): number {
|
||||||
|
let max = 0;
|
||||||
|
for (const [id, layer] of layers) {
|
||||||
|
if (id !== "end") max = Math.max(max, layer);
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用拓扑排序将节点分层
|
* 使用拓扑排序将节点分层
|
||||||
* - 'start' 节点固定在第 0 层
|
* - 'start' 节点固定在第 0 层
|
||||||
@@ -52,62 +111,15 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
|
|||||||
function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
||||||
const { outgoing, inDegree } = buildGraph(nodes, edges);
|
const { outgoing, inDegree } = buildGraph(nodes, edges);
|
||||||
const layers = new Map<string, number>();
|
const layers = new Map<string, number>();
|
||||||
const queue: string[] = [];
|
|
||||||
|
|
||||||
// 1. start 节点固定在第 0 层
|
|
||||||
layers.set("start", 0);
|
layers.set("start", 0);
|
||||||
queue.push("start");
|
bfsLayers(outgoing, inDegree, layers);
|
||||||
|
|
||||||
// 2. BFS 分层(排除 end 节点,稍后单独处理)
|
const afterBfsMax = maxLayerExcludingEnd(layers);
|
||||||
while (queue.length > 0) {
|
placeIsolatedNodes(nodes, layers, afterBfsMax);
|
||||||
const current = queue.shift() ?? "";
|
|
||||||
const currentLayer = layers.get(current) ?? 0;
|
|
||||||
|
|
||||||
for (const target of outgoing.get(current) ?? []) {
|
const finalMax = maxLayerExcludingEnd(layers);
|
||||||
// 跳过 end 节点,稍后处理
|
layers.set("end", finalMax + 1);
|
||||||
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);
|
|
||||||
|
|
||||||
return layers;
|
return layers;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<AnyWorkNode> = async ({ nodes, edges }) => {
|
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
|
||||||
for (const node of nodes) {
|
if (nodes.some(isProtectedNode)) return false;
|
||||||
if (node.type === "start" || node.type === "end") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (edges.length > 0) {
|
if (edges.length > 0) {
|
||||||
const allEdges = use(edgesModel)[0];
|
const allEdges = use(edgesModel)[0];
|
||||||
for (const edge of edges) {
|
if (edges.some((e) => isFirstConditionalSibling(e, allEdges))) return false;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
model.startTransaction();
|
model.startTransaction();
|
||||||
return true;
|
return true;
|
||||||
@@ -96,25 +97,28 @@ export const handlers = define.memoize((use, model) => {
|
|||||||
use(editNodeViewModel)[1].cancel();
|
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<HTMLDivElement>) {
|
||||||
|
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<HTMLDivElement>) {
|
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||||
if (event.code === "Escape") {
|
if (event.code === "Escape") {
|
||||||
const [addView, addViewActions] = use(addNodeViewModel);
|
handleEscape();
|
||||||
const [editView, editViewActions] = use(editNodeViewModel);
|
|
||||||
if (addView) addViewActions.cancel();
|
|
||||||
if (editView) editViewActions.cancel();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
handleUndoRedo(event);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSteps(steps: WorkFlowSteps) {
|
function loadSteps(steps: WorkFlowSteps) {
|
||||||
|
|||||||
@@ -10,16 +10,15 @@ import {
|
|||||||
import { Input } from "../../components/ui/input.tsx";
|
import { Input } from "../../components/ui/input.tsx";
|
||||||
import { Label } from "../../components/ui/label.tsx";
|
import { Label } from "../../components/ui/label.tsx";
|
||||||
import { Textarea } from "../../components/ui/textarea.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";
|
import type { RoleNodeData } from "../type.ts";
|
||||||
|
|
||||||
type FormProps = {
|
type FormProps = {
|
||||||
state: AddNodeState;
|
|
||||||
onSubmit: (params: { data: RoleNodeData }) => void;
|
onSubmit: (params: { data: RoleNodeData }) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
function Form({ onSubmit, onCancel }: FormProps): ReactNode {
|
||||||
const [name, setName] = useState("新角色");
|
const [name, setName] = useState("新角色");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [identity, setIdentity] = useState("");
|
const [identity, setIdentity] = useState("");
|
||||||
@@ -137,7 +136,7 @@ export function AddNodeDialog(): ReactNode {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||||
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
|
{state && <Form onSubmit={commit} onCancel={cancel} />}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,109 @@ function assignHandles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildNodeMap(
|
||||||
|
steps: WorkFlowStep[],
|
||||||
|
nodes: AnyWorkNode[],
|
||||||
|
): { nameToId: Map<string, string>; idToOrder: Map<string, number> } {
|
||||||
|
const nameToId = new Map<string, string>();
|
||||||
|
const idToOrder = new Map<string, number>();
|
||||||
|
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<string, string>,
|
||||||
|
): { 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<string, number>,
|
||||||
|
): 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<string, number>): void {
|
||||||
|
const incomingByTarget = new Map<string, number[]>();
|
||||||
|
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 {
|
export function transIn(steps: WorkFlowStep[]): Result {
|
||||||
const startNode: AnyWorkNode = {
|
const startNode: AnyWorkNode = {
|
||||||
id: "start",
|
id: "start",
|
||||||
@@ -42,30 +145,12 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
|||||||
position: { x: 250, y: 0 },
|
position: { x: 250, y: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (steps.length === 0) {
|
if (steps.length === 0) return { nodes: [startNode, endNode], edges: [] };
|
||||||
return { nodes: [startNode, endNode], edges: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes: AnyWorkNode[] = [startNode, endNode];
|
const nodes: AnyWorkNode[] = [startNode, endNode];
|
||||||
const edges: AnyWorkEdge[] = [];
|
const edges: AnyWorkEdge[] = [];
|
||||||
const nameToId = new Map<string, string>();
|
|
||||||
const idToOrder = new Map<string, number>();
|
|
||||||
nameToId.set("END", "end");
|
|
||||||
idToOrder.set("start", -1);
|
|
||||||
idToOrder.set("end", steps.length);
|
|
||||||
|
|
||||||
for (let si = 0; si < steps.length; si++) {
|
const { nameToId, idToOrder } = buildNodeMap(steps, nodes);
|
||||||
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 firstStepId = nameToId.get(steps[0].role.name) ?? "";
|
const firstStepId = nameToId.get(steps[0].role.name) ?? "";
|
||||||
edges.push({
|
edges.push({
|
||||||
@@ -79,88 +164,11 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
|||||||
|
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const sourceId = nameToId.get(step.role.name) ?? "";
|
const sourceId = nameToId.get(step.role.name) ?? "";
|
||||||
const _sourceOrder = idToOrder.get(sourceId) ?? 0;
|
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||||
const hasMultipleTransitions = step.transitions.length > 1;
|
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
|
||||||
|
|
||||||
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] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// in: group by target, sort by source order asc (leftmost first), assign input > input-top > input-bottom
|
assignTargetHandles(edges, idToOrder);
|
||||||
const incomingByTarget = new Map<string, number[]>();
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
function validateRoleNodes(
|
||||||
roleNodes: AnyWorkNode[],
|
roleNodes: AnyWorkNode[],
|
||||||
outgoing: Map<string, AnyWorkEdge[]>,
|
outgoing: Map<string, AnyWorkEdge[]>,
|
||||||
@@ -98,31 +128,7 @@ function validateRoleNodes(
|
|||||||
errors: ValidationError[],
|
errors: ValidationError[],
|
||||||
): void {
|
): void {
|
||||||
for (const node of roleNodes) {
|
for (const node of roleNodes) {
|
||||||
const inEdges = incoming.get(node.id) ?? [];
|
validateRoleNodeEdges(node, outgoing.get(node.id) ?? [], incoming.get(node.id) ?? [], errors);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user