2 changed files with 239 additions and 0 deletions
@ -0,0 +1,170 @@ |
|||
import { describe, it, expect, vi, beforeEach } from "vitest"; |
|||
import { executeSplitGrid } from "../splitGridExecutor"; |
|||
import type { NodeExecutionContext } from "../types"; |
|||
import type { WorkflowNode } from "@/types"; |
|||
|
|||
// Mock gridSplitter
|
|||
vi.mock("@/utils/gridSplitter", () => ({ |
|||
splitWithDimensions: vi.fn().mockResolvedValue({ |
|||
images: ["split-0.png", "split-1.png", "split-2.png", "split-3.png"], |
|||
}), |
|||
})); |
|||
|
|||
// Mock Image constructor for dimension loading
|
|||
class MockImage { |
|||
width = 512; |
|||
height = 512; |
|||
onload: (() => void) | null = null; |
|||
onerror: (() => void) | null = null; |
|||
private _src = ""; |
|||
get src() { return this._src; } |
|||
set src(val: string) { |
|||
this._src = val; |
|||
// Use queueMicrotask so onload fires after assignment
|
|||
queueMicrotask(() => this.onload?.()); |
|||
} |
|||
} |
|||
vi.stubGlobal("Image", MockImage); |
|||
|
|||
const defaultProviderSettings = { |
|||
providers: { |
|||
gemini: { apiKey: "" }, |
|||
replicate: { apiKey: "" }, |
|||
fal: { apiKey: "" }, |
|||
kie: { apiKey: "" }, |
|||
wavespeed: { apiKey: "" }, |
|||
openai: { apiKey: "" }, |
|||
}, |
|||
} as any; |
|||
|
|||
function makeNode(data: Record<string, unknown> = {}): WorkflowNode { |
|||
return { |
|||
id: "sg-1", |
|||
type: "splitGrid", |
|||
position: { x: 0, y: 0 }, |
|||
data: { |
|||
sourceImage: null, |
|||
status: null, |
|||
error: null, |
|||
gridRows: 2, |
|||
gridCols: 2, |
|||
isConfigured: true, |
|||
childNodeIds: [ |
|||
{ imageInput: "child-0" }, |
|||
{ imageInput: "child-1" }, |
|||
{ imageInput: "child-2" }, |
|||
{ imageInput: "child-3" }, |
|||
], |
|||
...data, |
|||
}, |
|||
} as WorkflowNode; |
|||
} |
|||
|
|||
function makeCtx( |
|||
node: WorkflowNode, |
|||
overrides: Partial<NodeExecutionContext> = {} |
|||
): NodeExecutionContext { |
|||
return { |
|||
node, |
|||
getConnectedInputs: vi.fn().mockReturnValue({ |
|||
images: ["data:image/png;base64,source"], |
|||
videos: [], |
|||
audio: [], |
|||
text: null, |
|||
dynamicInputs: {}, |
|||
easeCurve: null, |
|||
}), |
|||
updateNodeData: vi.fn(), |
|||
getFreshNode: vi.fn().mockReturnValue(node), |
|||
getEdges: vi.fn().mockReturnValue([]), |
|||
getNodes: vi.fn().mockReturnValue([node]), |
|||
providerSettings: defaultProviderSettings, |
|||
addIncurredCost: vi.fn(), |
|||
addToGlobalHistory: vi.fn(), |
|||
generationsPath: null, |
|||
saveDirectoryPath: null, |
|||
get: vi.fn(), |
|||
...overrides, |
|||
}; |
|||
} |
|||
|
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
describe("executeSplitGrid", () => { |
|||
it("should throw when no input image connected", async () => { |
|||
const node = makeNode(); |
|||
const ctx = makeCtx(node, { |
|||
getConnectedInputs: vi.fn().mockReturnValue({ |
|||
images: [], |
|||
videos: [], |
|||
audio: [], |
|||
text: null, |
|||
dynamicInputs: {}, |
|||
easeCurve: null, |
|||
}), |
|||
}); |
|||
|
|||
await expect(executeSplitGrid(ctx)).rejects.toThrow("No input image connected"); |
|||
}); |
|||
|
|||
it("should throw when not configured", async () => { |
|||
const node = makeNode({ isConfigured: false }); |
|||
const ctx = makeCtx(node); |
|||
|
|||
await expect(executeSplitGrid(ctx)).rejects.toThrow("Node not configured"); |
|||
}); |
|||
|
|||
it("should set loading status", async () => { |
|||
const node = makeNode(); |
|||
const ctx = makeCtx(node); |
|||
|
|||
await executeSplitGrid(ctx); |
|||
|
|||
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls; |
|||
const loadingCall = calls.find( |
|||
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "loading" |
|||
); |
|||
expect(loadingCall).toBeDefined(); |
|||
}); |
|||
|
|||
it("should set complete status on success", async () => { |
|||
const node = makeNode(); |
|||
const ctx = makeCtx(node); |
|||
|
|||
await executeSplitGrid(ctx); |
|||
|
|||
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls; |
|||
const completeCall = calls.find( |
|||
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete" |
|||
); |
|||
expect(completeCall).toBeDefined(); |
|||
}); |
|||
|
|||
it("should update child imageInput nodes with split images", async () => { |
|||
const node = makeNode(); |
|||
const ctx = makeCtx(node); |
|||
|
|||
await executeSplitGrid(ctx); |
|||
|
|||
// Check that child nodes were updated
|
|||
expect(ctx.updateNodeData).toHaveBeenCalledWith("child-0", expect.objectContaining({ |
|||
image: "split-0.png", |
|||
})); |
|||
expect(ctx.updateNodeData).toHaveBeenCalledWith("child-1", expect.objectContaining({ |
|||
image: "split-1.png", |
|||
})); |
|||
}); |
|||
|
|||
it("should set source image on node", async () => { |
|||
const node = makeNode(); |
|||
const ctx = makeCtx(node); |
|||
|
|||
await executeSplitGrid(ctx); |
|||
|
|||
expect(ctx.updateNodeData).toHaveBeenCalledWith("sg-1", expect.objectContaining({ |
|||
sourceImage: "data:image/png;base64,source", |
|||
})); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,69 @@ |
|||
/** |
|||
* SplitGrid Executor |
|||
* |
|||
* Unified executor for splitGrid nodes. |
|||
* Splits an input image into grid cells and populates child imageInput nodes. |
|||
*/ |
|||
|
|||
import type { SplitGridNodeData } from "@/types"; |
|||
import type { NodeExecutionContext } from "./types"; |
|||
|
|||
export async function executeSplitGrid(ctx: NodeExecutionContext): Promise<void> { |
|||
const { node, getConnectedInputs, updateNodeData } = ctx; |
|||
|
|||
const { images } = getConnectedInputs(node.id); |
|||
const sourceImage = images[0] || null; |
|||
|
|||
if (!sourceImage) { |
|||
updateNodeData(node.id, { |
|||
status: "error", |
|||
error: "No input image connected", |
|||
}); |
|||
throw new Error("No input image connected"); |
|||
} |
|||
|
|||
const nodeData = node.data as SplitGridNodeData; |
|||
|
|||
if (!nodeData.isConfigured) { |
|||
updateNodeData(node.id, { |
|||
status: "error", |
|||
error: "Node not configured - open settings first", |
|||
}); |
|||
throw new Error("Node not configured - open settings first"); |
|||
} |
|||
|
|||
updateNodeData(node.id, { |
|||
sourceImage, |
|||
status: "loading", |
|||
error: null, |
|||
}); |
|||
|
|||
const { splitWithDimensions } = await import("@/utils/gridSplitter"); |
|||
const { images: splitImages } = await splitWithDimensions( |
|||
sourceImage, |
|||
nodeData.gridRows, |
|||
nodeData.gridCols |
|||
); |
|||
|
|||
// Populate child imageInput nodes with split images
|
|||
for (let index = 0; index < nodeData.childNodeIds.length; index++) { |
|||
const childSet = nodeData.childNodeIds[index]; |
|||
if (splitImages[index]) { |
|||
await new Promise<void>((resolve) => { |
|||
const img = new Image(); |
|||
img.onload = () => { |
|||
updateNodeData(childSet.imageInput, { |
|||
image: splitImages[index], |
|||
filename: `split-${Math.floor(index / nodeData.gridCols) + 1}-${(index % nodeData.gridCols) + 1}.png`, |
|||
dimensions: { width: img.width, height: img.height }, |
|||
}); |
|||
resolve(); |
|||
}; |
|||
img.onerror = () => resolve(); |
|||
img.src = splitImages[index]; |
|||
}); |
|||
} |
|||
} |
|||
|
|||
updateNodeData(node.id, { status: "complete", error: null }); |
|||
} |
|||
Loading…
Reference in new issue