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