import { describe, it, expect, vi, beforeEach } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { GenerationComposer } from "@/components/composer/GenerationComposer"; import { clearPopiserverTaskPriceCacheForTests } from "@/hooks/usePopiserverTaskPrice"; import { useWorkflowStore } from "@/store/workflowStore"; import type { EaseCurveNodeData, GenerateAudioNodeData, GenerateVideoNodeData, ImageInputNodeData, NanoBananaNodeData, PromptNodeData, SmartAudioNodeData, SmartImageNodeData, SmartVideoNodeData, VideoStitchNodeData, WorkflowEdge, WorkflowNode, } from "@/types"; vi.mock("@xyflow/react", async () => { const actual = await vi.importActual("@xyflow/react"); return { ...actual, useReactFlow: () => ({ screenToFlowPosition: (position: { x: number; y: number }) => position, }), useViewport: () => ({ x: 0, y: 0, zoom: 1 }), ViewportPortal: ({ children }: { children: ReactNode }) => <>{children}, }; }); vi.mock("@/components/modals/ModelSearchDialog", () => ({ ModelSearchDialog: (props: { initialCapabilityFilter: string; title?: string; onModelSelected: (model: { id: string; name: string; provider: "newapiwg" | "fal" | "kie"; capabilities: Array<"text-to-image" | "image-to-image" | "text-to-video" | "image-to-video" | "text-to-audio">; }) => void; }) => (
), })); const originalActions = { addNode: useWorkflowStore.getState().addNode, updateNodeData: useWorkflowStore.getState().updateNodeData, regenerateNode: useWorkflowStore.getState().regenerateNode, selectSingleNode: useWorkflowStore.getState().selectSingleNode, getConnectedInputs: useWorkflowStore.getState().getConnectedInputs, }; function imageNode(id: string, selected = false, data: Partial = {}): WorkflowNode { return { id, type: "nanoBanana", selected, position: { x: 0, y: 0 }, data: { inputImages: [], inputPrompt: "image prompt", outputImage: null, aspectRatio: "16:9", resolution: "2K", model: "nano-banana-pro", selectedModel: { provider: "newapiwg", modelId: "apiyi_nano_banana_2", displayName: "apiyi_nano_banana_2", capabilities: ["text-to-image", "image-to-image"], }, useGoogleSearch: false, useImageSearch: false, status: "idle", error: null, imageHistory: [], selectedHistoryIndex: 0, ...data, }, } as WorkflowNode; } function pricedImageNode(id: string, selected = false, data: Partial = {}): WorkflowNode { return imageNode(id, selected, { selectedModel: { provider: "newapiwg", modelId: "wan-2.7", displayName: "万相 2.7", capabilities: ["text-to-image", "image-to-image"], pricing: { type: "per-run", amount: 2 }, }, ...data, }); } function imageInputNode(id: string, selected = false, data: Partial = {}): WorkflowNode { return { id, type: "imageInput", selected, position: { x: 0, y: 0 }, data: { image: "data:image/png;base64,input", filename: "input.png", dimensions: { width: 640, height: 480 }, ...data, }, } as WorkflowNode; } function videoNode(id: string, selected = false, data: Partial = {}): WorkflowNode { return { id, type: "generateVideo", selected, position: { x: 0, y: 0 }, data: { inputImages: [], inputPrompt: "video prompt", outputVideo: null, selectedModel: { provider: "newapiwg", modelId: "doubao-seedance-2-0-260128", displayName: "doubao-seedance-2-0-260128", capabilities: ["text-to-video", "image-to-video"], }, parameters: {}, parameterSchema: [ { name: "duration", type: "integer", label: "Duration", options: [ { label: "4", value: "4" }, { label: "6", value: "6" }, { label: "8", value: "8" }, ], }, { name: "resolution", type: "string", label: "Resolution", options: [ { label: "720p", value: "720p" }, { label: "1080p", value: "1080p" }, ], }, { name: "sound", type: "boolean", label: "Sound" }, ], status: "idle", error: null, videoHistory: [], selectedVideoHistoryIndex: 0, ...data, }, } as WorkflowNode; } function audioNode(id: string, selected = false, data: Partial = {}): WorkflowNode { return { id, type: "generateAudio", selected, position: { x: 0, y: 0 }, data: { inputPrompt: "audio prompt", outputAudio: null, selectedModel: { provider: "kie", modelId: "elevenlabs/turbo-v2.5", displayName: "ElevenLabs Turbo v2.5", capabilities: ["text-to-audio"], }, parameters: {}, parameterSchema: [ { name: "duration", type: "integer", label: "Duration", options: [ { label: "4", value: "4" }, { label: "6", value: "6" }, { label: "8", value: "8" }, ], }, { name: "resolution", type: "string", label: "Resolution", options: [ { label: "720p", value: "720p" }, { label: "1080p", value: "1080p" }, ], }, { name: "sound", type: "boolean", label: "Sound" }, ], status: "idle", error: null, audioHistory: [], selectedAudioHistoryIndex: 0, duration: null, format: null, ...data, }, } as WorkflowNode; } function smartImageNode(id: string, selected = false, data: Partial = {}): WorkflowNode { return { ...imageNode(id, selected, data), type: "smartImage", } as WorkflowNode; } function smartVideoNode(id: string, selected = false, data: Partial = {}): WorkflowNode { return { ...videoNode(id, selected, data), type: "smartVideo", } as WorkflowNode; } function smartAudioNode(id: string, selected = false, data: Partial = {}): WorkflowNode { return { ...audioNode(id, selected, data), type: "smartAudio", } as WorkflowNode; } function videoStitchNode(id: string, selected = false, data: Partial = {}): WorkflowNode { return { id, type: "videoStitch", selected, position: { x: 0, y: 0 }, data: { clips: [], clipOrder: [], outputVideo: null, loopCount: 1, status: "idle", error: null, progress: 0, encoderSupported: true, ...data, }, } as WorkflowNode; } function easeCurveNode(id: string, selected = false, data: Partial = {}): WorkflowNode { return { id, type: "easeCurve", selected, position: { x: 0, y: 0 }, data: { bezierHandles: [0.42, 0, 0.58, 1], easingPreset: "easeInOutSine", inheritedFrom: null, outputDuration: 5, outputVideo: null, status: "idle", error: null, progress: 0, encoderSupported: true, ...data, }, } as WorkflowNode; } function promptNode(id: string, prompt: string, selected = false): WorkflowNode { return { id, type: "prompt", selected, position: { x: 0, y: 0 }, data: { prompt } satisfies PromptNodeData, } as WorkflowNode; } function textEdge(source: string, target: string): WorkflowEdge { return { id: `edge-${source}-${target}-text-text`, source, sourceHandle: "text", target, targetHandle: "text", data: {}, } as WorkflowEdge; } function imageEdge(source: string, target: string, index: number, targetHandle = `image-${index}`): WorkflowEdge { return { id: `edge-${source}-${target}-image-${index}`, source, sourceHandle: "image", target, targetHandle, data: { createdAt: index }, } as WorkflowEdge; } function videoEdge(source: string, target: string, index: number, targetHandle = `video-${index}`): WorkflowEdge { return { id: `edge-${source}-${target}-video-${index}`, source, sourceHandle: "video", target, targetHandle, data: { createdAt: index }, } as WorkflowEdge; } function audioEdge(source: string, target: string, index: number, targetHandle = `audio-${index}`): WorkflowEdge { return { id: `edge-${source}-${target}-audio-${index}`, source, sourceHandle: "audio", target, targetHandle, data: { createdAt: index }, } as WorkflowEdge; } describe("GenerationComposer", () => { beforeEach(() => { vi.clearAllMocks(); clearPopiserverTaskPriceCacheForTests(); useWorkflowStore.setState({ nodes: [], edges: [], dimmedNodeIds: new Set(), isRunning: false, isModalOpen: false, addNode: originalActions.addNode, updateNodeData: originalActions.updateNodeData, regenerateNode: vi.fn().mockResolvedValue(undefined) as typeof originalActions.regenerateNode, selectSingleNode: originalActions.selectSingleNode, getConnectedInputs: originalActions.getConnectedInputs, }); }); it("does not render when no node is selected", () => { render(); expect(screen.queryByTestId("node-inline-composer")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Generate")).not.toBeInTheDocument(); }); it("does not render a single-node editor for multi-select", () => { useWorkflowStore.setState({ nodes: [ imageNode("img-1", true), imageNode("img-2", true), ], }); render(); expect(screen.queryByTestId("node-inline-composer")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Generate")).not.toBeInTheDocument(); }); it("centers the selected node editor below the selected node", () => { useWorkflowStore.setState({ nodes: [ { ...imageNode("img-1", true), position: { x: 500, y: 240 }, measured: { width: 260, height: 180 }, } as WorkflowNode, ], }); render(); expect(screen.getByTestId("node-inline-composer")).toHaveStyle({ left: "108px", top: "436px", }); expect(screen.getByTestId("node-inline-composer-panel")).toHaveStyle({ width: "900px", }); expect(screen.getByPlaceholderText(/Describe what you want to generate/)).toHaveClass("h-[13.5rem]"); }); it("positions the selected node editor below grouped nodes using absolute canvas coordinates", () => { useWorkflowStore.setState({ nodes: [ { id: "group-node-group-1", type: "group", position: { x: 400, y: 200 }, data: { groupId: "group-1" }, style: { width: 500, height: 360 }, } as WorkflowNode, { ...imageNode("img-1", true), parentId: "group-node-group-1", extent: "parent", expandParent: true, groupId: "group-1", position: { x: 100, y: 40 }, measured: { width: 260, height: 180 }, } as WorkflowNode, ], }); render(); expect(screen.getByTestId("node-inline-composer")).toHaveStyle({ left: "108px", top: "436px", }); }); it("keeps the selected node editor below the node when below would leave the viewport", () => { const originalInnerHeight = window.innerHeight; const originalInnerWidth = window.innerWidth; Object.defineProperty(window, "innerHeight", { configurable: true, value: 500 }); Object.defineProperty(window, "innerWidth", { configurable: true, value: 800 }); useWorkflowStore.setState({ nodes: [ { ...imageNode("img-1", true), position: { x: 80, y: 420 }, measured: { width: 240, height: 100 }, } as WorkflowNode, ], }); render(); expect(screen.getByTestId("node-inline-composer")).toHaveStyle({ left: "16px", top: "536px", }); Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight }); Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth }); }); it("reruns the selected image node without creating another node", async () => { useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] }); render(); const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement; fireEvent.change(prompt, { target: { value: "edited image prompt" } }); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("img-1"); }); expect((useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData).inputPrompt).toBe("edited image prompt"); expect(useWorkflowStore.getState().nodes).toHaveLength(1); }); it("converts a selected smart image node to nanoBanana before generating", async () => { useWorkflowStore.setState({ nodes: [smartImageNode("smart-img-1", true)] }); render(); const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement; fireEvent.change(prompt, { target: { value: "smart image prompt" } }); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("smart-img-1"); }); const node = useWorkflowStore.getState().nodes[0]; expect(node.type).toBe("nanoBanana"); expect((node.data as NanoBananaNodeData).inputPrompt).toBe("smart image prompt"); }); it("converts a selected smart video node to generateVideo before generating", async () => { useWorkflowStore.setState({ nodes: [smartVideoNode("smart-vid-1", true)] }); render(); const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement; fireEvent.change(prompt, { target: { value: "smart video prompt" } }); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("smart-vid-1"); }); const node = useWorkflowStore.getState().nodes[0]; expect(node.type).toBe("generateVideo"); expect((node.data as GenerateVideoNodeData).inputPrompt).toBe("smart video prompt"); }); it("converts a selected smart audio node to generateAudio before generating", async () => { useWorkflowStore.setState({ nodes: [smartAudioNode("smart-aud-1", true)] }); render(); const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement; fireEvent.change(prompt, { target: { value: "smart audio prompt" } }); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("smart-aud-1"); }); const node = useWorkflowStore.getState().nodes[0]; expect(node.type).toBe("generateAudio"); expect((node.data as GenerateAudioNodeData).inputPrompt).toBe("smart audio prompt"); }); it("does not let composer clicks bubble into the canvas selection layer", () => { const onPointerDown = vi.fn(); const onMouseDown = vi.fn(); const onClick = vi.fn(); useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] }); render(
); const generateButton = screen.getByLabelText("Generate"); fireEvent.pointerDown(generateButton); fireEvent.mouseDown(generateButton); fireEvent.click(generateButton); expect(onPointerDown).not.toHaveBeenCalled(); expect(onMouseDown).not.toHaveBeenCalled(); expect(onClick).not.toHaveBeenCalled(); }); it("opens the reference menu when @ is typed in the middle of the prompt", () => { useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "before after" })] }); render(); const prompt = screen.getByDisplayValue("before after") as HTMLTextAreaElement; prompt.setSelectionRange(7, 7); fireEvent.change(prompt, { target: { value: "before @after", selectionStart: 8, selectionEnd: 8 } }); expect(screen.getByText("Reference assets")).toBeInTheDocument(); fireEvent.click(screen.getByText("Canvas assets")); expect(prompt).toHaveValue("before @Canvas assets after"); }); it("opens the command menu when / is typed in the middle of the prompt", () => { useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "before after" })] }); render(); const prompt = screen.getByDisplayValue("before after") as HTMLTextAreaElement; prompt.setSelectionRange(7, 7); fireEvent.change(prompt, { target: { value: "before /after", selectionStart: 8, selectionEnd: 8 } }); expect(screen.getByText("Commands")).toBeInTheDocument(); fireEvent.click(screen.getByText("Character image")); expect(prompt.value).toMatch(/^before Generate a character image/); expect(prompt.value).toContain("after"); }); it("selects reference menu items with arrow keys and enter", () => { useWorkflowStore.setState({ nodes: [ imageNode("img-1", true, { inputPrompt: "before after", inputImages: ["data:image/png;base64,first", "data:image/png;base64,second"], }), ], }); render(); const prompt = screen.getByDisplayValue("before after") as HTMLTextAreaElement; prompt.setSelectionRange(7, 7); fireEvent.change(prompt, { target: { value: "before @after", selectionStart: 8, selectionEnd: 8 } }); fireEvent.keyDown(prompt, { key: "ArrowDown" }); fireEvent.keyDown(prompt, { key: "Enter" }); expect(prompt).toHaveValue("before @图2 after"); }); it("selects command menu items with arrow keys and enter", () => { useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "before after" })] }); render(); const prompt = screen.getByDisplayValue("before after") as HTMLTextAreaElement; prompt.setSelectionRange(7, 7); fireEvent.change(prompt, { target: { value: "before /after", selectionStart: 8, selectionEnd: 8 } }); fireEvent.keyDown(prompt, { key: "ArrowDown" }); fireEvent.keyDown(prompt, { key: "Enter" }); expect(prompt.value).toMatch(/^before Generate a character turnaround/); expect(prompt.value).toContain("after"); }); it("hides the temporary translate and settings actions", () => { useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] }); render(); expect(screen.queryByRole("button", { name: "Translate" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument(); }); it("does not render multi-angle controls in the bottom composer", () => { useWorkflowStore.setState({ nodes: [imageInputNode("input-1", true)] }); render(); expect(screen.queryByRole("button", { name: "Multi-angle" })).not.toBeInTheDocument(); expect(screen.queryByText("Multi-Angle Editor")).not.toBeInTheDocument(); }); it("collapses and expands the bottom composer without losing the selected node context", () => { useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] }); render(); expect(screen.getByDisplayValue("visible prompt")).toBeInTheDocument(); fireEvent.click(screen.getByLabelText("Collapse composer")); expect(screen.queryByDisplayValue("visible prompt")).not.toBeInTheDocument(); expect(screen.getByText("apiyi_nano_banana_2")).toBeInTheDocument(); expect(screen.getByText("visible prompt")).toBeInTheDocument(); fireEvent.click(screen.getByLabelText("Expand composer")); expect(screen.getByDisplayValue("visible prompt")).toBeInTheDocument(); }); it("expands the collapsed composer when clicking the summary content", () => { useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] }); render(); fireEvent.click(screen.getByLabelText("Collapse composer")); fireEvent.click(screen.getByText("visible prompt")); expect(screen.getByDisplayValue("visible prompt")).toBeInTheDocument(); }); it("opens model selection from the collapsed model label without expanding", () => { useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] }); render(); fireEvent.click(screen.getByLabelText("Collapse composer")); fireEvent.click(screen.getByLabelText("Select model")); expect(screen.getByTestId("model-dialog")).toHaveAttribute("data-filter", "image"); expect(screen.queryByDisplayValue("visible prompt")).not.toBeInTheDocument(); }); it("shows the concrete selected image model name in the composer", async () => { useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] }); render(); fireEvent.click(screen.getByLabelText("Select model")); fireEvent.click(screen.getByText("Select image model")); await waitFor(() => { expect(screen.getByText("apiyi_seedream5.0")).toBeInTheDocument(); }); expect(screen.queryByText("PopiArt Nano Pro")).not.toBeInTheDocument(); expect((useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData).selectedModel?.displayName) .toBe("apiyi_seedream5.0"); }); it("shows NewApiWG point cost on the generate button when model pricing is available", () => { useWorkflowStore.setState({ nodes: [pricedImageNode("img-1", true)] }); render(); expect(screen.getByRole("button", { name: "2 points" })).toBeInTheDocument(); }); it("multiplies NewApiWG image point cost by image count", () => { useWorkflowStore.setState({ nodes: [pricedImageNode("img-1", true, { imageCount: 4 })], }); render(); expect(screen.getByRole("button", { name: "8 points" })).toBeInTheDocument(); }); it("uses NewApiWG pricing variant matching the selected resolution", () => { useWorkflowStore.setState({ nodes: [ imageNode("img-1", true, { resolution: "4K", selectedModel: { provider: "newapiwg", modelId: "apiyi_nano_banana_2", displayName: "apiyi_nano_banana_2", capabilities: ["text-to-image", "image-to-image"], pricing: { type: "per-run", amount: 15, variants: [ { dimensions: { size: "1k" }, amount: 15 }, { dimensions: { size: "4k" }, amount: 24 }, ], }, }, }), ], }); render(); expect(screen.getByRole("button", { name: "24 points" })).toBeInTheDocument(); }); it("uses NewApiWG video pricing variant matching duration, resolution, sound, and reference input", () => { useWorkflowStore.setState({ nodes: [ videoNode("vid-1", true, { inputImages: ["data:image/png;base64,ref"], durationSeconds: 4, resolution: "720p", soundEnabled: true, selectedModel: { provider: "newapiwg", modelId: "viduq3-turbo", displayName: "viduq3-turbo", capabilities: ["text-to-video", "image-to-video"], pricing: { type: "per-run", amount: 20, variants: [ { dimensions: { action: "textgenerate", duration: "4s", resolution: "720p", has_video_input: false, has_sound: true, }, amount: 55, }, { dimensions: { action: "referencegenerate", duration: "4s", resolution: "720p", has_video_input: true, has_sound: true, }, amount: 70, }, ], }, }, }), ], }); render(); expect(screen.getByRole("button", { name: "70 points" })).toBeInTheDocument(); }); it("uses live Seedance video point estimates from NewApiWG", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/points/estimate")) { return new Response(JSON.stringify({ success: true, data: { estimated_points: 123, }, }), { status: 200 }); } if (url.startsWith("/api/models")) { return new Response(JSON.stringify({ success: true, models: [] }), { status: 200 }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal("fetch", fetchMock); useWorkflowStore.setState({ nodes: [ videoNode("vid-1", true, { durationSeconds: 5, resolution: "720p", selectedModel: { provider: "newapiwg", modelId: "doubao-seedance-2-0-260128", displayName: "doubao-seedance-2-0-260128", capabilities: ["text-to-video", "image-to-video"], }, }), ], }); render(); await waitFor(() => { expect(screen.getByRole("button", { name: "123 points" })).toBeInTheDocument(); }); const estimateCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/estimate")); expect(estimateCall).toBeDefined(); expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/models"))).toBe(false); expect(estimateCall?.[1]).toMatchObject({ method: "POST", headers: { "Content-Type": "application/json" }, }); expect(JSON.parse(String((estimateCall?.[1] as RequestInit).body))).toEqual({ modelName: "doubao-seedance-2-0-260128", estimationType: "seedance_video", seedanceVideo: { resolution: "720p", aspect_ratio: "16:9", input_video_duration_seconds: 0, output_video_duration_seconds: 5, }, }); }); it("uses PopiServer task price estimates for selected image models", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/points/calculate-task-price")) { return new Response(JSON.stringify({ success: true, data: { taskPrice: 36, }, }), { status: 200 }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal("fetch", fetchMock); useWorkflowStore.setState({ nodes: [ imageNode("img-1", true, { imageCount: 4, parameterSchema: [], selectedModel: { provider: "popiserver", modelId: "32", displayName: "Popi Image", capabilities: ["text-to-image", "image-to-image"], metadata: { aiModelId: 32, aiModelCode: "gpt-image-2-all", aiModelCodeAlias: "gpt-image-2-all", type: 1, subType: 102, billingMethod: 1, }, }, }), ], }); render(); await waitFor(() => { expect(screen.getByRole("button", { name: "36 points" })).toBeInTheDocument(); }); const priceCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/calculate-task-price")); expect(priceCall).toBeDefined(); expect(JSON.parse(String((priceCall?.[1] as RequestInit).body))).toMatchObject({ type: 1, subType: 102, aiModelId: 32, aiModelCode: "gpt-image-2-all", aiModelCodeAlias: "gpt-image-2-all", model: "gpt-image-2-all", aspectRatio: "16:9", ratio: "16:9", resolution: "2K", width: 1280, height: 720, batchSize: 4, }); }); it("normalizes PopiServer image price resolution to the active schema option", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/points/calculate-task-price")) { return new Response(JSON.stringify({ success: true, data: { taskPrice: 36, }, }), { status: 200 }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal("fetch", fetchMock); useWorkflowStore.setState({ nodes: [ imageNode("img-1", true, { resolution: "2K", config: { aspectRatio: "16:9", resolution: "2K", imageCount: 1, selectedModel: { provider: "popiserver", modelId: "33", displayName: "gpt-image-2-vip", capabilities: ["text-to-image", "image-to-image"], metadata: { aiModelId: 33, aiModelCode: "gpt-image-2-vip", aiModelCodeAlias: "gpt-image-2-vip", type: 1, subType: 103, billingMethod: 1, }, pricing: { type: "per-run", amount: 36, variants: [ { dimensions: { resolution: "4K" }, amount: 36 }, ], }, }, parameters: {}, }, parameterSchema: [ { name: "resolution", type: "string", label: "Resolution", options: [ { label: "4K", value: "4K" }, ], default: "4K", }, ], selectedModel: { provider: "popiserver", modelId: "33", displayName: "gpt-image-2-vip", capabilities: ["text-to-image", "image-to-image"], metadata: { aiModelId: 33, aiModelCode: "gpt-image-2-vip", aiModelCodeAlias: "gpt-image-2-vip", type: 1, subType: 103, billingMethod: 1, }, pricing: { type: "per-run", amount: 36, variants: [ { dimensions: { resolution: "4K" }, amount: 36 }, ], }, }, }), ], }); render(); await waitFor(() => { const data = useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData; expect(data.resolution).toBe("4K"); expect(data.config?.resolution).toBe("4K"); }); await waitFor(() => { expect(fetchMock.mock.calls.some(([input, init]) => { if (!String(input).startsWith("/api/points/calculate-task-price")) return false; return JSON.parse(String((init as RequestInit).body)).resolution === "4K"; })).toBe(true); }); const priceCall = fetchMock.mock.calls.find(([input, init]) => { if (!String(input).startsWith("/api/points/calculate-task-price")) return false; return JSON.parse(String((init as RequestInit).body)).resolution === "4K"; }); expect(priceCall).toBeDefined(); expect(JSON.parse(String((priceCall?.[1] as RequestInit).body))).toMatchObject({ aiModelId: 33, model: "gpt-image-2-vip", aspectRatio: "16:9", ratio: "16:9", resolution: "4K", width: 1280, height: 720, batchSize: 1, }); }); it("reuses an in-flight PopiServer task price request across remounts", async () => { let resolveTaskPrice: (response: Response) => void = () => undefined; const taskPriceResponse = new Promise((resolve) => { resolveTaskPrice = resolve; }); const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/points/calculate-task-price")) { return taskPriceResponse; } if (url.startsWith("/api/models")) { return Promise.resolve(new Response(JSON.stringify({ success: true, models: [] }), { status: 200 })); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); }); vi.stubGlobal("fetch", fetchMock); useWorkflowStore.setState({ nodes: [ imageNode("img-1", true, { imageCount: 4, parameterSchema: [], selectedModel: { provider: "popiserver", modelId: "32", displayName: "Popi Image", capabilities: ["text-to-image", "image-to-image"], metadata: { aiModelId: 32, aiModelCode: "gpt-image-2-all", aiModelCodeAlias: "gpt-image-2-all", type: 1, subType: 102, billingMethod: 1, }, }, }), ], }); const firstRender = render(); await waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(1); }); firstRender.unmount(); render(); await waitFor(() => { expect(screen.getByTestId("node-inline-composer")).toBeInTheDocument(); }); expect(fetchMock).toHaveBeenCalledTimes(1); resolveTaskPrice(new Response(JSON.stringify({ success: true, data: { taskPrice: 36, }, }), { status: 200 })); await waitFor(() => { expect(screen.getByRole("button", { name: "36 points" })).toBeInTheDocument(); }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("renders gateway image dimension options by schema name", async () => { useWorkflowStore.setState({ nodes: [ imageNode("img-1", true, { selectedModel: { provider: "popiserver", modelId: "37", displayName: "Gateway Image", capabilities: ["text-to-image", "image-to-image"], }, parameterSchema: [ { name: "ratio", type: "string", label: "Ratio", options: [ { label: "16:9", value: "16:9" }, { label: "9:16", value: "9:16" }, ], default: "16:9", }, { name: "resolution", type: "string", label: "Resolution", options: [ { label: "720p", value: "720" }, { label: "4k", value: "4k" }, ], default: "720", }, ], parameters: {}, }), ], }); render(); act(() => { fireEvent.change(screen.getByLabelText("Ratio"), { target: { value: "9:16" } }); fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "4k" } }); }); await waitFor(() => { const data = useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData; expect(data.aspectRatio).toBe("9:16"); expect(data.resolution).toBe("4k"); expect(data.config).toMatchObject({ aspectRatio: "9:16", resolution: "4k", }); }); }); it("does not show PopiServer task price estimates for token-billed models", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { throw new Error(`Unexpected fetch: ${String(input)}`); }); vi.stubGlobal("fetch", fetchMock); useWorkflowStore.setState({ nodes: [ imageNode("img-1", true, { selectedModel: { provider: "popiserver", modelId: "32", displayName: "Token Billed Image", capabilities: ["text-to-image"], metadata: { aiModelId: 32, aiModelCode: "token-image-model", aiModelCodeAlias: "token-image-model", type: 1, subType: 102, billingMethod: 2, }, }, }), ], }); render(); await waitFor(() => { expect(screen.getByRole("button", { name: "Generate" })).toBeInTheDocument(); }); expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false); }); it("uses the Seedance estimate endpoint for PopiServer Seedance models", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/points/estimate")) { return new Response(JSON.stringify({ success: true, data: { estimated_points: 88, }, }), { status: 200 }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal("fetch", fetchMock); useWorkflowStore.setState({ nodes: [ videoNode("vid-1", true, { durationSeconds: 8, resolution: "1080p", selectedModel: { provider: "popiserver", modelId: "51", displayName: "Seedance Pro", capabilities: ["text-to-video", "image-to-video"], metadata: { aiModelId: 51, aiModelCode: "doubao-seedance-2-0-260128", aiModelCodeAlias: "seedance-video-pro", type: 2, subType: 203, billingMethod: 1, }, }, }), ], }); render(); await waitFor(() => { expect(screen.getByRole("button", { name: "88 points" })).toBeInTheDocument(); }); expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false); const estimateCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/estimate")); expect(JSON.parse(String((estimateCall?.[1] as RequestInit).body))).toMatchObject({ modelName: "seedance-video-pro", estimationType: "seedance_video", seedanceVideo: { resolution: "1080p", aspect_ratio: "16:9", input_video_duration_seconds: 0, output_video_duration_seconds: 8, }, }); }); it("does not show PopiServer points when the model cannot be priced", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/models")) { return new Response(JSON.stringify({ success: true, models: [] }), { status: 200 }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal("fetch", fetchMock); useWorkflowStore.setState({ nodes: [ imageNode("img-1", true, { selectedModel: { provider: "popiserver", modelId: "", displayName: "Select model", capabilities: ["text-to-image", "image-to-image"], }, }), ], }); render(); expect(screen.queryByRole("button", { name: /points/ })).not.toBeInTheDocument(); expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false); }); it("hydrates NewApiWG point cost for stored models without pricing", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ success: true, models: [ { id: "gpt-image-2-all", name: "gpt-image-2-all", description: null, provider: "newapiwg", capabilities: ["text-to-image", "image-to-image"], pricing: { type: "per-run", amount: 3, currency: "points" }, }, ], }), { status: 200 }))); useWorkflowStore.setState({ nodes: [ imageNode("img-1", true, { selectedModel: { provider: "newapiwg", modelId: "gpt-image-2-all", displayName: "gpt-image-2-all", capabilities: ["text-to-image", "image-to-image"], }, }), ], }); render(); await waitFor(() => { expect(screen.getByRole("button", { name: "3 points" })).toBeInTheDocument(); }); expect((useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData).selectedModel?.pricing) .toEqual({ type: "per-run", amount: 3 }); }); it("submits from the collapsed generate button without expanding", async () => { useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] }); render(); fireEvent.click(screen.getByLabelText("Collapse composer")); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("img-1"); }); expect(screen.queryByDisplayValue("visible prompt")).not.toBeInTheDocument(); }); it("uses the video model filter and reruns the selected video node", async () => { useWorkflowStore.setState({ nodes: [videoNode("vid-1", true)] }); render(); fireEvent.click(screen.getByLabelText("Select model")); expect(screen.getByTestId("model-dialog")).toHaveAttribute("data-filter", "video"); const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement; fireEvent.change(prompt, { target: { value: "edited video prompt" } }); fireEvent.change(screen.getByLabelText("Duration"), { target: { value: "8" } }); fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "1080p" } }); fireEvent.click(screen.getByRole("switch", { name: "Sound" })); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("vid-1"); }); const data = useWorkflowStore.getState().nodes[0].data as GenerateVideoNodeData; expect(data.inputPrompt).toBe("edited video prompt"); expect(data.durationSeconds).toBe(8); expect(data.resolution).toBe("1080p"); expect(data.soundEnabled).toBe(false); }); it("shows connected image, video, and audio materials in the @ menu", async () => { useWorkflowStore.setState({ nodes: [ imageNode("img-ref", false, { outputImage: "https://static.popi.art/ref.jpg" }), videoNode("vid-ref", false, { outputVideo: "https://static.popi.art/ref.mp4", durationSeconds: 5.033, }), audioNode("aud-ref", false, { outputAudio: "https://static.popi.art/ref.mp3", }), videoNode("vid-1", true, { inputPrompt: "make it", }), ], edges: [ imageEdge("img-ref", "vid-1", 0), videoEdge("vid-ref", "vid-1", 1, "video"), audioEdge("aud-ref", "vid-1", 2, "audio"), ], }); render(); const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement; fireEvent.change(prompt, { target: { value: "make it @" } }); await waitFor(() => { expect(screen.getByText("图1")).toBeInTheDocument(); expect(screen.getByText("视频2")).toBeInTheDocument(); expect(screen.getByText("音频3")).toBeInTheDocument(); }); fireEvent.click(screen.getByText("音频3")); expect(prompt).toHaveValue("make it @音频3 "); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("vid-1"); }); const data = useWorkflowStore.getState().nodes[3].data as GenerateVideoNodeData; expect(data.parameters?.referenceSubjectList).toEqual([ { id: 1, type: "image", url: "https://static.popi.art/ref.jpg", name: "@图1", }, { id: 2, type: "video", url: "https://static.popi.art/ref.mp4", name: "@视频2", duration: 5.033, }, { id: 3, type: "audio", url: "https://static.popi.art/ref.mp3", name: "@音频3", }, ]); }); it("shows multiple connected text inputs before media while keeping the prompt editable", async () => { useWorkflowStore.setState({ nodes: [ promptNode("prompt-1", "first connected text"), promptNode("prompt-2", "second connected text"), imageNode("img-ref", false, { outputImage: "https://static.popi.art/ref.jpg" }), videoNode("vid-1", true, { inputPrompt: "local video prompt", }), ], edges: [ textEdge("prompt-1", "vid-1"), textEdge("prompt-2", "vid-1"), imageEdge("img-ref", "vid-1", 0), ], }); render(); expect(screen.getByTitle("first connected text")).toBeInTheDocument(); expect(screen.getByTitle("second connected text")).toBeInTheDocument(); const textMaterial = screen.getByLabelText("Text1"); const imageMaterial = screen.getByLabelText("Reference image 1"); expect(textMaterial.compareDocumentPosition(imageMaterial) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); const prompt = screen.getByDisplayValue("local video prompt") as HTMLTextAreaElement; expect(prompt).not.toHaveAttribute("readonly"); fireEvent.change(prompt, { target: { value: "edited local prompt" } }); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("vid-1"); }); const data = useWorkflowStore.getState().nodes[3].data as GenerateVideoNodeData; expect(data.inputPrompt).toBe("edited local prompt"); }); it("reorders connected materials by dragging thumbnails and syncs @ references", async () => { useWorkflowStore.setState({ nodes: [ imageNode("img-ref", false, { outputImage: "https://static.popi.art/ref.jpg" }), videoNode("vid-ref", false, { outputVideo: "https://static.popi.art/ref.mp4", durationSeconds: 5.033, }), videoNode("vid-1", true, { inputPrompt: "use @图1 and @视频2", }), ], edges: [ imageEdge("img-ref", "vid-1", 0), videoEdge("vid-ref", "vid-1", 1, "video"), ], }); render(); const dataTransferStore = new Map(); const dataTransfer = { effectAllowed: "all", setData: (type: string, value: string) => dataTransferStore.set(type, value), getData: (type: string) => dataTransferStore.get(type) ?? "", }; const videoMaterial = screen.getByTitle("@视频2"); const imageMaterial = screen.getByTitle("Reference image 1"); const prompt = screen.getByDisplayValue("use @图1 and @视频2") as HTMLTextAreaElement; fireEvent.dragStart(videoMaterial, { dataTransfer }); fireEvent.drop(imageMaterial, { dataTransfer }); expect(prompt).toHaveValue("use @图2 and @视频1"); expect(screen.getByTitle("@视频1")).toBeInTheDocument(); expect(screen.getByTitle("Reference image 2")).toBeInTheDocument(); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("vid-1"); }); const data = useWorkflowStore.getState().nodes[2].data as GenerateVideoNodeData; expect(data.parameters?.referenceSubjectList).toEqual([ { id: 1, type: "video", url: "https://static.popi.art/ref.mp4", name: "@视频1", duration: 5.033, }, { id: 2, type: "image", url: "https://static.popi.art/ref.jpg", name: "@图2", }, ]); }); it("does not allow first/last frame mode unless exactly two images are connected", async () => { useWorkflowStore.setState({ nodes: [ imageNode("img-a", false, { outputImage: "https://static.popi.art/a.jpg" }), imageNode("img-b", false, { outputImage: "https://static.popi.art/b.jpg" }), imageNode("img-c", false, { outputImage: "https://static.popi.art/c.jpg" }), videoNode("vid-1", true, { parameters: {}, }), ], edges: [ imageEdge("img-a", "vid-1", 0), imageEdge("img-b", "vid-1", 1), imageEdge("img-c", "vid-1", 2), ], }); render(); fireEvent.click(screen.getByText("First/last frame")); await waitFor(() => { const data = useWorkflowStore.getState().nodes[3].data as GenerateVideoNodeData; expect(data.parameters?.subType).not.toBe(204); }); }); it("allows image-to-video only when exactly one image is connected", async () => { useWorkflowStore.setState({ nodes: [ imageNode("img-a", false, { outputImage: "https://static.popi.art/a.jpg" }), videoNode("vid-1", true, { parameters: { subType: 203 }, }), ], edges: [ imageEdge("img-a", "vid-1", 0), ], }); render(); fireEvent.click(screen.getByText("Image to video")); await waitFor(() => { const data = useWorkflowStore.getState().nodes[1].data as GenerateVideoNodeData; expect(data.parameters?.subType).toBe(202); }); }); it("does not allow image-to-video when multiple images are connected", async () => { useWorkflowStore.setState({ nodes: [ imageNode("img-a", false, { outputImage: "https://static.popi.art/a.jpg" }), imageNode("img-b", false, { outputImage: "https://static.popi.art/b.jpg" }), videoNode("vid-1", true, { parameters: { subType: 203 }, }), ], edges: [ imageEdge("img-a", "vid-1", 0), imageEdge("img-b", "vid-1", 1), ], }); render(); fireEvent.click(screen.getByText("Image to video")); await waitFor(() => { const data = useWorkflowStore.getState().nodes[2].data as GenerateVideoNodeData; expect(data.parameters?.subType).toBe(203); }); }); it("falls back from image-to-video when multiple images are connected", async () => { useWorkflowStore.setState({ nodes: [ imageNode("img-a", false, { outputImage: "https://static.popi.art/a.jpg" }), imageNode("img-b", false, { outputImage: "https://static.popi.art/b.jpg" }), videoNode("vid-1", true, { parameters: { subType: 202 }, }), ], edges: [ imageEdge("img-a", "vid-1", 0), imageEdge("img-b", "vid-1", 1), ], }); render(); await waitFor(() => { const data = useWorkflowStore.getState().nodes[2].data as GenerateVideoNodeData; expect(data.parameters?.subType).toBe(203); }); }); it("labels two first/last frame images without changing their reference order", async () => { useWorkflowStore.setState({ nodes: [ imageNode("img-first", false, { outputImage: "https://static.popi.art/first.jpg" }), imageNode("img-last", false, { outputImage: "https://static.popi.art/last.jpg" }), videoNode("vid-1", true, { inputPrompt: "animate", parameters: { subType: 204 }, }), ], edges: [ imageEdge("img-first", "vid-1", 0), imageEdge("img-last", "vid-1", 1), ], }); render(); expect(screen.getByText("First")).toBeInTheDocument(); expect(screen.getByText("Last")).toBeInTheDocument(); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("vid-1"); }); const data = useWorkflowStore.getState().nodes[2].data as GenerateVideoNodeData; expect(data.parameters?.referenceSubjectList).toEqual([ { id: 1, type: "image", url: "https://static.popi.art/first.jpg", name: "@图1", }, { id: 2, type: "image", url: "https://static.popi.art/last.jpg", name: "@图2", }, ]); }); it("writes selected video quick settings to the node immediately", () => { useWorkflowStore.setState({ nodes: [videoNode("vid-1", true, { durationSeconds: 6, resolution: "720p", soundEnabled: true, })], }); render(); fireEvent.change(screen.getByLabelText("Duration"), { target: { value: "4" } }); fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "1080p" } }); fireEvent.click(screen.getByRole("switch", { name: "Sound" })); const data = useWorkflowStore.getState().nodes[0].data as GenerateVideoNodeData; expect(data.durationSeconds).toBe(4); expect(data.resolution).toBe("1080p"); expect(data.soundEnabled).toBe(false); }); it("renders gateway video dimension options with raw values", () => { useWorkflowStore.setState({ nodes: [ videoNode("vid-1", true, { selectedModel: { provider: "popiserver", modelId: "38", displayName: "Gateway Video", capabilities: ["text-to-video", "image-to-video"], }, parameterSchema: [ { name: "ratio", type: "string", label: "Ratio", options: [ { label: "16:9", value: "16:9" }, { label: "9:16", value: "9:16" }, ], default: "16:9", }, { name: "videoRatio", type: "integer", label: "Video ratio", options: [ { label: "10-10", value: 10 }, { label: "20-20", value: 20 }, ], default: 10, }, { name: "resolution", type: "string", label: "Resolution", options: [ { label: "720p", value: "720" }, { label: "1024p", value: "1024" }, ], default: "720", }, { name: "duration", type: "integer", label: "Duration", options: [ { label: "5", value: 5 }, { label: "4", value: 4 }, ], default: 5, }, ], parameters: {}, }), ], }); render(); act(() => { fireEvent.change(screen.getByLabelText("Ratio"), { target: { value: "9:16" } }); fireEvent.change(screen.getByLabelText("Video ratio"), { target: { value: "20" } }); fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "1024" } }); fireEvent.change(screen.getByLabelText("Duration"), { target: { value: "4" } }); }); const data = useWorkflowStore.getState().nodes[0].data as GenerateVideoNodeData; expect(data.parameters).toEqual({ videoRatio: 20, }); expect(data.aspectRatio).toBe("9:16"); expect(data.resolution).toBe("1024"); expect(data.durationSeconds).toBe(4); expect(data.config).toMatchObject({ aspectRatio: "9:16", resolution: "1024", durationSeconds: 4, parameters: { videoRatio: 20 }, }); }); it("defaults gateway video dimension quick settings to the first schema option", async () => { useWorkflowStore.setState({ nodes: [videoNode("vid-1", true, { durationSeconds: 9 })], }); render(); await waitFor(() => { expect(screen.getByLabelText("Duration")).toHaveValue("4"); }); await waitFor(() => { const data = useWorkflowStore.getState().nodes[0].data as GenerateVideoNodeData; expect(data.durationSeconds).toBe(4); expect(data.config).toMatchObject({ durationSeconds: 4, }); }); }); it("uses the audio model filter and reruns the selected audio node", async () => { useWorkflowStore.setState({ nodes: [audioNode("aud-1", true)] }); render(); expect(screen.getByText("ElevenLabs Turbo v2.5")).toBeInTheDocument(); fireEvent.click(screen.getByLabelText("Select model")); expect(screen.getByTestId("model-dialog")).toHaveAttribute("data-filter", "audio"); expect(screen.getByTestId("model-dialog")).toHaveAttribute("data-title", "Choose audio model"); const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement; fireEvent.change(prompt, { target: { value: "edited audio prompt" } }); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("aud-1"); }); expect((useWorkflowStore.getState().nodes[0].data as GenerateAudioNodeData).inputPrompt).toBe("edited audio prompt"); }); it("does not render a single-node editor for unsupported selected nodes", () => { useWorkflowStore.setState({ nodes: [promptNode("prompt-1", "hello", true)] }); render(); expect(screen.queryByTestId("node-inline-composer")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Generate")).not.toBeInTheDocument(); }); it("shows selected video stitch node content and reruns it without a prompt", async () => { useWorkflowStore.setState({ nodes: [ videoNode("vid-1", false, { outputVideo: "data:video/mp4;base64,one" }), videoNode("vid-2", false, { outputVideo: "data:video/mp4;base64,two" }), videoStitchNode("stitch-1", true, { clipOrder: ["edge-vid-2-stitch-1-video-1", "edge-vid-1-stitch-1-video-0"], }), ], edges: [ videoEdge("vid-1", "stitch-1", 0), videoEdge("vid-2", "stitch-1", 1), ], }); render(); expect(screen.getByText("2 video clips")).toBeInTheDocument(); expect(screen.getByText("Video inputs")).toBeInTheDocument(); expect(screen.getByText("Clip 1")).toBeInTheDocument(); expect(screen.queryByPlaceholderText(/Describe what you want to generate/)).not.toBeInTheDocument(); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("stitch-1"); }); }); it("requires at least two connected video clips before running a video stitch node", () => { useWorkflowStore.setState({ nodes: [ videoNode("vid-1", false, { outputVideo: "data:video/mp4;base64,one" }), videoStitchNode("stitch-1", true), ], edges: [videoEdge("vid-1", "stitch-1", 0)], }); render(); expect(screen.getByText("Connect at least 2 video clips to stitch.")).toBeInTheDocument(); expect(screen.getByLabelText("Generate")).toBeDisabled(); }); it("shows selected ease curve node content and reruns it without a prompt", async () => { useWorkflowStore.setState({ nodes: [ videoNode("vid-1", false, { outputVideo: "data:video/mp4;base64,one" }), easeCurveNode("ease-1", true), ], edges: [videoEdge("vid-1", "ease-1", 0, "video")], }); render(); expect(screen.getByText("Video connected")).toBeInTheDocument(); expect(screen.getByText("Curve settings")).toBeInTheDocument(); expect(screen.getByText("easeInOutSine")).toBeInTheDocument(); expect(screen.queryByPlaceholderText(/Describe what you want to generate/)).not.toBeInTheDocument(); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("ease-1"); }); }); it("requires a connected video before running an ease curve node", () => { useWorkflowStore.setState({ nodes: [easeCurveNode("ease-1", true)] }); render(); expect(screen.getAllByText("Connect a video input to apply ease curve.").length).toBeGreaterThan(0); expect(screen.getByLabelText("Generate")).toBeDisabled(); }); it("does not render the local reference image upload control", () => { useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] }); render(); expect(screen.queryByLabelText("Upload reference image")).not.toBeInTheDocument(); expect(screen.queryByText("Reference")).not.toBeInTheDocument(); }); it("renders stored reference images in order instead of stacking them", () => { useWorkflowStore.setState({ nodes: [ imageNode("img-1", true, { inputImages: ["data:image/png;base64,first", "data:image/png;base64,second"], }), ], }); render(); expect(screen.getByTitle("Reference image 1")).toBeInTheDocument(); expect(screen.getByTitle("Reference image 2")).toBeInTheDocument(); expect(screen.queryByLabelText(/Remove reference image/)).not.toBeInTheDocument(); }); it("opens a reference image preview from its thumbnail", async () => { useWorkflowStore.setState({ nodes: [ imageNode("img-1", true, { inputImages: ["data:image/png;base64,preview"], }), ], }); render(); fireEvent.click(screen.getByTitle("Reference image 1")); const dialog = screen.getByRole("dialog", { name: "Reference image 1" }); expect(within(dialog).getByAltText("Reference image 1")).toHaveAttribute( "src", "data:image/png;base64,preview" ); fireEvent.keyDown(window, { key: "Escape" }); await waitFor(() => { expect(screen.queryByRole("dialog", { name: "Reference image 1" })).not.toBeInTheDocument(); }); }); it("shows upstream text without overwriting the editable local prompt", async () => { useWorkflowStore.setState({ nodes: [ promptNode("prompt-1", "connected prompt"), imageNode("img-1", true, { inputPrompt: "fallback prompt" }), ], edges: [textEdge("prompt-1", "img-1")], }); render(); expect(screen.getByTitle("connected prompt")).toBeInTheDocument(); const prompt = screen.getByDisplayValue("fallback prompt") as HTMLTextAreaElement; expect(prompt).not.toHaveAttribute("readonly"); fireEvent.click(screen.getByLabelText("Generate")); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("img-1"); }); expect((useWorkflowStore.getState().nodes[1].data as NanoBananaNodeData).inputPrompt).toBe("fallback prompt"); }); it("autosaves a dirty node draft before loading another selected node", async () => { const first = imageNode("img-1", true, { inputPrompt: "first" }); const second = imageNode("img-2", false, { inputPrompt: "second" }); useWorkflowStore.setState({ nodes: [first, second] }); render(); fireEvent.change(screen.getByDisplayValue("first"), { target: { value: "edited first" }, }); act(() => { useWorkflowStore.setState({ nodes: [ { ...first, selected: false }, { ...second, selected: true }, ], }); }); await waitFor(() => { expect(screen.getByDisplayValue("second")).toBeInTheDocument(); }); const nodes = useWorkflowStore.getState().nodes; expect((nodes[0].data as NanoBananaNodeData).inputPrompt).toBe("edited first"); }); });