You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
531 lines
16 KiB
531 lines
16 KiB
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import type { ReactNode } from "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();
|
|
|
|
// 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),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Wrapper component for React Flow context
|
|
function TestWrapper({ children }: { children: ReactNode }) {
|
|
return <ReactFlowProvider>{children}</ReactFlowProvider>;
|
|
}
|
|
|
|
describe("AnnotationNode", () => {
|
|
beforeAll(() => {
|
|
vi.stubGlobal("alert", mockAlert);
|
|
vi.stubGlobal("DataTransfer", MockDataTransfer);
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
// Default mock implementation for workflow store
|
|
mockUseWorkflowStore.mockImplementation((selector) => {
|
|
const state = {
|
|
updateNodeData: mockUpdateNodeData,
|
|
currentNodeIds: [],
|
|
groups: {},
|
|
nodes: [],
|
|
getNodesWithComments: vi.fn(() => []),
|
|
markCommentViewed: vi.fn(),
|
|
setNavigationTarget: vi.fn(),
|
|
};
|
|
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,
|
|
sourceImageRef: undefined,
|
|
outputImage: null,
|
|
outputImageRef: undefined,
|
|
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 using vi.stubGlobal for proper cleanup
|
|
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);
|
|
}
|
|
}
|
|
vi.stubGlobal("FileReader", MockFileReader);
|
|
|
|
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",
|
|
sourceImageRef: undefined,
|
|
outputImage: null,
|
|
outputImageRef: undefined,
|
|
annotations: [],
|
|
});
|
|
});
|
|
|
|
// Restore FileReader
|
|
vi.unstubAllGlobals();
|
|
// Re-stub the globals we need for other tests
|
|
vi.stubGlobal("alert", mockAlert);
|
|
vi.stubGlobal("DataTransfer", MockDataTransfer);
|
|
});
|
|
|
|
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!;
|
|
|
|
// Use fireEvent.dragOver for idiomatic testing
|
|
fireEvent.dragOver(dropZone);
|
|
|
|
// 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" });
|
|
});
|
|
});
|
|
});
|
|
|