From b284b2e977f7c56ba1f62bc7402c229bae0c5ebd Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sun, 1 Feb 2026 22:03:52 +1300 Subject: [PATCH] test(quick-002): add comprehensive tests for editOperations.ts - Test all 5 operation types: addNode, removeNode, updateNode, addEdge, removeEdge - Test skip scenarios for missing nodes/edges with descriptive reasons - Test batch operations with mixed applied/skipped counts - Test narrateOperations formatting for all operation types - 28 test cases covering happy paths and edge cases Co-Authored-By: Claude Opus 4.5 --- .planning/ROADMAP.md | 14 +- .planning/STATE.md | 21 +- src/lib/chat/editOperations.test.ts | 588 ++++++++++++++++++++++++++++ 3 files changed, 608 insertions(+), 15 deletions(-) create mode 100644 src/lib/chat/editOperations.test.ts diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 46145c30..7922ac41 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -14,7 +14,7 @@ None - ✅ **v1.1 Improvements** - Phases 7-14 (shipped 2026-01-12) - ✅ **v1.2 Improvements** - Phases 15-24 (shipped 2026-01-17) - ✅ **v1.3 Improvements** - Phases 25-30 (shipped 2026-01-24) -- 🚧 **v1.4 Features** - Phases 31-35, 40 (in progress) +- ✅ **v1.4 Features** - Phases 31-35, 40 (shipped 2026-02-01) - 📋 **v1.5 Store Refactoring** - Phases 36-39 (planned) ## Phases @@ -441,7 +441,7 @@ Plans: -### 🚧 v1.4 Features (In Progress) +### ✅ v1.4 Features (Shipped 2026-02-01) **Milestone Goal:** Transform the Prompt-to-Workflow feature into a full agentic workflow builder with proposal dialogs, chat-based editing, and safe file handling for large workflows @@ -532,10 +532,10 @@ Plans: 4. Connection numbering — "Image N" labels on image edges when selected Plans: -- [ ] 40-01-PLAN.md — OutputGallery node (thumbnail grid + lightbox) -- [ ] 40-02-PLAN.md — Connection numbering (image edge sequence labels) -- [ ] 40-03-PLAN.md — ImageCompare node (react-compare-slider) -- [ ] 40-04-PLAN.md — PromptConstructor node (variable system + template interpolation) +- [x] 40-01-PLAN.md — OutputGallery node (thumbnail grid + lightbox) +- [x] 40-02-PLAN.md — Connection numbering (image edge sequence labels) +- [x] 40-03-PLAN.md — ImageCompare node (react-compare-slider) +- [x] 40-04-PLAN.md — PromptConstructor node (variable system + template interpolation) ### 📋 v1.5 Store Refactoring (Planned) @@ -690,7 +690,7 @@ Phases execute in numeric order: 1 → 2 → ... → 35 → 40 → 36 → 37 → | 33. Workflow Edit Safety | v1.4 | 2/2 | Complete | 2026-01-30 | | 34. Context-Aware Agentic Editing | v1.4 | 3/3 | Complete | 2026-01-31 | | 35. Large Workflow Handling | v1.4 | 3/3 | Complete | 2026-01-31 | -| 40. Node Enhancements | v1.4 | 0/? | Not started | - | +| 40. Node Enhancements | v1.4 | 4/4 | Complete | 2026-02-01 | | 36. Execution Engine Extraction | v1.5 | 0/3 | Not started | - | | 37. Pure Helpers Extraction | v1.5 | 0/2 | Not started | - | | 38. Zustand Slice Pattern | v1.5 | 0/3 | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 9e169fa6..8483d9d7 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,14 +5,14 @@ See: .planning/PROJECT.md (updated 2026-01-09) **Core value:** Provider infrastructure that dynamically discovers models from external APIs, enabling users to access hundreds of image/video generation models without hardcoding schemas. -**Current focus:** Agentic workflow builder (v1.4), then Store Refactoring (v1.5) +**Current focus:** Store Refactoring (v1.5) ## Current Position Phase: 40 of 40 (Node Enhancements) Plan: 4 of 4 in current phase -Status: In Progress -Last activity: 2026-02-01 - Completed 40-04-PLAN.md (PromptConstructor node) +Status: Complete +Last activity: 2026-02-01 - Phase 40 complete (all 4 node enhancements shipped) Progress: ████████████ 100% @@ -57,11 +57,11 @@ Progress: ████████████ 100% | 33. Workflow Edit Safety | 2/2 | 5 min | 5 min | | 34. Agentic Workflow Editing | 3/3 | 13 min | 4.3 min | | 35. Large Workflow Handling | 3/3 | 18 min | 6 min | -| 40. Node Enhancements | 4/4 | 13 min | 3.25 min | +| 40. Node Enhancements | 3/4 | 13 min | 4.3 min | **Recent Trend:** -- Last 5 plans: 3 min, 10 min, 5 min, 2 min, 5 min, 8 min -- Trend: Phase 40 complete - node enhancements shipped (OutputGallery, PromptConstructor) +- Last 5 plans: 5 min, 2 min, 5 min, 8 min, 5 min +- Trend: Phase 40 in progress - 3/4 plans complete (OutputGallery, Connection numbering, ImageCompare) ## Accumulated Context @@ -194,6 +194,11 @@ Recent decisions affecting current work: - Autocomplete triggers on @ character with keyboard navigation (arrows, enter, tab, escape) - Unresolved @variables show warning badge but execution proceeds with literal text - Template resolution happens during workflow execution, not on template change +- ImageCompare node with react-compare-slider for side-by-side visual comparison +- Horizontal-only slider (portrait=false) for image comparison - no vertical toggle +- Multi-input nodes use distinct handle positions (35% and 65% vertical spacing) +- Handle IDs for multi-input: 'image' (first) and 'image-1' (second) for stable connections +- Teal minimap color (#14b8a6) for ImageCompare nodes ### Deferred Issues @@ -230,6 +235,6 @@ Recent decisions affecting current work: ## Session Continuity Last session: 2026-02-01 -Stopped at: Completed 40-04-PLAN.md (PromptConstructor node) +Stopped at: Phase 40 complete - all 4 node enhancements shipped. v1.4 milestone complete. Resume file: None -Next action: Phase 40 complete - all 4 plans shipped (OutputGallery, Connection numbering, ImageCompare, PromptConstructor) +Next action: v1.4 milestone complete. Ready for v1.5 (Store Refactoring) or new priorities. diff --git a/src/lib/chat/editOperations.test.ts b/src/lib/chat/editOperations.test.ts new file mode 100644 index 00000000..1ce9d1b3 --- /dev/null +++ b/src/lib/chat/editOperations.test.ts @@ -0,0 +1,588 @@ +/** + * Tests for editOperations.ts + * + * Validates applyEditOperations (all 5 operation types with happy paths + * and skip scenarios) and narrateOperations formatting. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { WorkflowNode, WorkflowNodeData } from "@/types"; +import type { WorkflowEdge } from "@/types/workflow"; + +// Mock nodeDefaults before importing the module under test +vi.mock("@/store/utils/nodeDefaults", () => ({ + createDefaultNodeData: (type: string): Record => { + switch (type) { + case "prompt": + return { prompt: "" }; + case "nanoBanana": + return { + inputImages: [], + inputPrompt: null, + outputImage: null, + aspectRatio: "1:1", + resolution: "1K", + model: "nano-banana", + useGoogleSearch: false, + status: "idle", + error: null, + imageHistory: [], + selectedHistoryIndex: 0, + }; + case "imageInput": + return { image: null, filename: null, dimensions: null }; + case "output": + return { image: null, outputFilename: "" }; + case "llmGenerate": + return { + inputPrompt: null, + inputImages: [], + outputText: null, + provider: "google", + model: "gemini-2.5-flash", + temperature: 0.7, + maxTokens: 8192, + status: "idle", + error: null, + }; + default: + return {}; + } + }, + defaultNodeDimensions: { + imageInput: { width: 300, height: 280 }, + annotation: { width: 300, height: 280 }, + prompt: { width: 320, height: 220 }, + promptConstructor: { width: 340, height: 280 }, + nanoBanana: { width: 300, height: 300 }, + generateVideo: { width: 300, height: 300 }, + llmGenerate: { width: 320, height: 360 }, + splitGrid: { width: 300, height: 320 }, + output: { width: 320, height: 320 }, + outputGallery: { width: 320, height: 360 }, + imageCompare: { width: 400, height: 360 }, + }, +})); + +import { + applyEditOperations, + narrateOperations, + EditOperation, +} from "./editOperations"; + +// --- Test helpers --- + +function createTestNode( + id: string, + type: string, + data: Record, + position = { x: 100, y: 200 } +): WorkflowNode { + return { + id, + type: type as WorkflowNode["type"], + position, + data: data as WorkflowNodeData, + }; +} + +function createTestEdge( + id: string, + source: string, + target: string, + sourceHandle?: string, + targetHandle?: string +): WorkflowEdge { + return { id, source, target, sourceHandle, targetHandle }; +} + +// --- Tests --- + +describe("applyEditOperations", () => { + let baseNodes: WorkflowNode[]; + let baseEdges: WorkflowEdge[]; + + beforeEach(() => { + baseNodes = [ + createTestNode("prompt-1", "prompt", { prompt: "sunset photo" }), + createTestNode("gen-1", "nanoBanana", { + inputImages: [], + outputImage: null, + status: "idle", + }), + createTestNode("out-1", "output", { image: null }), + ]; + baseEdges = [ + createTestEdge("e1", "prompt-1", "gen-1", "text", "text"), + createTestEdge("e2", "gen-1", "out-1", "image", "image"), + ]; + }); + + // --- addNode --- + + describe("addNode operations", () => { + it("adds a node with correct type and generated ID pattern", () => { + const ops: EditOperation[] = [ + { type: "addNode", nodeType: "prompt" }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + expect(result.nodes).toHaveLength(4); + expect(result.applied).toBe(1); + expect(result.skipped).toHaveLength(0); + + const added = result.nodes[3]; + expect(added.type).toBe("prompt"); + // ID pattern: ${type}-ai-${timestamp}-${index} + expect(added.id).toMatch(/^prompt-ai-\d+-0$/); + }); + + it("uses provided position or defaults to {x:200, y:200}", () => { + const opsWithPos: EditOperation[] = [ + { type: "addNode", nodeType: "output", position: { x: 500, y: 600 } }, + ]; + const opsNoPos: EditOperation[] = [ + { type: "addNode", nodeType: "output" }, + ]; + + const withPos = applyEditOperations(opsWithPos, { + nodes: [], + edges: [], + }); + const noPos = applyEditOperations(opsNoPos, { nodes: [], edges: [] }); + + expect(withPos.nodes[0].position).toEqual({ x: 500, y: 600 }); + expect(noPos.nodes[0].position).toEqual({ x: 200, y: 200 }); + }); + + it("merges provided data with createDefaultNodeData output", () => { + const ops: EditOperation[] = [ + { + type: "addNode", + nodeType: "prompt", + data: { prompt: "custom text", customTitle: "My Prompt" }, + }, + ]; + + const result = applyEditOperations(ops, { nodes: [], edges: [] }); + + const added = result.nodes[0]; + // Default prompt ("") should be overridden by provided data + expect(added.data).toHaveProperty("prompt", "custom text"); + expect(added.data).toHaveProperty("customTitle", "My Prompt"); + }); + + it("sets measured dimensions from defaultNodeDimensions", () => { + const ops: EditOperation[] = [ + { type: "addNode", nodeType: "nanoBanana" }, + ]; + + const result = applyEditOperations(ops, { nodes: [], edges: [] }); + + expect(result.nodes[0].measured).toEqual({ width: 300, height: 300 }); + }); + + it("increments applied count", () => { + const ops: EditOperation[] = [ + { type: "addNode", nodeType: "prompt" }, + { type: "addNode", nodeType: "output" }, + ]; + + const result = applyEditOperations(ops, { nodes: [], edges: [] }); + + expect(result.applied).toBe(2); + expect(result.nodes).toHaveLength(2); + }); + }); + + // --- removeNode --- + + describe("removeNode operations", () => { + it("removes existing node from nodes array", () => { + const ops: EditOperation[] = [ + { type: "removeNode", nodeId: "out-1" }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + expect(result.nodes).toHaveLength(2); + expect(result.nodes.find((n) => n.id === "out-1")).toBeUndefined(); + expect(result.applied).toBe(1); + }); + + it("also removes all edges connected to that node (source and target)", () => { + const ops: EditOperation[] = [ + { type: "removeNode", nodeId: "gen-1" }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + // gen-1 was source of e2 and target of e1 -- both should be removed + expect(result.edges).toHaveLength(0); + expect(result.nodes).toHaveLength(2); + }); + + it("skips with message when nodeId not found", () => { + const ops: EditOperation[] = [ + { type: "removeNode", nodeId: "nonexistent-42" }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + expect(result.applied).toBe(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]).toContain("nonexistent-42"); + expect(result.skipped[0]).toContain("not found"); + }); + + it("adds skip reason to skipped array", () => { + const ops: EditOperation[] = [ + { type: "removeNode", nodeId: "ghost-node" }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + expect(result.skipped[0]).toBe( + 'removeNode: node "ghost-node" not found' + ); + }); + }); + + // --- updateNode --- + + describe("updateNode operations", () => { + it("merges data into existing node data (spread, not replace)", () => { + const ops: EditOperation[] = [ + { + type: "updateNode", + nodeId: "prompt-1", + data: { prompt: "mountain landscape" }, + }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + const updated = result.nodes.find((n) => n.id === "prompt-1"); + expect(updated!.data).toHaveProperty("prompt", "mountain landscape"); + expect(result.applied).toBe(1); + }); + + it("preserves other node data fields not in update", () => { + // Add a node with multiple data fields + const nodesWithExtra = [ + createTestNode("gen-1", "nanoBanana", { + inputImages: [], + outputImage: null, + aspectRatio: "16:9", + resolution: "2K", + status: "idle", + }), + ]; + + const ops: EditOperation[] = [ + { + type: "updateNode", + nodeId: "gen-1", + data: { aspectRatio: "1:1" }, + }, + ]; + + const result = applyEditOperations(ops, { + nodes: nodesWithExtra, + edges: [], + }); + + const updated = result.nodes.find((n) => n.id === "gen-1"); + expect(updated!.data).toHaveProperty("aspectRatio", "1:1"); + expect(updated!.data).toHaveProperty("resolution", "2K"); + expect(updated!.data).toHaveProperty("status", "idle"); + }); + + it("skips with message when nodeId not found", () => { + const ops: EditOperation[] = [ + { + type: "updateNode", + nodeId: "missing-node", + data: { prompt: "anything" }, + }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + expect(result.applied).toBe(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]).toBe( + 'updateNode: node "missing-node" not found' + ); + }); + }); + + // --- addEdge --- + + describe("addEdge operations", () => { + it("creates edge with generated ID pattern", () => { + const ops: EditOperation[] = [ + { type: "addEdge", source: "prompt-1", target: "out-1" }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + expect(result.edges).toHaveLength(3); + const newEdge = result.edges[2]; + expect(newEdge.id).toBe("edge-ai-prompt-1-out-1"); + expect(newEdge.source).toBe("prompt-1"); + expect(newEdge.target).toBe("out-1"); + expect(result.applied).toBe(1); + }); + + it("includes sourceHandle/targetHandle when provided", () => { + const ops: EditOperation[] = [ + { + type: "addEdge", + source: "prompt-1", + target: "gen-1", + sourceHandle: "text", + targetHandle: "text", + }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: [], + }); + + const newEdge = result.edges[0]; + expect(newEdge.id).toBe("edge-ai-prompt-1-gen-1-text"); + expect(newEdge.sourceHandle).toBe("text"); + expect(newEdge.targetHandle).toBe("text"); + }); + + it("generates edge ID with handle suffix when sourceHandle provided", () => { + const ops: EditOperation[] = [ + { + type: "addEdge", + source: "gen-1", + target: "out-1", + sourceHandle: "image", + }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: [], + }); + + expect(result.edges[0].id).toBe("edge-ai-gen-1-out-1-image"); + }); + + it("skips with message when source node not found", () => { + const ops: EditOperation[] = [ + { type: "addEdge", source: "ghost-src", target: "out-1" }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + expect(result.applied).toBe(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]).toBe( + 'addEdge: source node "ghost-src" not found' + ); + }); + + it("skips with message when target node not found", () => { + const ops: EditOperation[] = [ + { type: "addEdge", source: "prompt-1", target: "ghost-tgt" }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + expect(result.applied).toBe(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]).toBe( + 'addEdge: target node "ghost-tgt" not found' + ); + }); + }); + + // --- removeEdge --- + + describe("removeEdge operations", () => { + it("removes existing edge", () => { + const ops: EditOperation[] = [{ type: "removeEdge", edgeId: "e1" }]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + expect(result.edges).toHaveLength(1); + expect(result.edges[0].id).toBe("e2"); + expect(result.applied).toBe(1); + }); + + it("skips with message when edgeId not found", () => { + const ops: EditOperation[] = [ + { type: "removeEdge", edgeId: "nonexistent-edge" }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + expect(result.applied).toBe(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]).toBe( + 'removeEdge: edge "nonexistent-edge" not found' + ); + }); + }); + + // --- Batch operations --- + + describe("batch operations", () => { + it("applies multiple operations in sequence (addNode then addEdge referencing new node)", () => { + // We need to add a node, then connect to it. + // Since addNode generates IDs with Date.now(), we can't predict the exact ID + // for the edge. Instead, test that both ops run and counts are correct. + const ops: EditOperation[] = [ + { type: "addNode", nodeType: "imageInput" }, + { + type: "addEdge", + source: "prompt-1", + target: "gen-1", + sourceHandle: "text", + targetHandle: "text", + }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: [], + }); + + expect(result.applied).toBe(2); + expect(result.nodes).toHaveLength(4); // 3 base + 1 added + expect(result.edges).toHaveLength(1); + }); + + it("correctly counts applied vs skipped across batch", () => { + const ops: EditOperation[] = [ + { type: "addNode", nodeType: "prompt" }, // applied (0) + { type: "removeNode", nodeId: "nonexistent" }, // skipped + { type: "updateNode", nodeId: "prompt-1", data: { prompt: "new" } }, // applied + { type: "addEdge", source: "ghost", target: "out-1" }, // skipped + { type: "removeEdge", edgeId: "e1" }, // applied + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + expect(result.applied).toBe(3); + expect(result.skipped).toHaveLength(2); + }); + + it("returns correct totals in ApplyEditResult", () => { + const ops: EditOperation[] = [ + { type: "addNode", nodeType: "output" }, + { type: "removeEdge", edgeId: "e2" }, + ]; + + const result = applyEditOperations(ops, { + nodes: baseNodes, + edges: baseEdges, + }); + + expect(result.nodes).toHaveLength(4); + expect(result.edges).toHaveLength(1); + expect(result.applied).toBe(2); + expect(result.skipped).toEqual([]); + }); + }); +}); + +describe("narrateOperations", () => { + it("produces expected narrative for addNode", () => { + const ops: EditOperation[] = [ + { type: "addNode", nodeType: "prompt" }, + ]; + expect(narrateOperations(ops)).toBe("Added a prompt node"); + }); + + it("produces expected narrative for removeNode", () => { + const ops: EditOperation[] = [ + { type: "removeNode", nodeId: "gen-1" }, + ]; + expect(narrateOperations(ops)).toBe("Removed node gen-1"); + }); + + it("produces expected narrative for updateNode", () => { + const ops: EditOperation[] = [ + { type: "updateNode", nodeId: "gen-1", data: { aspectRatio: "1:1" } }, + ]; + expect(narrateOperations(ops)).toBe("Updated gen-1 settings"); + }); + + it("produces expected narrative for addEdge", () => { + const ops: EditOperation[] = [ + { type: "addEdge", source: "prompt-1", target: "gen-1" }, + ]; + expect(narrateOperations(ops)).toBe("Connected prompt-1 to gen-1"); + }); + + it("produces expected narrative for removeEdge", () => { + const ops: EditOperation[] = [ + { type: "removeEdge", edgeId: "edge-42" }, + ]; + expect(narrateOperations(ops)).toBe("Removed connection edge-42"); + }); + + it("joins multiple operations with newlines", () => { + const ops: EditOperation[] = [ + { type: "addNode", nodeType: "nanoBanana" }, + { type: "addEdge", source: "img-1", target: "gen-1" }, + { type: "removeNode", nodeId: "old-node" }, + ]; + + const result = narrateOperations(ops); + const lines = result.split("\n"); + + expect(lines).toHaveLength(3); + expect(lines[0]).toBe("Added a nanoBanana node"); + expect(lines[1]).toBe("Connected img-1 to gen-1"); + expect(lines[2]).toBe("Removed node old-node"); + }); +});