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, currentNodeIds: [], groups: {}, nodes: [], getNodesWithComments: vi.fn(() => []), markCommentViewed: vi.fn(), setNavigationTarget: vi.fn(), }; 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 drop zone when no image is set", () => { render( ); expect(screen.getByText("Drop image")).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 not show drop zone when image is set", () => { render( ); expect(screen.queryByText("Drop image")).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", imageRef: undefined, 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, imageRef: undefined, filename: null, dimensions: null, }); }); }); describe("Drag and Drop", () => { it("should handle dragOver event", () => { render( ); const dropZone = screen.getByText("Drop image").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 image").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 image").parentElement!; fireEvent.click(dropZone); expect(clickSpy).toHaveBeenCalled(); }); }); });