Browse Source
- Basic rendering with title and handles (image input/output) - Empty state with "Drop, click, or connect" message - Image display (sourceImage, outputImage, priority logic) - Edit overlay with annotation count display - Modal opening via image click with existing annotations - Remove button clearing all image data and annotations - File upload with format/size validation (PNG, JPG, WebP; <10MB) - Drag and drop handling - Custom title editing functionality Note: Tests focus on component shell and state management, not internal Konva canvas interactions per plan guidance. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>handoff-20260429-1057
1 changed files with 514 additions and 0 deletions
@ -0,0 +1,514 @@ |
|||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; |
||||
|
import { AnnotationNode } from "@/components/nodes/AnnotationNode"; |
||||
|
import { ReactFlowProvider } from "@xyflow/react"; |
||||
|
import { AnnotationNodeData } from "@/types"; |
||||
|
|
||||
|
// Mock the workflow store
|
||||
|
const mockUpdateNodeData = vi.fn(); |
||||
|
const mockUseWorkflowStore = vi.fn(); |
||||
|
|
||||
|
vi.mock("@/store/workflowStore", () => ({ |
||||
|
useWorkflowStore: (selector?: (state: unknown) => unknown) => { |
||||
|
if (selector) { |
||||
|
return mockUseWorkflowStore(selector); |
||||
|
} |
||||
|
return mockUseWorkflowStore((s: unknown) => s); |
||||
|
}, |
||||
|
})); |
||||
|
|
||||
|
// Mock the annotation store
|
||||
|
const mockOpenModal = vi.fn(); |
||||
|
const mockUseAnnotationStore = vi.fn(); |
||||
|
|
||||
|
vi.mock("@/store/annotationStore", () => ({ |
||||
|
useAnnotationStore: (selector?: (state: unknown) => unknown) => { |
||||
|
if (selector) { |
||||
|
return mockUseAnnotationStore(selector); |
||||
|
} |
||||
|
return mockUseAnnotationStore((s: unknown) => s); |
||||
|
}, |
||||
|
})); |
||||
|
|
||||
|
// Mock alert
|
||||
|
const mockAlert = vi.fn(); |
||||
|
global.alert = mockAlert; |
||||
|
|
||||
|
// Mock DataTransfer
|
||||
|
class MockDataTransfer { |
||||
|
items: { add: (file: File) => void }; |
||||
|
private _files: File[] = []; |
||||
|
get files() { |
||||
|
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("AnnotationNode", () => { |
||||
|
beforeEach(() => { |
||||
|
vi.clearAllMocks(); |
||||
|
|
||||
|
// Default mock implementation for workflow store
|
||||
|
mockUseWorkflowStore.mockImplementation((selector) => { |
||||
|
const state = { |
||||
|
updateNodeData: mockUpdateNodeData, |
||||
|
currentNodeId: null, |
||||
|
groups: {}, |
||||
|
nodes: [], |
||||
|
}; |
||||
|
return selector(state); |
||||
|
}); |
||||
|
|
||||
|
// Default mock implementation for annotation store
|
||||
|
mockUseAnnotationStore.mockImplementation((selector) => { |
||||
|
const state = { |
||||
|
openModal: mockOpenModal, |
||||
|
}; |
||||
|
return selector(state); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
vi.restoreAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
const createNodeData = (overrides: Partial<AnnotationNodeData> = {}): AnnotationNodeData => ({ |
||||
|
sourceImage: null, |
||||
|
annotations: [], |
||||
|
outputImage: null, |
||||
|
...overrides, |
||||
|
}); |
||||
|
|
||||
|
const createNodeProps = (data: Partial<AnnotationNodeData> = {}) => ({ |
||||
|
id: "test-annotation-1", |
||||
|
type: "annotation" as const, |
||||
|
data: createNodeData(data), |
||||
|
selected: false, |
||||
|
}); |
||||
|
|
||||
|
describe("Basic Rendering", () => { |
||||
|
it("should render with title 'Annotate'", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("Annotate")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should render image input handle on left", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const inputHandle = container.querySelector('[data-handletype="image"][class*="target"]'); |
||||
|
expect(inputHandle).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should render image output handle on right", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const outputHandle = container.querySelector('[data-handletype="image"][class*="source"]'); |
||||
|
expect(outputHandle).toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Empty State", () => { |
||||
|
it("should show empty state message when no image", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps({ sourceImage: null, outputImage: null })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("Drop, click, or connect")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should render drop zone with dashed border when no image", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const dropZone = container.querySelector(".border-dashed"); |
||||
|
expect(dropZone).toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Image Display", () => { |
||||
|
const propsWithImage = { |
||||
|
sourceImage: "data:image/png;base64,sourceImageData", |
||||
|
annotations: [], |
||||
|
outputImage: null, |
||||
|
}; |
||||
|
|
||||
|
it("should display source image when sourceImage is set", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps(propsWithImage)} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const img = screen.getByAltText("Annotated"); |
||||
|
expect(img).toBeInTheDocument(); |
||||
|
expect(img).toHaveAttribute("src", "data:image/png;base64,sourceImageData"); |
||||
|
}); |
||||
|
|
||||
|
it("should display output image when outputImage is set", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps({ |
||||
|
sourceImage: "data:image/png;base64,sourceImageData", |
||||
|
outputImage: "data:image/png;base64,outputImageData", |
||||
|
annotations: [], |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const img = screen.getByAltText("Annotated"); |
||||
|
expect(img).toHaveAttribute("src", "data:image/png;base64,outputImageData"); |
||||
|
}); |
||||
|
|
||||
|
it("should prefer outputImage over sourceImage when both exist", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps({ |
||||
|
sourceImage: "data:image/png;base64,source", |
||||
|
outputImage: "data:image/png;base64,output", |
||||
|
annotations: [], |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const img = screen.getByAltText("Annotated"); |
||||
|
expect(img).toHaveAttribute("src", "data:image/png;base64,output"); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Edit Button / Image Click", () => { |
||||
|
it("should show 'Add annotations' hint when no annotations exist", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps({ |
||||
|
sourceImage: "data:image/png;base64,test", |
||||
|
annotations: [], |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("Add annotations")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should show annotation count when annotations exist", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps({ |
||||
|
sourceImage: "data:image/png;base64,test", |
||||
|
annotations: [ |
||||
|
{ id: "1", type: "rectangle", x: 0, y: 0, width: 100, height: 100, stroke: "#ff0000", strokeWidth: 2, opacity: 1, fill: null }, |
||||
|
{ id: "2", type: "rectangle", x: 50, y: 50, width: 100, height: 100, stroke: "#00ff00", strokeWidth: 2, opacity: 1, fill: null }, |
||||
|
], |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("Edit (2)")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should open annotation modal when image is clicked", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps({ |
||||
|
sourceImage: "data:image/png;base64,test", |
||||
|
annotations: [], |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const img = screen.getByAltText("Annotated"); |
||||
|
fireEvent.click(img.parentElement!); |
||||
|
|
||||
|
expect(mockOpenModal).toHaveBeenCalledWith( |
||||
|
"test-annotation-1", |
||||
|
"data:image/png;base64,test", |
||||
|
[] |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it("should pass existing annotations when opening modal", () => { |
||||
|
const annotations = [ |
||||
|
{ id: "1", type: "rectangle" as const, x: 0, y: 0, width: 100, height: 100, stroke: "#ff0000", strokeWidth: 2, opacity: 1, fill: null }, |
||||
|
]; |
||||
|
|
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps({ |
||||
|
sourceImage: "data:image/png;base64,test", |
||||
|
annotations, |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const img = screen.getByAltText("Annotated"); |
||||
|
fireEvent.click(img.parentElement!); |
||||
|
|
||||
|
expect(mockOpenModal).toHaveBeenCalledWith( |
||||
|
"test-annotation-1", |
||||
|
"data:image/png;base64,test", |
||||
|
annotations |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it("should show alert when trying to edit without an image", () => { |
||||
|
// Test when trying to trigger edit without image
|
||||
|
// The handleEdit function alerts when there's no image
|
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps({ sourceImage: null, outputImage: null })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
// Empty state doesn't have the clickable edit area
|
||||
|
// But we can verify the empty state is shown
|
||||
|
expect(screen.getByText("Drop, click, or connect")).toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Remove/Clear Button", () => { |
||||
|
it("should render remove button when image is present", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps({ |
||||
|
sourceImage: "data:image/png;base64,test", |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
// The remove button has an X SVG icon
|
||||
|
const removeButton = container.querySelector('button svg path[d*="M6 18"]'); |
||||
|
expect(removeButton).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should call updateNodeData to clear when remove button is clicked", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps({ |
||||
|
sourceImage: "data:image/png;base64,test", |
||||
|
outputImage: "data:image/png;base64,output", |
||||
|
annotations: [{ id: "1", type: "rectangle", x: 0, y: 0, width: 100, height: 100, stroke: "#ff0000", strokeWidth: 2, opacity: 1, fill: null }], |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
// Find the remove button by looking for the button element that contains the X icon
|
||||
|
// The button has opacity-0 by default but is still clickable
|
||||
|
const buttons = container.querySelectorAll('button'); |
||||
|
// The remove button is the one with the X icon SVG
|
||||
|
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-annotation-1", { |
||||
|
sourceImage: null, |
||||
|
outputImage: null, |
||||
|
annotations: [], |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("File Upload", () => { |
||||
|
it("should render hidden file input", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const fileInput = container.querySelector('input[type="file"]'); |
||||
|
expect(fileInput).toBeInTheDocument(); |
||||
|
expect(fileInput).toHaveClass("hidden"); |
||||
|
}); |
||||
|
|
||||
|
it("should trigger file input click when drop zone is clicked", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; |
||||
|
const clickSpy = vi.spyOn(fileInput, "click"); |
||||
|
|
||||
|
const dropZone = screen.getByText("Drop, click, or connect").parentElement!; |
||||
|
fireEvent.click(dropZone); |
||||
|
|
||||
|
expect(clickSpy).toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it("should process valid image file and call updateNodeData", async () => { |
||||
|
// Mock FileReader
|
||||
|
const mockReadAsDataURL = vi.fn(); |
||||
|
class MockFileReader { |
||||
|
onload: ((event: ProgressEvent<FileReader>) => void) | null = null; |
||||
|
result: string = "data:image/png;base64,uploadedImage"; |
||||
|
readAsDataURL(file: Blob) { |
||||
|
mockReadAsDataURL(file); |
||||
|
setTimeout(() => { |
||||
|
this.onload?.({ target: { result: this.result } } as ProgressEvent<FileReader>); |
||||
|
}, 0); |
||||
|
} |
||||
|
} |
||||
|
global.FileReader = MockFileReader as unknown as typeof FileReader; |
||||
|
|
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps()} /> |
||||
|
</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); |
||||
|
|
||||
|
await waitFor(() => { |
||||
|
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-annotation-1", { |
||||
|
sourceImage: "data:image/png;base64,uploadedImage", |
||||
|
outputImage: null, |
||||
|
annotations: [], |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("should reject non-image file types", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps()} /> |
||||
|
</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> |
||||
|
<AnnotationNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; |
||||
|
const file = new File([""], "large.png", { type: "image/png" }); |
||||
|
Object.defineProperty(file, "size", { value: 11 * 1024 * 1024 }); |
||||
|
Object.defineProperty(fileInput, "files", { value: [file] }); |
||||
|
|
||||
|
fireEvent.change(fileInput); |
||||
|
|
||||
|
expect(mockAlert).toHaveBeenCalledWith("Image too large. Maximum size is 10MB."); |
||||
|
expect(mockUpdateNodeData).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Drag and Drop", () => { |
||||
|
it("should handle dragOver event", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const dropZone = screen.getByText("Drop, click, or connect").parentElement!; |
||||
|
const dragOverEvent = new Event("dragover", { bubbles: true }); |
||||
|
Object.assign(dragOverEvent, { |
||||
|
preventDefault: vi.fn(), |
||||
|
stopPropagation: vi.fn(), |
||||
|
}); |
||||
|
|
||||
|
fireEvent(dropZone, dragOverEvent); |
||||
|
|
||||
|
// Should handle without error
|
||||
|
expect(dropZone).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should handle drop event with empty files", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const dropZone = screen.getByText("Drop, click, or connect").parentElement!; |
||||
|
|
||||
|
fireEvent.drop(dropZone, { |
||||
|
dataTransfer: { files: [] }, |
||||
|
}); |
||||
|
|
||||
|
// Should handle gracefully without updating node data
|
||||
|
expect(mockUpdateNodeData).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Custom Title and Comment", () => { |
||||
|
it("should display custom title when provided", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps({ customTitle: "My Annotation" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText(/My Annotation/)).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should call updateNodeData when custom title is changed", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<AnnotationNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
// Click on title to edit
|
||||
|
const title = screen.getByText("Annotate"); |
||||
|
fireEvent.click(title); |
||||
|
|
||||
|
const input = screen.getByPlaceholderText("Custom title..."); |
||||
|
fireEvent.change(input, { target: { value: "Custom Annotate" } }); |
||||
|
fireEvent.keyDown(input, { key: "Enter" }); |
||||
|
|
||||
|
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-annotation-1", { customTitle: "Custom Annotate" }); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue