From 1305c627be77a208b24ae207aec9455957384d29 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sat, 14 Feb 2026 23:44:34 +1300 Subject: [PATCH] feat: update tests and remaining registrations for generate3d - Add Generate3DNode.test.tsx with render, handle, and output tests - Add 3D handle tests to ConnectionDropMenu.test.tsx - Add 3D model routing test to ModelSearchDialog.test.tsx - Update ModelSearchDialog test expectations for capabilities passthrough - Register generate3d in executeNode.ts dispatcher - Add generate3d to WorkflowCanvas keyboard shortcut dimensions - Add generate3d to quickstart validation (VALID_NODE_TYPES, dimensions, defaults) - Add generate3d to chat tools (VALID_NODE_TYPES, description) Co-Authored-By: Claude Opus 4.6 --- src/components/WorkflowCanvas.tsx | 1 + .../__tests__/ConnectionDropMenu.test.tsx | 19 ++ .../__tests__/Generate3DNode.test.tsx | 234 ++++++++++++++++++ .../__tests__/ModelSearchDialog.test.tsx | 43 +++- src/lib/chat/tools.ts | 3 +- src/lib/quickstart/validation.ts | 11 + src/store/execution/executeNode.ts | 4 + 7 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 src/components/__tests__/Generate3DNode.test.tsx diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 3f651c27..c80ebf66 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -1094,6 +1094,7 @@ export function WorkflowCanvas() { promptConstructor: { width: 340, height: 280 }, nanoBanana: { width: 300, height: 300 }, generateVideo: { width: 300, height: 300 }, + generate3d: { width: 300, height: 300 }, llmGenerate: { width: 320, height: 360 }, splitGrid: { width: 300, height: 320 }, output: { width: 320, height: 320 }, diff --git a/src/components/__tests__/ConnectionDropMenu.test.tsx b/src/components/__tests__/ConnectionDropMenu.test.tsx index d42267c3..9d47cc39 100644 --- a/src/components/__tests__/ConnectionDropMenu.test.tsx +++ b/src/components/__tests__/ConnectionDropMenu.test.tsx @@ -79,6 +79,16 @@ describe("ConnectionDropMenu", () => { expect(screen.queryByText("Annotate")).not.toBeInTheDocument(); expect(screen.queryByText("LLM Generate")).not.toBeInTheDocument(); }); + + it("should show 3D-accepting nodes when dragging from 3D output", () => { + render(); + + expect(screen.getByText("3D Viewer")).toBeInTheDocument(); + // Should NOT show image/text/video nodes + expect(screen.queryByText("Annotate")).not.toBeInTheDocument(); + expect(screen.queryByText("Generate Image")).not.toBeInTheDocument(); + expect(screen.queryByText("Generate Video")).not.toBeInTheDocument(); + }); }); describe("Node Type Filtering - Target Connection (from input handle)", () => { @@ -110,6 +120,15 @@ describe("ConnectionDropMenu", () => { expect(screen.queryByText("Image Input")).not.toBeInTheDocument(); expect(screen.queryByText("Prompt")).not.toBeInTheDocument(); }); + + it("should show 3D-producing nodes when dragging from 3D input", () => { + render(); + + expect(screen.getByText("Generate 3D")).toBeInTheDocument(); + // Should NOT show other nodes + expect(screen.queryByText("Image Input")).not.toBeInTheDocument(); + expect(screen.queryByText("Generate Image")).not.toBeInTheDocument(); + }); }); describe("Menu Item Click", () => { diff --git a/src/components/__tests__/Generate3DNode.test.tsx b/src/components/__tests__/Generate3DNode.test.tsx new file mode 100644 index 00000000..6d587973 --- /dev/null +++ b/src/components/__tests__/Generate3DNode.test.tsx @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Generate3DNode } from "@/components/nodes/Generate3DNode"; +import { ReactFlowProvider } from "@xyflow/react"; +import { Generate3DNodeData } from "@/types"; + +// Mock deduplicatedFetch +vi.mock("@/utils/deduplicatedFetch", () => ({ + deduplicatedFetch: (...args: Parameters) => fetch(...args), + clearFetchCache: vi.fn(), +})); + +// Mock the workflow store +const mockUpdateNodeData = vi.fn(); +const mockRegenerateNode = vi.fn(); +const mockUseWorkflowStore = vi.fn(); + +vi.mock("@/store/workflowStore", () => ({ + useWorkflowStore: (selector?: (state: unknown) => unknown) => { + if (selector) { + return mockUseWorkflowStore(selector); + } + return mockUseWorkflowStore((s: unknown) => s); + }, + useProviderApiKeys: () => ({ + replicateApiKey: null, + falApiKey: null, + kieApiKey: null, + wavespeedApiKey: null, + replicateEnabled: false, + kieEnabled: false, + }), +})); + +// Mock useReactFlow +const mockSetNodes = vi.fn(); + +vi.mock("@xyflow/react", async () => { + const actual = await vi.importActual("@xyflow/react"); + return { + ...actual, + useReactFlow: () => ({ + getNodes: vi.fn(() => []), + setNodes: mockSetNodes, + screenToFlowPosition: vi.fn((pos) => pos), + }), + }; +}); + +// Mock Toast +vi.mock("@/components/Toast", () => ({ + useToast: { + getState: () => ({ + show: vi.fn(), + }), + }, +})); + +// Mock createPortal +vi.mock("react-dom", async () => { + const actual = await vi.importActual("react-dom"); + return { + ...actual, + createPortal: (node: React.ReactNode) => node, + }; +}); + +// Mock fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +function TestWrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("Generate3DNode", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ models: [], success: true }), + }); + + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + updateNodeData: mockUpdateNodeData, + regenerateNode: mockRegenerateNode, + isRunning: false, + currentNodeIds: [], + groups: {}, + nodes: [], + recentModels: [], + trackModelUsage: vi.fn(), + incrementModalCount: vi.fn(), + decrementModalCount: vi.fn(), + getNodesWithComments: vi.fn(() => []), + markCommentViewed: vi.fn(), + setNavigationTarget: vi.fn(), + generationsPath: null, + }; + return selector(state); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createNodeData = (overrides: Partial = {}): Generate3DNodeData => ({ + inputImages: [], + inputPrompt: null, + output3dUrl: null, + status: "idle", + error: null, + ...overrides, + }); + + const createNodeProps = (data: Partial = {}) => ({ + id: "test-3d-node", + type: "generate3d" as const, + data: createNodeData(data), + selected: false, + }); + + describe("Basic Rendering", () => { + it("should render with default title when no model selected", () => { + render( + + + + ); + + expect(screen.getByText("Select 3D model...")).toBeInTheDocument(); + }); + + it("should render model name when selected", () => { + render( + + + + ); + + expect(screen.getByText("TripoSR")).toBeInTheDocument(); + }); + + it("should render Browse button", () => { + render( + + + + ); + + expect(screen.getByText("Browse")).toBeInTheDocument(); + }); + + it("should render 'Run to generate' when idle", () => { + render( + + + + ); + + expect(screen.getByText("Run to generate")).toBeInTheDocument(); + }); + }); + + describe("Handles", () => { + it("should render image and text input handles", () => { + const { container } = render( + + + + ); + + const imageHandle = container.querySelector('[data-handletype="image"]'); + const textHandle = container.querySelector('[data-handletype="text"]'); + expect(imageHandle).toBeInTheDocument(); + expect(textHandle).toBeInTheDocument(); + }); + + it("should render 3D output handle", () => { + const { container } = render( + + + + ); + + const outputHandle = container.querySelector('[data-handletype="3d"]'); + expect(outputHandle).toBeInTheDocument(); + }); + + it("should render output label as '3D'", () => { + render( + + + + ); + + expect(screen.getByText("3D")).toBeInTheDocument(); + }); + }); + + describe("Output States", () => { + it("should show 3D model generated indicator when output exists", () => { + render( + + + + ); + + expect(screen.getByText("3D Model Generated")).toBeInTheDocument(); + expect(screen.getByText("Connect to 3D Viewer")).toBeInTheDocument(); + }); + + it("should show error message when status is error", () => { + render( + + + + ); + + expect(screen.getByText("Model not found")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/__tests__/ModelSearchDialog.test.tsx b/src/components/__tests__/ModelSearchDialog.test.tsx index f30a7f02..7fbaf9b6 100644 --- a/src/components/__tests__/ModelSearchDialog.test.tsx +++ b/src/components/__tests__/ModelSearchDialog.test.tsx @@ -104,6 +104,14 @@ const sampleModels: ProviderModel[] = [ capabilities: ["text-to-video", "image-to-video"], coverImage: "https://example.com/kling.jpg", }, + { + id: "fal-ai/triposr", + name: "TripoSR", + description: "3D model generation from images", + provider: "fal", + capabilities: ["image-to-3d"], + coverImage: "https://example.com/triposr.jpg", + }, ]; describe("ModelSearchDialog", () => { @@ -412,7 +420,7 @@ describe("ModelSearchDialog", () => { ); await waitFor(() => { - expect(screen.getByText(/3 models? found/)).toBeInTheDocument(); + expect(screen.getByText(/4 models? found/)).toBeInTheDocument(); }); }); }); @@ -475,6 +483,7 @@ describe("ModelSearchDialog", () => { provider: "replicate", modelId: "stability-ai/sdxl", displayName: "SDXL", + capabilities: ["text-to-image"], }, }) ); @@ -506,6 +515,38 @@ describe("ModelSearchDialog", () => { provider: "fal", modelId: "kling-video/v1.6/pro", displayName: "Kling Video Pro", + capabilities: ["text-to-video", "image-to-video"], + }, + }) + ); + }); + + it("should create generate3d node for 3D models", async () => { + const onClose = vi.fn(); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("TripoSR")).toBeInTheDocument(); + }); + + // Click on the TripoSR 3D model card + const modelCard = screen.getByText("TripoSR").closest("button"); + fireEvent.click(modelCard!); + + expect(mockAddNode).toHaveBeenCalledWith( + "generate3d", + expect.any(Object), + expect.objectContaining({ + selectedModel: { + provider: "fal", + modelId: "fal-ai/triposr", + displayName: "TripoSR", + capabilities: ["image-to-3d"], }, }) ); diff --git a/src/lib/chat/tools.ts b/src/lib/chat/tools.ts index beea39b2..17f2e962 100644 --- a/src/lib/chat/tools.ts +++ b/src/lib/chat/tools.ts @@ -15,6 +15,7 @@ const VALID_NODE_TYPES: NodeType[] = [ "prompt", "nanoBanana", "generateVideo", + "generate3d", "llmGenerate", "splitGrid", "output", @@ -205,7 +206,7 @@ export function createChatTools(nodeIds: string[]) { .string() .optional() .describe( - "Node type for addNode. Valid: imageInput, annotation, prompt, nanoBanana, generateVideo, llmGenerate, splitGrid, output" + "Node type for addNode. Valid: imageInput, annotation, prompt, nanoBanana, generateVideo, generate3d, llmGenerate, splitGrid, output" ), nodeId: z .string() diff --git a/src/lib/quickstart/validation.ts b/src/lib/quickstart/validation.ts index ecbada66..c0cd0aa6 100644 --- a/src/lib/quickstart/validation.ts +++ b/src/lib/quickstart/validation.ts @@ -17,6 +17,7 @@ const VALID_NODE_TYPES: NodeType[] = [ "prompt", "nanoBanana", "generateVideo", + "generate3d", "llmGenerate", "splitGrid", "output", @@ -33,6 +34,7 @@ const DEFAULT_DIMENSIONS: Record = promptConstructor: { width: 340, height: 280 }, nanoBanana: { width: 300, height: 300 }, generateVideo: { width: 300, height: 300 }, + generate3d: { width: 300, height: 300 }, llmGenerate: { width: 320, height: 360 }, splitGrid: { width: 300, height: 320 }, output: { width: 320, height: 320 }, @@ -250,6 +252,15 @@ function createDefaultNodeData(type: NodeType): WorkflowNodeData { videoHistory: [], selectedVideoHistoryIndex: 0, }; + case "generate3d": + return { + inputImages: [], + inputPrompt: null, + output3dUrl: null, + selectedModel: undefined, + status: "idle", + error: null, + }; case "llmGenerate": return { inputPrompt: null, diff --git a/src/store/execution/executeNode.ts b/src/store/execution/executeNode.ts index 4d7d2530..640cb3ed 100644 --- a/src/store/execution/executeNode.ts +++ b/src/store/execution/executeNode.ts @@ -18,6 +18,7 @@ import { } from "./simpleNodeExecutors"; import { executeNanoBanana } from "./nanoBananaExecutor"; import { executeGenerateVideo } from "./generateVideoExecutor"; +import { executeGenerate3D } from "./generate3dExecutor"; import { executeLlmGenerate } from "./llmGenerateExecutor"; import { executeSplitGrid } from "./splitGridExecutor"; import { executeVideoStitch, executeEaseCurve } from "./videoProcessingExecutors"; @@ -58,6 +59,9 @@ export async function executeNode( case "generateVideo": await executeGenerateVideo(ctx, regenOpts); break; + case "generate3d": + await executeGenerate3D(ctx, regenOpts); + break; case "llmGenerate": await executeLlmGenerate(ctx, regenOpts); break;