diff --git a/src/components/__tests__/ImageInputNode.test.tsx b/src/components/__tests__/ImageInputNode.test.tsx new file mode 100644 index 00000000..48cea4a7 --- /dev/null +++ b/src/components/__tests__/ImageInputNode.test.tsx @@ -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 {children}; +} + +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( + + + + ); + + expect(screen.getByText("Image")).toBeInTheDocument(); + }); + + it("should render drop zone when no image is set", () => { + render( + + + + ); + + expect(screen.getByText("Drop or click")).toBeInTheDocument(); + }); + + it("should render reference input handle on left", () => { + const { container } = render( + + + + ); + + const referenceHandle = container.querySelector('[data-handletype="reference"]'); + expect(referenceHandle).toBeInTheDocument(); + }); + + it("should render image output handle on right", () => { + const { container } = render( + + + + ); + + 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( + + + + ); + + const img = screen.getByAltText("test-image.png"); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="); + }); + + it("should display filename", () => { + render( + + + + ); + + expect(screen.getByText("test-image.png")).toBeInTheDocument(); + }); + + it("should display dimensions", () => { + render( + + + + ); + + expect(screen.getByText("800x600")).toBeInTheDocument(); + }); + + it("should not show drop zone when image is set", () => { + render( + + + + ); + + 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) => void) | null = null; + const mockReadAsDataURL = vi.fn(); + + class MockFileReader { + onload: ((event: ProgressEvent) => 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); + }, 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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) => void) | null = null; + result: string = "data:image/png;base64,test"; + readAsDataURL(file: Blob) { + mockReadAsDataURL(file); + } + } + global.FileReader = MockFileReader as unknown as typeof FileReader; + + render( + + + + ); + + 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) => void) | null = null; + result: string = "data:image/jpeg;base64,test"; + readAsDataURL(file: Blob) { + mockReadAsDataURL(file); + } + } + global.FileReader = MockFileReader as unknown as typeof FileReader; + + render( + + + + ); + + 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) => void) | null = null; + result: string = "data:image/webp;base64,test"; + readAsDataURL(file: Blob) { + mockReadAsDataURL(file); + } + } + global.FileReader = MockFileReader as unknown as typeof FileReader; + + render( + + + + ); + + 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( + + + + ); + + // 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( + + + + ); + + // 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + expect(screen.getByText("My Image - Image")).toBeInTheDocument(); + }); + + it("should update custom title via onCustomTitleChange", () => { + render( + + + + ); + + // 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", + }); + }); + }); +}); diff --git a/src/components/__tests__/PromptNode.test.tsx b/src/components/__tests__/PromptNode.test.tsx index c82c5232..fdbf61a8 100644 --- a/src/components/__tests__/PromptNode.test.tsx +++ b/src/components/__tests__/PromptNode.test.tsx @@ -101,4 +101,38 @@ describe("PromptNode", () => { expect(screen.getByText("Prompt")).toBeInTheDocument(); }); + + it("should render expand button", () => { + render( + + + + ); + + expect(screen.getByTitle("Expand editor")).toBeInTheDocument(); + }); + + it("should call incrementModalCount when expand button is clicked", () => { + render( + + + + ); + + const expandButton = screen.getByTitle("Expand editor"); + fireEvent.click(expandButton); + + expect(mockIncrementModalCount).toHaveBeenCalled(); + }); + + it("should render text output handle", () => { + const { container } = render( + + + + ); + + const handle = container.querySelector('[data-handletype="text"]'); + expect(handle).toBeInTheDocument(); + }); });