You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

1936 lines
64 KiB

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;
}) => (
<div data-testid="model-dialog" data-filter={props.initialCapabilityFilter} data-title={props.title}>
<button
type="button"
onClick={() =>
props.onModelSelected({
id: "apiyi_seedream5.0",
name: "apiyi_seedream5.0",
provider: "newapiwg",
capabilities: ["text-to-image", "image-to-image"],
})
}
>
Select image model
</button>
<button
type="button"
onClick={() =>
props.onModelSelected({
id: "fal-video",
name: "Fal Video",
provider: "fal",
capabilities: ["text-to-video", "image-to-video"],
})
}
>
Select video model
</button>
<button
type="button"
onClick={() =>
props.onModelSelected({
id: "elevenlabs/sound-effect-v2",
name: "ElevenLabs Sound Effects v2",
provider: "kie",
capabilities: ["text-to-audio"],
})
}
>
Select audio model
</button>
</div>
),
}));
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<NanoBananaNodeData> = {}): 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<NanoBananaNodeData> = {}): 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<ImageInputNodeData> = {}): 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<GenerateVideoNodeData> = {}): 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<GenerateAudioNodeData> = {}): 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<SmartImageNodeData> = {}): WorkflowNode {
return {
...imageNode(id, selected, data),
type: "smartImage",
} as WorkflowNode;
}
function smartVideoNode(id: string, selected = false, data: Partial<SmartVideoNodeData> = {}): WorkflowNode {
return {
...videoNode(id, selected, data),
type: "smartVideo",
} as WorkflowNode;
}
function smartAudioNode(id: string, selected = false, data: Partial<SmartAudioNodeData> = {}): WorkflowNode {
return {
...audioNode(id, selected, data),
type: "smartAudio",
} as WorkflowNode;
}
function videoStitchNode(id: string, selected = false, data: Partial<VideoStitchNodeData> = {}): 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<EaseCurveNodeData> = {}): 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<string>(),
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(
<div onPointerDown={onPointerDown} onMouseDown={onMouseDown} onClick={onClick}>
<GenerationComposer />
</div>
);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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<Response>((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(<GenerationComposer />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
firstRender.unmount();
render(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
const dataTransferStore = new Map<string, string>();
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);
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");
});
});