Browse Source
- Create comprehensive test suite for extractSubgraph function - Test cases cover: no selection, select all, single node, connected pairs, subset selection, type breakdown, disconnected nodes, multi-handle scenarios - All 8 tests failing with "Not implemented" error (RED phase) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>handoff-20260429-1057
3 changed files with 349 additions and 4 deletions
@ -0,0 +1,289 @@ |
|||||
|
/** |
||||
|
* Tests for subgraphExtractor.ts |
||||
|
* |
||||
|
* Validates selection-aware subgraph extraction for context scoping. |
||||
|
*/ |
||||
|
|
||||
|
import { describe, it, expect } from "vitest"; |
||||
|
import type { WorkflowNode } from "@/types/nodes"; |
||||
|
import type { WorkflowEdge } from "@/types/workflow"; |
||||
|
import { extractSubgraph, SubgraphResult } from "./subgraphExtractor"; |
||||
|
|
||||
|
// Helper to create test nodes
|
||||
|
function createNode(id: string, type: string, selected = false): WorkflowNode { |
||||
|
return { |
||||
|
id, |
||||
|
type: type as any, |
||||
|
position: { x: 0, y: 0 }, |
||||
|
data: {}, |
||||
|
selected, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// Helper to create test edges
|
||||
|
function createEdge( |
||||
|
source: string, |
||||
|
target: string, |
||||
|
sourceHandle = "image", |
||||
|
targetHandle = "image" |
||||
|
): WorkflowEdge { |
||||
|
return { |
||||
|
id: `${source}-${target}`, |
||||
|
source, |
||||
|
target, |
||||
|
sourceHandle, |
||||
|
targetHandle, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
describe("extractSubgraph", () => { |
||||
|
describe("No selection (empty array)", () => { |
||||
|
it("returns all nodes and edges when selectedNodeIds is empty", () => { |
||||
|
const nodes: WorkflowNode[] = [ |
||||
|
createNode("A", "prompt"), |
||||
|
createNode("B", "nanoBanana"), |
||||
|
createNode("C", "output"), |
||||
|
]; |
||||
|
const edges: WorkflowEdge[] = [ |
||||
|
createEdge("A", "B", "text", "text"), |
||||
|
createEdge("B", "C", "image", "image"), |
||||
|
]; |
||||
|
|
||||
|
const result = extractSubgraph(nodes, edges, []); |
||||
|
|
||||
|
expect(result.isScoped).toBe(false); |
||||
|
expect(result.selectedNodes).toHaveLength(3); |
||||
|
expect(result.selectedEdges).toHaveLength(2); |
||||
|
expect(result.restSummary).toBeNull(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Select all nodes", () => { |
||||
|
it("returns all nodes with isScoped=true and empty restSummary", () => { |
||||
|
const nodes: WorkflowNode[] = [ |
||||
|
createNode("A", "prompt"), |
||||
|
createNode("B", "nanoBanana"), |
||||
|
createNode("C", "output"), |
||||
|
]; |
||||
|
const edges: WorkflowEdge[] = [ |
||||
|
createEdge("A", "B", "text", "text"), |
||||
|
createEdge("B", "C", "image", "image"), |
||||
|
]; |
||||
|
|
||||
|
const result = extractSubgraph(nodes, edges, ["A", "B", "C"]); |
||||
|
|
||||
|
expect(result.isScoped).toBe(true); |
||||
|
expect(result.selectedNodes).toHaveLength(3); |
||||
|
expect(result.selectedEdges).toHaveLength(2); |
||||
|
expect(result.restSummary).not.toBeNull(); |
||||
|
expect(result.restSummary!.nodeCount).toBe(0); |
||||
|
expect(result.restSummary!.boundaryConnections).toHaveLength(0); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Select one node in chain", () => { |
||||
|
it("selects middle node with incoming and outgoing boundary connections", () => { |
||||
|
const nodes: WorkflowNode[] = [ |
||||
|
createNode("A", "prompt"), |
||||
|
createNode("B", "nanoBanana"), |
||||
|
createNode("C", "output"), |
||||
|
]; |
||||
|
const edges: WorkflowEdge[] = [ |
||||
|
createEdge("A", "B", "text", "text"), |
||||
|
createEdge("B", "C", "image", "image"), |
||||
|
]; |
||||
|
|
||||
|
const result = extractSubgraph(nodes, edges, ["B"]); |
||||
|
|
||||
|
expect(result.isScoped).toBe(true); |
||||
|
expect(result.selectedNodes).toHaveLength(1); |
||||
|
expect(result.selectedNodes[0].id).toBe("B"); |
||||
|
expect(result.selectedEdges).toHaveLength(0); // No edges fully within selection
|
||||
|
|
||||
|
expect(result.restSummary).not.toBeNull(); |
||||
|
expect(result.restSummary!.nodeCount).toBe(2); |
||||
|
expect(result.restSummary!.boundaryConnections).toHaveLength(2); |
||||
|
|
||||
|
const incoming = result.restSummary!.boundaryConnections.find( |
||||
|
(c) => c.direction === "incoming" |
||||
|
); |
||||
|
expect(incoming).toBeDefined(); |
||||
|
expect(incoming!.selectedNodeId).toBe("B"); |
||||
|
expect(incoming!.otherNodeId).toBe("A"); |
||||
|
expect(incoming!.handleType).toBe("text"); |
||||
|
|
||||
|
const outgoing = result.restSummary!.boundaryConnections.find( |
||||
|
(c) => c.direction === "outgoing" |
||||
|
); |
||||
|
expect(outgoing).toBeDefined(); |
||||
|
expect(outgoing!.selectedNodeId).toBe("B"); |
||||
|
expect(outgoing!.otherNodeId).toBe("C"); |
||||
|
expect(outgoing!.handleType).toBe("image"); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Select two connected nodes", () => { |
||||
|
it("includes edge between selected nodes", () => { |
||||
|
const nodes: WorkflowNode[] = [ |
||||
|
createNode("A", "prompt"), |
||||
|
createNode("B", "nanoBanana"), |
||||
|
createNode("C", "output"), |
||||
|
]; |
||||
|
const edges: WorkflowEdge[] = [ |
||||
|
createEdge("A", "B", "text", "text"), |
||||
|
createEdge("B", "C", "image", "image"), |
||||
|
]; |
||||
|
|
||||
|
const result = extractSubgraph(nodes, edges, ["A", "B"]); |
||||
|
|
||||
|
expect(result.isScoped).toBe(true); |
||||
|
expect(result.selectedNodes).toHaveLength(2); |
||||
|
expect(result.selectedEdges).toHaveLength(1); |
||||
|
expect(result.selectedEdges[0].id).toBe("A-B"); |
||||
|
|
||||
|
expect(result.restSummary).not.toBeNull(); |
||||
|
expect(result.restSummary!.nodeCount).toBe(1); |
||||
|
expect(result.restSummary!.boundaryConnections).toHaveLength(1); |
||||
|
|
||||
|
const boundary = result.restSummary!.boundaryConnections[0]; |
||||
|
expect(boundary.direction).toBe("outgoing"); |
||||
|
expect(boundary.selectedNodeId).toBe("B"); |
||||
|
expect(boundary.otherNodeId).toBe("C"); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Select subset of chain", () => { |
||||
|
it("correctly identifies boundary connections for middle segment", () => { |
||||
|
const nodes: WorkflowNode[] = [ |
||||
|
createNode("A", "prompt"), |
||||
|
createNode("B", "nanoBanana"), |
||||
|
createNode("C", "annotation"), |
||||
|
createNode("D", "output"), |
||||
|
]; |
||||
|
const edges: WorkflowEdge[] = [ |
||||
|
createEdge("A", "B", "text", "text"), |
||||
|
createEdge("B", "C", "image", "image"), |
||||
|
createEdge("C", "D", "image", "image"), |
||||
|
]; |
||||
|
|
||||
|
const result = extractSubgraph(nodes, edges, ["B", "C"]); |
||||
|
|
||||
|
expect(result.isScoped).toBe(true); |
||||
|
expect(result.selectedNodes).toHaveLength(2); |
||||
|
expect(result.selectedEdges).toHaveLength(1); |
||||
|
expect(result.selectedEdges[0].id).toBe("B-C"); |
||||
|
|
||||
|
expect(result.restSummary).not.toBeNull(); |
||||
|
expect(result.restSummary!.nodeCount).toBe(2); |
||||
|
expect(result.restSummary!.boundaryConnections).toHaveLength(2); |
||||
|
|
||||
|
const incoming = result.restSummary!.boundaryConnections.find( |
||||
|
(c) => c.direction === "incoming" && c.selectedNodeId === "B" |
||||
|
); |
||||
|
expect(incoming).toBeDefined(); |
||||
|
expect(incoming!.otherNodeId).toBe("A"); |
||||
|
|
||||
|
const outgoing = result.restSummary!.boundaryConnections.find( |
||||
|
(c) => c.direction === "outgoing" && c.selectedNodeId === "C" |
||||
|
); |
||||
|
expect(outgoing).toBeDefined(); |
||||
|
expect(outgoing!.otherNodeId).toBe("D"); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Type breakdown", () => { |
||||
|
it("correctly counts node types in unselected nodes", () => { |
||||
|
const nodes: WorkflowNode[] = [ |
||||
|
createNode("A", "prompt"), |
||||
|
createNode("B", "prompt"), |
||||
|
createNode("C", "nanoBanana"), |
||||
|
createNode("D", "nanoBanana"), |
||||
|
createNode("E", "nanoBanana"), |
||||
|
createNode("F", "output"), |
||||
|
]; |
||||
|
const edges: WorkflowEdge[] = []; |
||||
|
|
||||
|
const result = extractSubgraph(nodes, edges, ["A"]); |
||||
|
|
||||
|
expect(result.isScoped).toBe(true); |
||||
|
expect(result.restSummary).not.toBeNull(); |
||||
|
expect(result.restSummary!.nodeCount).toBe(5); |
||||
|
expect(result.restSummary!.typeBreakdown).toEqual({ |
||||
|
prompt: 1, |
||||
|
nanoBanana: 3, |
||||
|
output: 1, |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Disconnected selected nodes", () => { |
||||
|
it("handles selection with no edges between selected nodes", () => { |
||||
|
const nodes: WorkflowNode[] = [ |
||||
|
createNode("A", "prompt"), |
||||
|
createNode("B", "nanoBanana"), |
||||
|
createNode("C", "prompt"), |
||||
|
createNode("D", "output"), |
||||
|
]; |
||||
|
const edges: WorkflowEdge[] = [ |
||||
|
createEdge("A", "B", "text", "text"), |
||||
|
createEdge("C", "D", "text", "image"), |
||||
|
]; |
||||
|
|
||||
|
const result = extractSubgraph(nodes, edges, ["A", "C"]); |
||||
|
|
||||
|
expect(result.isScoped).toBe(true); |
||||
|
expect(result.selectedNodes).toHaveLength(2); |
||||
|
expect(result.selectedEdges).toHaveLength(0); // No edges between A and C
|
||||
|
|
||||
|
expect(result.restSummary).not.toBeNull(); |
||||
|
expect(result.restSummary!.nodeCount).toBe(2); |
||||
|
expect(result.restSummary!.boundaryConnections).toHaveLength(2); |
||||
|
|
||||
|
// Both are outgoing since A->B and C->D
|
||||
|
const outgoingConnections = result.restSummary!.boundaryConnections.filter( |
||||
|
(c) => c.direction === "outgoing" |
||||
|
); |
||||
|
expect(outgoingConnections).toHaveLength(2); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Complex multi-handle scenario", () => { |
||||
|
it("handles multiple connections to same node with different handle types", () => { |
||||
|
const nodes: WorkflowNode[] = [ |
||||
|
createNode("prompt1", "prompt"), |
||||
|
createNode("prompt2", "prompt"), |
||||
|
createNode("img1", "imageInput"), |
||||
|
createNode("gen", "nanoBanana"), |
||||
|
createNode("out", "output"), |
||||
|
]; |
||||
|
const edges: WorkflowEdge[] = [ |
||||
|
createEdge("prompt1", "gen", "text", "text"), |
||||
|
createEdge("prompt2", "gen", "text", "text"), |
||||
|
createEdge("img1", "gen", "image", "image"), |
||||
|
createEdge("gen", "out", "image", "image"), |
||||
|
]; |
||||
|
|
||||
|
const result = extractSubgraph(nodes, edges, ["gen"]); |
||||
|
|
||||
|
expect(result.isScoped).toBe(true); |
||||
|
expect(result.selectedNodes).toHaveLength(1); |
||||
|
expect(result.selectedEdges).toHaveLength(0); |
||||
|
|
||||
|
expect(result.restSummary).not.toBeNull(); |
||||
|
expect(result.restSummary!.nodeCount).toBe(4); |
||||
|
expect(result.restSummary!.boundaryConnections).toHaveLength(4); |
||||
|
|
||||
|
// 3 incoming (2 text from prompts, 1 image from img1)
|
||||
|
const incoming = result.restSummary!.boundaryConnections.filter( |
||||
|
(c) => c.direction === "incoming" |
||||
|
); |
||||
|
expect(incoming).toHaveLength(3); |
||||
|
|
||||
|
// 1 outgoing (to out)
|
||||
|
const outgoing = result.restSummary!.boundaryConnections.filter( |
||||
|
(c) => c.direction === "outgoing" |
||||
|
); |
||||
|
expect(outgoing).toHaveLength(1); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,54 @@ |
|||||
|
/** |
||||
|
* Subgraph Extractor |
||||
|
* |
||||
|
* Splits a workflow into a detailed selected subgraph and a summary of the rest, |
||||
|
* based on React Flow's node.selected property. When users select nodes before |
||||
|
* chatting, the LLM gets focused context on the selection with a lightweight |
||||
|
* summary of the surrounding workflow. |
||||
|
*/ |
||||
|
|
||||
|
import type { WorkflowNode } from "@/types/nodes"; |
||||
|
import type { WorkflowEdge } from "@/types/workflow"; |
||||
|
|
||||
|
/** |
||||
|
* Boundary connection between selected and unselected nodes |
||||
|
*/ |
||||
|
export interface BoundaryConnection { |
||||
|
direction: "incoming" | "outgoing"; |
||||
|
selectedNodeId: string; |
||||
|
otherNodeId: string; |
||||
|
handleType: string; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Result of subgraph extraction |
||||
|
*/ |
||||
|
export interface SubgraphResult { |
||||
|
// When selectedNodeIds is empty, these are ALL nodes/edges (no scoping)
|
||||
|
selectedNodes: WorkflowNode[]; |
||||
|
selectedEdges: WorkflowEdge[]; |
||||
|
// Summary of unselected nodes (null when no selection)
|
||||
|
restSummary: { |
||||
|
nodeCount: number; |
||||
|
typeBreakdown: Record<string, number>; |
||||
|
boundaryConnections: BoundaryConnection[]; |
||||
|
} | null; |
||||
|
isScoped: boolean; // true when selectedNodeIds is non-empty
|
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Extract subgraph based on selected node IDs |
||||
|
* |
||||
|
* @param nodes - All workflow nodes |
||||
|
* @param edges - All workflow edges |
||||
|
* @param selectedNodeIds - IDs of selected nodes |
||||
|
* @returns Subgraph result with selected nodes/edges and rest summary |
||||
|
*/ |
||||
|
export function extractSubgraph( |
||||
|
nodes: WorkflowNode[], |
||||
|
edges: WorkflowEdge[], |
||||
|
selectedNodeIds: string[] |
||||
|
): SubgraphResult { |
||||
|
// TODO: Implement
|
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
Loading…
Reference in new issue