From 3c12e4f02ea279100a28066b59d7d2bbc342417d Mon Sep 17 00:00:00 2001 From: shrimbly Date: Thu, 2 Apr 2026 22:54:33 +1300 Subject: [PATCH] test(48-01): add failing tests for loop edge utilities - Extend WorkflowEdgeData with isLoop and loopCount fields - Add wouldCreateCycle tests (linear, diamond, self-loop, disconnected) - Add findLoopSubgraph tests (simple loop, partial loop, branch within loop) - Add copyLoopOutput tests (image, video, text, audio data types) - Tests fail as expected (RED phase) --- src/store/utils/__tests__/loopEdge.test.ts | 161 +++++++++++++++++++++ src/types/workflow.ts | 2 + 2 files changed, 163 insertions(+) create mode 100644 src/store/utils/__tests__/loopEdge.test.ts diff --git a/src/store/utils/__tests__/loopEdge.test.ts b/src/store/utils/__tests__/loopEdge.test.ts new file mode 100644 index 00000000..aece9249 --- /dev/null +++ b/src/store/utils/__tests__/loopEdge.test.ts @@ -0,0 +1,161 @@ +/** + * Loop Edge Utilities Tests + * + * Tests for cycle detection, loop subgraph identification, and loop data copying. + */ + +import { describe, it, expect, vi } from "vitest"; +import { + wouldCreateCycle, + findLoopSubgraph, + copyLoopOutput, +} from "@/store/utils/executionUtils"; +import { WorkflowEdge, WorkflowNode, WorkflowNodeData } from "@/types"; + +// Helper to create minimal edge objects +function makeEdge(source: string, target: string, data?: Record): WorkflowEdge { + return { + id: `${source}-${target}`, + source, + target, + data: data || {}, + }; +} + +// Helper to create minimal node objects +function makeNode(id: string, type: string, data: Partial = {}): WorkflowNode { + return { + id, + type, + position: { x: 0, y: 0 }, + data: data as WorkflowNodeData, + }; +} + +describe("wouldCreateCycle", () => { + it("detects cycle in linear chain A→B→C when connecting C→A", () => { + const edges = [makeEdge("A", "B"), makeEdge("B", "C")]; + expect(wouldCreateCycle("C", "A", edges)).toBe(true); + }); + + it("returns false when no cycle: A→B, connecting A→C", () => { + const edges = [makeEdge("A", "B")]; + expect(wouldCreateCycle("A", "C", edges)).toBe(false); + }); + + it("detects cycle in diamond: A→B→D, A→C→D, connecting D→A", () => { + const edges = [ + makeEdge("A", "B"), + makeEdge("A", "C"), + makeEdge("B", "D"), + makeEdge("C", "D"), + ]; + expect(wouldCreateCycle("D", "A", edges)).toBe(true); + }); + + it("detects self-loop: connecting A→A", () => { + const edges: WorkflowEdge[] = []; + expect(wouldCreateCycle("A", "A", edges)).toBe(true); + }); + + it("returns false for disconnected graph: A→B, C→D, connecting D→A", () => { + const edges = [makeEdge("A", "B"), makeEdge("C", "D")]; + expect(wouldCreateCycle("D", "A", edges)).toBe(false); + }); +}); + +describe("findLoopSubgraph", () => { + it("identifies simple loop A→B→C with loop edge C→A", () => { + const edges = [makeEdge("A", "B"), makeEdge("B", "C")]; + const result = findLoopSubgraph("C", "A", edges); + expect(result.sort()).toEqual(["A", "B", "C"].sort()); + }); + + it("excludes nodes outside loop: A→B→C→D with loop edge C→A", () => { + const edges = [makeEdge("A", "B"), makeEdge("B", "C"), makeEdge("C", "D")]; + const result = findLoopSubgraph("C", "A", edges); + expect(result.sort()).toEqual(["A", "B", "C"].sort()); + expect(result).not.toContain("D"); + }); + + it("includes branch within loop: A→B→C, B→D→C with loop edge C→A", () => { + const edges = [ + makeEdge("A", "B"), + makeEdge("B", "C"), + makeEdge("B", "D"), + makeEdge("D", "C"), + ]; + const result = findLoopSubgraph("C", "A", edges); + expect(result.sort()).toEqual(["A", "B", "C", "D"].sort()); + }); +}); + +describe("copyLoopOutput", () => { + it("copies image from nanoBanana to imageInput", () => { + const sourceNode = makeNode("source", "nanoBanana", { + outputImage: "data:image/png;base64,abc123", + }); + const targetNode = makeNode("target", "imageInput", {}); + const updateNodeData = vi.fn(); + + copyLoopOutput(sourceNode, "image", targetNode, "image", updateNodeData); + + expect(updateNodeData).toHaveBeenCalledWith("target", { + image: "data:image/png;base64,abc123", + }); + }); + + it("copies video from generateVideo to videoInput", () => { + const sourceNode = makeNode("source", "generateVideo", { + outputVideo: "data:video/mp4;base64,xyz789", + }); + const targetNode = makeNode("target", "videoInput", {}); + const updateNodeData = vi.fn(); + + copyLoopOutput(sourceNode, "video", targetNode, "video", updateNodeData); + + expect(updateNodeData).toHaveBeenCalledWith("target", { + video: "data:video/mp4;base64,xyz789", + }); + }); + + it("copies text from llmGenerate to prompt", () => { + const sourceNode = makeNode("source", "llmGenerate", { + outputText: "Generated prompt text", + }); + const targetNode = makeNode("target", "prompt", {}); + const updateNodeData = vi.fn(); + + copyLoopOutput(sourceNode, "text", targetNode, "text", updateNodeData); + + expect(updateNodeData).toHaveBeenCalledWith("target", { + prompt: "Generated prompt text", + }); + }); + + it("copies audio from generateAudio to audioInput", () => { + const sourceNode = makeNode("source", "generateAudio", { + outputAudio: "data:audio/mp3;base64,audio123", + }); + const targetNode = makeNode("target", "audioInput", {}); + const updateNodeData = vi.fn(); + + copyLoopOutput(sourceNode, "audio", targetNode, "audio", updateNodeData); + + expect(updateNodeData).toHaveBeenCalledWith("target", { + audioFile: "data:audio/mp3;base64,audio123", + }); + }); + + it("does nothing when source value is null", () => { + const sourceNode = makeNode("source", "nanoBanana", { + outputImage: null, + }); + const targetNode = makeNode("target", "imageInput", {}); + const updateNodeData = vi.fn(); + + copyLoopOutput(sourceNode, "image", targetNode, "image", updateNodeData); + + expect(updateNodeData).not.toHaveBeenCalled(); + }); +}); diff --git a/src/types/workflow.ts b/src/types/workflow.ts index 8662c062..24571425 100644 --- a/src/types/workflow.ts +++ b/src/types/workflow.ts @@ -11,6 +11,8 @@ import { Edge } from "@xyflow/react"; export interface WorkflowEdgeData extends Record { hasPause?: boolean; createdAt?: number; + isLoop?: boolean; + loopCount?: number; } // Workflow Edge