Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import type { WorkflowNode } from '../../../parsers/models/workflowNode';
import type { WorkflowState } from '../workflowInterfaces';
import { getWorkflowNodeFromGraphState, getWorkflowGraphPath, buildNodeIndex } from '../workflowGraphTraversal';

/**
* Builds a synthetic tree graph with the given breadth and depth.
* Node IDs follow the pattern "node-{level}-{index}".
*/
const buildGraph = (breadth: number, depth: number): WorkflowNode => {
const build = (level: number, prefix: string): WorkflowNode[] => {
if (level >= depth) {
return [];
}
return Array.from({ length: breadth }, (_, index) => {
const id = `${prefix}-${index}`;
return {
id,
type: 'OPERATION_NODE' as const,
children: build(level + 1, id),
};
});
};

return {
id: 'root',
type: 'GRAPH_NODE' as const,
children: build(0, 'node'),
};
};

/**
* Wraps a graph in a minimal WorkflowState for getWorkflowNodeFromGraphState.
*/
const wrapState = (graph: WorkflowNode): WorkflowState => ({ graph }) as unknown as WorkflowState;

describe('getWorkflowNodeFromGraphState', () => {
it('should find the root node', () => {
const graph = buildGraph(3, 2);
const result = getWorkflowNodeFromGraphState(wrapState(graph), 'root');
expect(result).toBeDefined();
expect(result?.id).toBe('root');
});

it('should find a deeply nested node', () => {
const graph = buildGraph(2, 4);
const result = getWorkflowNodeFromGraphState(wrapState(graph), 'node-0-0-0');
expect(result).toBeDefined();
expect(result?.id).toBe('node-0-0-0');
});

it('should return undefined for a non-existent node', () => {
const graph = buildGraph(2, 3);
const result = getWorkflowNodeFromGraphState(wrapState(graph), 'does-not-exist');
expect(result).toBeUndefined();
});

it('should short-circuit and not visit all nodes after finding match', () => {
const children: WorkflowNode[] = Array.from({ length: 100 }, (_, index) => ({
id: `child-${index}`,
type: 'OPERATION_NODE' as const,
}));
const graph: WorkflowNode = { id: 'root', type: 'GRAPH_NODE' as const, children };

expect(getWorkflowNodeFromGraphState(wrapState(graph), 'child-0')?.id).toBe('child-0');
expect(getWorkflowNodeFromGraphState(wrapState(graph), 'child-99')?.id).toBe('child-99');
});
});

describe('getWorkflowGraphPath', () => {
it('should return path to the root node', () => {
const graph = buildGraph(2, 3);
const path = getWorkflowGraphPath(graph, 'root');
expect(path).toEqual(['root']);
});

it('should return complete ancestor path for a nested node', () => {
const graph = buildGraph(2, 3);
const path = getWorkflowGraphPath(graph, 'node-0-1');
expect(path).toEqual(['root', 'node-0', 'node-0-1']);
});

it('should return [graphId] for a non-existent node', () => {
const graph = buildGraph(2, 2);
const path = getWorkflowGraphPath(graph, 'does-not-exist');
expect(path).toEqual(['does-not-exist']);
});

it('should short-circuit after finding the target path', () => {
const graph = buildGraph(3, 3);
const path = getWorkflowGraphPath(graph, 'node-0-0');
expect(path).toEqual(['root', 'node-0', 'node-0-0']);
});
});

describe('buildNodeIndex', () => {
it('should index all nodes in the graph', () => {
const graph = buildGraph(10, 3);

const allIds: string[] = [];
const collectIds = (node: WorkflowNode) => {
allIds.push(node.id);
for (const child of node.children ?? []) {
collectIds(child);
}
};
collectIds(graph);

const index = buildNodeIndex(graph);

expect(index.size).toBe(allIds.length);
for (const id of allIds) {
expect(index.get(id)?.id).toBe(id);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { WorkflowNode } from '../../parsers/models/workflowNode';
import type { WorkflowState } from './workflowInterfaces';
import Queue from 'yocto-queue';

/**
* Recursively searches for a WorkflowNode by ID starting from the graph root.
* Returns undefined if the node is not found or the graph is empty.
*/
export const getWorkflowNodeFromGraphState = (state: WorkflowState, actionId: string): WorkflowNode | undefined => {
const graph = state.graph;
if (!graph) {
return undefined;
}

const traverseGraph = (node: WorkflowNode): WorkflowNode | undefined => {
if (node.id === actionId) {
return node;
}

for (const child of node.children ?? []) {
const childRes = traverseGraph(child);
if (childRes) {
return childRes;
}
}
return undefined;
};

return traverseGraph(graph);
};

/**
* Returns the array of ancestor node IDs leading to the given graphId, ending with graphId itself.
* If graphId is not found, returns [graphId].
*/
export const getWorkflowGraphPath = (graph: WorkflowNode, graphId: string): string[] => {
const traverseGraph = (node: WorkflowNode, path: string[] = []): string[] | undefined => {
if (node.id === graphId) {
return path;
}
for (const child of node.children ?? []) {
const childResult = traverseGraph(child, [...path, node.id]);
if (childResult) {
return childResult;
}
}
return undefined;
};

return [...(traverseGraph(graph) ?? []), graphId];
};

/**
* Builds a flat Map<string, WorkflowNode> index of all nodes via BFS.
* Returns an empty map if graph is null.
*/
export const buildNodeIndex = (graph: WorkflowNode | null): Map<string, WorkflowNode> => {
const index = new Map<string, WorkflowNode>();
if (!graph) {
return index;
}

const queue = new Queue<WorkflowNode>();
queue.enqueue(graph);
while (queue.size > 0) {
const node = queue.dequeue();
if (!node) {
break;
}
index.set(node.id, node);
for (const child of node.children ?? []) {
queue.enqueue(child);
}
}
return index;
};
Loading
Loading