Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
1305c627be
  1. 1
      src/components/WorkflowCanvas.tsx
  2. 19
      src/components/__tests__/ConnectionDropMenu.test.tsx
  3. 234
      src/components/__tests__/Generate3DNode.test.tsx
  4. 43
      src/components/__tests__/ModelSearchDialog.test.tsx
  5. 3
      src/lib/chat/tools.ts
  6. 11
      src/lib/quickstart/validation.ts
  7. 4
      src/store/execution/executeNode.ts

1
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 },

19
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(<ConnectionDropMenu {...defaultProps} handleType="3d" connectionType="source" />);
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(<ConnectionDropMenu {...defaultProps} handleType="3d" connectionType="target" />);
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", () => {

234
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<typeof fetch>) => 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 <ReactFlowProvider>{children}</ReactFlowProvider>;
}
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> = {}): Generate3DNodeData => ({
inputImages: [],
inputPrompt: null,
output3dUrl: null,
status: "idle",
error: null,
...overrides,
});
const createNodeProps = (data: Partial<Generate3DNodeData> = {}) => ({
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(
<TestWrapper>
<Generate3DNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.getByText("Select 3D model...")).toBeInTheDocument();
});
it("should render model name when selected", () => {
render(
<TestWrapper>
<Generate3DNode {...createNodeProps({
selectedModel: {
provider: "fal",
modelId: "fal-ai/triposr",
displayName: "TripoSR",
},
})} />
</TestWrapper>
);
expect(screen.getByText("TripoSR")).toBeInTheDocument();
});
it("should render Browse button", () => {
render(
<TestWrapper>
<Generate3DNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.getByText("Browse")).toBeInTheDocument();
});
it("should render 'Run to generate' when idle", () => {
render(
<TestWrapper>
<Generate3DNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.getByText("Run to generate")).toBeInTheDocument();
});
});
describe("Handles", () => {
it("should render image and text input handles", () => {
const { container } = render(
<TestWrapper>
<Generate3DNode {...createNodeProps()} />
</TestWrapper>
);
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(
<TestWrapper>
<Generate3DNode {...createNodeProps()} />
</TestWrapper>
);
const outputHandle = container.querySelector('[data-handletype="3d"]');
expect(outputHandle).toBeInTheDocument();
});
it("should render output label as '3D'", () => {
render(
<TestWrapper>
<Generate3DNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.getByText("3D")).toBeInTheDocument();
});
});
describe("Output States", () => {
it("should show 3D model generated indicator when output exists", () => {
render(
<TestWrapper>
<Generate3DNode {...createNodeProps({ output3dUrl: "https://example.com/model.glb" })} />
</TestWrapper>
);
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(
<TestWrapper>
<Generate3DNode {...createNodeProps({
status: "error",
error: "Model not found",
})} />
</TestWrapper>
);
expect(screen.getByText("Model not found")).toBeInTheDocument();
});
});
});

43
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(
<TestWrapper>
<ModelSearchDialog isOpen={true} onClose={onClose} />
</TestWrapper>
);
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"],
},
})
);

3
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()

11
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<NodeType, { width: number; height: number }> =
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,

4
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;

Loading…
Cancel
Save