Browse Source
PromptNode test extensions: - Add test for expand button rendering - Add test for incrementModalCount called when expand clicked - Add test for text output handle presence ImageInputNode tests (21 tests): - Basic rendering (title, drop zone, handles) - Image display (image src, filename, dimensions) - File input change handler with FileReader mocking - File validation (reject non-images, reject >10MB files) - Accept PNG, JPEG, WebP file types - Remove button functionality - Drag and drop event handling - Click to upload via drop zone - Custom title and comment functionality Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>handoff-20260429-1057
2 changed files with 550 additions and 0 deletions
@ -0,0 +1,516 @@ |
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
|||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; |
|||
import { ImageInputNode } from "@/components/nodes/ImageInputNode"; |
|||
import { ReactFlowProvider } from "@xyflow/react"; |
|||
|
|||
// Mock the workflow store
|
|||
const mockUpdateNodeData = vi.fn(); |
|||
|
|||
vi.mock("@/store/workflowStore", () => ({ |
|||
useWorkflowStore: vi.fn((selector) => { |
|||
const state = { |
|||
updateNodeData: mockUpdateNodeData, |
|||
currentNodeId: null, |
|||
groups: {}, |
|||
nodes: [], |
|||
}; |
|||
return selector(state); |
|||
}), |
|||
})); |
|||
|
|||
// Mock alert
|
|||
const mockAlert = vi.fn(); |
|||
global.alert = mockAlert; |
|||
|
|||
// Mock DataTransfer (not available in jsdom)
|
|||
// The drop handler in ImageInputNode tries to set fileInputRef.current.files
|
|||
// which requires a proper FileList. Since jsdom doesn't allow setting files directly
|
|||
// from a plain array, we need to work around this in the test.
|
|||
class MockDataTransfer { |
|||
items: { add: (file: File) => void }; |
|||
private _files: File[] = []; |
|||
get files() { |
|||
// Return a FileList-like object
|
|||
const fileList = Object.assign(this._files, { |
|||
item: (index: number) => this._files[index] || null, |
|||
}); |
|||
return fileList as unknown as FileList; |
|||
} |
|||
constructor() { |
|||
this.items = { |
|||
add: (file: File) => this._files.push(file), |
|||
}; |
|||
} |
|||
} |
|||
global.DataTransfer = MockDataTransfer as unknown as typeof DataTransfer; |
|||
|
|||
// Wrapper component for React Flow context
|
|||
function TestWrapper({ children }: { children: React.ReactNode }) { |
|||
return <ReactFlowProvider>{children}</ReactFlowProvider>; |
|||
} |
|||
|
|||
describe("ImageInputNode", () => { |
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
vi.restoreAllMocks(); |
|||
}); |
|||
|
|||
const defaultProps = { |
|||
id: "test-image-1", |
|||
type: "imageInput" as const, |
|||
data: { |
|||
image: null, |
|||
filename: null, |
|||
dimensions: null, |
|||
}, |
|||
selected: false, |
|||
isConnectable: true, |
|||
positionAbsoluteX: 0, |
|||
positionAbsoluteY: 0, |
|||
zIndex: 0, |
|||
dragging: false, |
|||
deletable: true, |
|||
selectable: true, |
|||
parentId: undefined, |
|||
dragHandle: undefined, |
|||
}; |
|||
|
|||
describe("Basic Rendering", () => { |
|||
it("should render the Image title", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByText("Image")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render drop zone when no image is set", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByText("Drop or click")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render reference input handle on left", () => { |
|||
const { container } = render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const referenceHandle = container.querySelector('[data-handletype="reference"]'); |
|||
expect(referenceHandle).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render image output handle on right", () => { |
|||
const { container } = render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const imageHandle = container.querySelector('[data-handletype="image"]'); |
|||
expect(imageHandle).toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
|
|||
describe("Image Display", () => { |
|||
const propsWithImage = { |
|||
...defaultProps, |
|||
data: { |
|||
image: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==", |
|||
filename: "test-image.png", |
|||
dimensions: { width: 800, height: 600 }, |
|||
}, |
|||
}; |
|||
|
|||
it("should display image when data.image is set", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...propsWithImage} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const img = screen.getByAltText("test-image.png"); |
|||
expect(img).toBeInTheDocument(); |
|||
expect(img).toHaveAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="); |
|||
}); |
|||
|
|||
it("should display filename", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...propsWithImage} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByText("test-image.png")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should display dimensions", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...propsWithImage} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByText("800x600")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should not show drop zone when image is set", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...propsWithImage} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.queryByText("Drop or click")).not.toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
|
|||
describe("File Input Change Handler", () => { |
|||
it("should process valid image file and call updateNodeData", async () => { |
|||
// Mock FileReader as a class
|
|||
let mockOnload: ((event: ProgressEvent<FileReader>) => void) | null = null; |
|||
const mockReadAsDataURL = vi.fn(); |
|||
|
|||
class MockFileReader { |
|||
onload: ((event: ProgressEvent<FileReader>) => void) | null = null; |
|||
result: string = "data:image/png;base64,test123"; |
|||
readAsDataURL(file: Blob) { |
|||
mockOnload = this.onload; |
|||
mockReadAsDataURL(file); |
|||
// Trigger onload asynchronously
|
|||
setTimeout(() => { |
|||
this.onload?.({ target: { result: this.result } } as ProgressEvent<FileReader>); |
|||
}, 0); |
|||
} |
|||
} |
|||
global.FileReader = MockFileReader as unknown as typeof FileReader; |
|||
|
|||
// Mock Image as a class
|
|||
let mockImageOnload: (() => void) | null = null; |
|||
class MockImage { |
|||
onload: (() => void) | null = null; |
|||
width: number = 1024; |
|||
height: number = 768; |
|||
private _src: string = ""; |
|||
get src() { return this._src; } |
|||
set src(value: string) { |
|||
this._src = value; |
|||
mockImageOnload = this.onload; |
|||
// Trigger onload asynchronously
|
|||
setTimeout(() => { |
|||
this.onload?.(); |
|||
}, 0); |
|||
} |
|||
} |
|||
global.Image = MockImage as unknown as typeof Image; |
|||
|
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; |
|||
expect(fileInput).toBeInTheDocument(); |
|||
|
|||
const file = new File(["test"], "test.png", { type: "image/png" }); |
|||
Object.defineProperty(fileInput, "files", { value: [file] }); |
|||
|
|||
fireEvent.change(fileInput); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-image-1", { |
|||
image: "data:image/png;base64,test123", |
|||
filename: "test.png", |
|||
dimensions: { width: 1024, height: 768 }, |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
it("should reject non-image file types", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; |
|||
|
|||
const file = new File(["test"], "test.txt", { type: "text/plain" }); |
|||
Object.defineProperty(fileInput, "files", { value: [file] }); |
|||
|
|||
fireEvent.change(fileInput); |
|||
|
|||
expect(mockAlert).toHaveBeenCalledWith("Unsupported format. Use PNG, JPG, or WebP."); |
|||
expect(mockUpdateNodeData).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("should reject files larger than 10MB", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; |
|||
|
|||
// Create a file object with size > 10MB
|
|||
const file = new File([""], "large.png", { type: "image/png" }); |
|||
Object.defineProperty(file, "size", { value: 11 * 1024 * 1024 }); // 11MB
|
|||
Object.defineProperty(fileInput, "files", { value: [file] }); |
|||
|
|||
fireEvent.change(fileInput); |
|||
|
|||
expect(mockAlert).toHaveBeenCalledWith("Image too large. Maximum size is 10MB."); |
|||
expect(mockUpdateNodeData).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("should accept PNG files", () => { |
|||
const mockReadAsDataURL = vi.fn(); |
|||
class MockFileReader { |
|||
onload: ((event: ProgressEvent<FileReader>) => void) | null = null; |
|||
result: string = "data:image/png;base64,test"; |
|||
readAsDataURL(file: Blob) { |
|||
mockReadAsDataURL(file); |
|||
} |
|||
} |
|||
global.FileReader = MockFileReader as unknown as typeof FileReader; |
|||
|
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; |
|||
|
|||
const file = new File(["test"], "test.png", { type: "image/png" }); |
|||
Object.defineProperty(fileInput, "files", { value: [file] }); |
|||
|
|||
fireEvent.change(fileInput); |
|||
|
|||
expect(mockAlert).not.toHaveBeenCalled(); |
|||
expect(mockReadAsDataURL).toHaveBeenCalledWith(file); |
|||
}); |
|||
|
|||
it("should accept JPEG files", () => { |
|||
const mockReadAsDataURL = vi.fn(); |
|||
class MockFileReader { |
|||
onload: ((event: ProgressEvent<FileReader>) => void) | null = null; |
|||
result: string = "data:image/jpeg;base64,test"; |
|||
readAsDataURL(file: Blob) { |
|||
mockReadAsDataURL(file); |
|||
} |
|||
} |
|||
global.FileReader = MockFileReader as unknown as typeof FileReader; |
|||
|
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; |
|||
|
|||
const file = new File(["test"], "test.jpg", { type: "image/jpeg" }); |
|||
Object.defineProperty(fileInput, "files", { value: [file] }); |
|||
|
|||
fireEvent.change(fileInput); |
|||
|
|||
expect(mockAlert).not.toHaveBeenCalled(); |
|||
expect(mockReadAsDataURL).toHaveBeenCalledWith(file); |
|||
}); |
|||
|
|||
it("should accept WebP files", () => { |
|||
const mockReadAsDataURL = vi.fn(); |
|||
class MockFileReader { |
|||
onload: ((event: ProgressEvent<FileReader>) => void) | null = null; |
|||
result: string = "data:image/webp;base64,test"; |
|||
readAsDataURL(file: Blob) { |
|||
mockReadAsDataURL(file); |
|||
} |
|||
} |
|||
global.FileReader = MockFileReader as unknown as typeof FileReader; |
|||
|
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; |
|||
|
|||
const file = new File(["test"], "test.webp", { type: "image/webp" }); |
|||
Object.defineProperty(fileInput, "files", { value: [file] }); |
|||
|
|||
fireEvent.change(fileInput); |
|||
|
|||
expect(mockAlert).not.toHaveBeenCalled(); |
|||
expect(mockReadAsDataURL).toHaveBeenCalledWith(file); |
|||
}); |
|||
}); |
|||
|
|||
describe("Remove Button", () => { |
|||
const propsWithImage = { |
|||
...defaultProps, |
|||
data: { |
|||
image: "data:image/png;base64,test123", |
|||
filename: "test-image.png", |
|||
dimensions: { width: 800, height: 600 }, |
|||
}, |
|||
}; |
|||
|
|||
it("should render remove button when image is present", () => { |
|||
const { container } = render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...propsWithImage} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
// The remove button contains an SVG with X icon
|
|||
const removeButton = container.querySelector("button"); |
|||
expect(removeButton).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should call updateNodeData to clear image when remove button is clicked", () => { |
|||
const { container } = render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...propsWithImage} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
// Find the remove button (it's the button inside the image container)
|
|||
const buttons = container.querySelectorAll("button"); |
|||
// The remove button is the one that appears when image is present
|
|||
const removeButton = Array.from(buttons).find((btn) => |
|||
btn.querySelector('svg path[d*="M6 18"]') |
|||
); |
|||
expect(removeButton).toBeInTheDocument(); |
|||
|
|||
if (removeButton) { |
|||
fireEvent.click(removeButton); |
|||
} |
|||
|
|||
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-image-1", { |
|||
image: null, |
|||
filename: null, |
|||
dimensions: null, |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe("Drag and Drop", () => { |
|||
it("should handle dragOver event", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const dropZone = screen.getByText("Drop or click").parentElement!; |
|||
const dragOverEvent = new Event("dragover", { bubbles: true }); |
|||
Object.assign(dragOverEvent, { |
|||
preventDefault: vi.fn(), |
|||
stopPropagation: vi.fn(), |
|||
}); |
|||
|
|||
fireEvent(dropZone, dragOverEvent); |
|||
|
|||
// Event should be handled without error
|
|||
expect(dropZone).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should handle drop event with empty dataTransfer", () => { |
|||
// Note: Full drop-to-file-processing flow cannot be fully tested in jsdom
|
|||
// because jsdom doesn't allow setting HTMLInputElement.files property.
|
|||
// We test that the drop handler gracefully handles an empty file list.
|
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const dropZone = screen.getByText("Drop or click").parentElement!; |
|||
|
|||
// Drop with empty files array (no file to process)
|
|||
const dataTransfer = { |
|||
files: [], |
|||
}; |
|||
|
|||
fireEvent.drop(dropZone, { dataTransfer }); |
|||
|
|||
// Component should handle empty drop gracefully
|
|||
expect(dropZone).toBeInTheDocument(); |
|||
// No updateNodeData should be called with empty files
|
|||
expect(mockUpdateNodeData).not.toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
|
|||
describe("Click to Upload", () => { |
|||
it("should trigger file input click when drop zone is clicked", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; |
|||
const clickSpy = vi.spyOn(fileInput, "click"); |
|||
|
|||
const dropZone = screen.getByText("Drop or click").parentElement!; |
|||
fireEvent.click(dropZone); |
|||
|
|||
expect(clickSpy).toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
|
|||
describe("Custom Title and Comment", () => { |
|||
it("should display custom title when provided", () => { |
|||
const propsWithCustomTitle = { |
|||
...defaultProps, |
|||
data: { |
|||
...defaultProps.data, |
|||
customTitle: "My Image", |
|||
}, |
|||
}; |
|||
|
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...propsWithCustomTitle} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByText("My Image - Image")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should update custom title via onCustomTitleChange", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<ImageInputNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
// Click on title to edit
|
|||
const title = screen.getByText("Image"); |
|||
fireEvent.click(title); |
|||
|
|||
const input = screen.getByPlaceholderText("Custom title..."); |
|||
fireEvent.change(input, { target: { value: "New Title" } }); |
|||
fireEvent.keyDown(input, { key: "Enter" }); |
|||
|
|||
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-image-1", { |
|||
customTitle: "New Title", |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue