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.
538 lines
16 KiB
538 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 { AnnotationModal } from "@/components/AnnotationModal";
|
|
import { ToolType, ToolOptions } from "@/types";
|
|
|
|
// Mock Konva and react-konva - canvas internals cannot be tested in jsdom
|
|
vi.mock("react-konva", () => ({
|
|
Stage: ({ children }: { children: ReactNode }) => <div data-testid="konva-stage">{children}</div>,
|
|
Layer: ({ children }: { children: ReactNode }) => <div data-testid="konva-layer">{children}</div>,
|
|
Image: () => <div data-testid="konva-image" />,
|
|
Rect: () => <div data-testid="konva-rect" />,
|
|
Ellipse: () => <div data-testid="konva-ellipse" />,
|
|
Arrow: () => <div data-testid="konva-arrow" />,
|
|
Line: () => <div data-testid="konva-line" />,
|
|
Text: () => <div data-testid="konva-text" />,
|
|
Transformer: () => <div data-testid="konva-transformer" />,
|
|
}));
|
|
|
|
vi.mock("konva", () => ({
|
|
default: {
|
|
Stage: vi.fn(),
|
|
Layer: vi.fn(),
|
|
Image: vi.fn(),
|
|
Rect: vi.fn(),
|
|
Ellipse: vi.fn(),
|
|
Arrow: vi.fn(),
|
|
Line: vi.fn(),
|
|
Text: vi.fn(),
|
|
Transformer: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock annotation store
|
|
const mockCloseModal = vi.fn();
|
|
const mockAddAnnotation = vi.fn();
|
|
const mockUpdateAnnotation = vi.fn();
|
|
const mockDeleteAnnotation = vi.fn();
|
|
const mockClearAnnotations = vi.fn();
|
|
const mockSelectShape = vi.fn();
|
|
const mockSetCurrentTool = vi.fn();
|
|
const mockSetToolOptions = vi.fn();
|
|
const mockUndo = vi.fn();
|
|
const mockRedo = vi.fn();
|
|
|
|
const defaultToolOptions: ToolOptions = {
|
|
strokeColor: "#ef4444",
|
|
strokeWidth: 3,
|
|
fillColor: null,
|
|
fontSize: 24,
|
|
opacity: 1,
|
|
};
|
|
|
|
const createMockAnnotationStore = (overrides = {}) => ({
|
|
isModalOpen: true,
|
|
sourceNodeId: "test-node",
|
|
sourceImage: "data:image/png;base64,test",
|
|
annotations: [],
|
|
selectedShapeId: null,
|
|
currentTool: "rectangle" as ToolType,
|
|
toolOptions: defaultToolOptions,
|
|
closeModal: mockCloseModal,
|
|
addAnnotation: mockAddAnnotation,
|
|
updateAnnotation: mockUpdateAnnotation,
|
|
deleteAnnotation: mockDeleteAnnotation,
|
|
clearAnnotations: mockClearAnnotations,
|
|
selectShape: mockSelectShape,
|
|
setCurrentTool: mockSetCurrentTool,
|
|
setToolOptions: mockSetToolOptions,
|
|
undo: mockUndo,
|
|
redo: mockRedo,
|
|
...overrides,
|
|
});
|
|
|
|
let mockAnnotationStore = createMockAnnotationStore();
|
|
|
|
vi.mock("@/store/annotationStore", () => ({
|
|
useAnnotationStore: (selector?: (state: ReturnType<typeof createMockAnnotationStore>) => unknown) => {
|
|
if (selector) {
|
|
return selector(mockAnnotationStore);
|
|
}
|
|
return mockAnnotationStore;
|
|
},
|
|
}));
|
|
|
|
// Mock workflow store
|
|
const mockUpdateNodeData = vi.fn();
|
|
|
|
vi.mock("@/store/workflowStore", () => ({
|
|
useWorkflowStore: (selector?: (state: unknown) => unknown) => {
|
|
const state = { updateNodeData: mockUpdateNodeData };
|
|
if (selector) {
|
|
return selector(state);
|
|
}
|
|
return state;
|
|
},
|
|
}));
|
|
|
|
// Mock Image constructor
|
|
class MockImage {
|
|
onload: (() => void) | null = null;
|
|
src: string = "";
|
|
width = 800;
|
|
height = 600;
|
|
|
|
constructor() {
|
|
setTimeout(() => {
|
|
if (this.onload) this.onload();
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
// Store original Image to restore later
|
|
const OriginalImage = global.Image;
|
|
|
|
describe("AnnotationModal", () => {
|
|
beforeAll(() => {
|
|
vi.stubGlobal("Image", MockImage);
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.stubGlobal("Image", OriginalImage);
|
|
});
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockAnnotationStore = createMockAnnotationStore();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("Visibility", () => {
|
|
it("should not render when isModalOpen is false", () => {
|
|
mockAnnotationStore = createMockAnnotationStore({ isModalOpen: false });
|
|
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.queryByText("Select")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should render when isModalOpen is true", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.getByText("Select")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Image Loading", () => {
|
|
it("should load remote source images through the media proxy for canvas export", async () => {
|
|
const remoteImage = "https://cdn.example.com/generated.png";
|
|
const originalCreateObjectURL = URL.createObjectURL;
|
|
const originalRevokeObjectURL = URL.revokeObjectURL;
|
|
const createObjectURL = vi.fn(() => "blob:canvas-safe-image");
|
|
const revokeObjectURL = vi.fn();
|
|
Object.defineProperty(URL, "createObjectURL", { value: createObjectURL, configurable: true });
|
|
Object.defineProperty(URL, "revokeObjectURL", { value: revokeObjectURL, configurable: true });
|
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(new Blob(["image"], { type: "image/png" }))
|
|
);
|
|
mockAnnotationStore = createMockAnnotationStore({ sourceImage: remoteImage });
|
|
|
|
render(<AnnotationModal />);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchSpy).toHaveBeenCalledWith(`/api/proxy-media?url=${encodeURIComponent(remoteImage)}`);
|
|
expect(createObjectURL).toHaveBeenCalled();
|
|
});
|
|
|
|
Object.defineProperty(URL, "createObjectURL", { value: originalCreateObjectURL, configurable: true });
|
|
Object.defineProperty(URL, "revokeObjectURL", { value: originalRevokeObjectURL, configurable: true });
|
|
});
|
|
});
|
|
|
|
describe("Tool Buttons", () => {
|
|
it("should render all tool buttons", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.getByText("Select")).toBeInTheDocument();
|
|
expect(screen.getByText("Rect")).toBeInTheDocument();
|
|
expect(screen.getByText("Circle")).toBeInTheDocument();
|
|
expect(screen.getByText("Arrow")).toBeInTheDocument();
|
|
expect(screen.getByText("Draw")).toBeInTheDocument();
|
|
expect(screen.getByText("Text")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should call setCurrentTool when tool button is clicked", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.click(screen.getByText("Circle"));
|
|
|
|
expect(mockSetCurrentTool).toHaveBeenCalledWith("circle");
|
|
});
|
|
|
|
it("should highlight the current tool", () => {
|
|
mockAnnotationStore = createMockAnnotationStore({ currentTool: "circle" });
|
|
|
|
render(<AnnotationModal />);
|
|
|
|
const circleButton = screen.getByText("Circle");
|
|
expect(circleButton).toHaveClass("bg-white");
|
|
});
|
|
|
|
it("should set tool to select when Select is clicked", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.click(screen.getByText("Select"));
|
|
|
|
expect(mockSetCurrentTool).toHaveBeenCalledWith("select");
|
|
});
|
|
|
|
it("should set tool to rectangle when Rect is clicked", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.click(screen.getByText("Rect"));
|
|
|
|
expect(mockSetCurrentTool).toHaveBeenCalledWith("rectangle");
|
|
});
|
|
|
|
it("should set tool to arrow when Arrow is clicked", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.click(screen.getByText("Arrow"));
|
|
|
|
expect(mockSetCurrentTool).toHaveBeenCalledWith("arrow");
|
|
});
|
|
|
|
it("should set tool to freehand when Draw is clicked", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.click(screen.getByText("Draw"));
|
|
|
|
expect(mockSetCurrentTool).toHaveBeenCalledWith("freehand");
|
|
});
|
|
|
|
it("should set tool to text when Text is clicked", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.click(screen.getByText("Text"));
|
|
|
|
expect(mockSetCurrentTool).toHaveBeenCalledWith("text");
|
|
});
|
|
});
|
|
|
|
describe("Undo/Redo Buttons", () => {
|
|
it("should render Undo and Redo buttons", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.getByText("Undo")).toBeInTheDocument();
|
|
expect(screen.getByText("Redo")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should call undo when Undo button is clicked", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.click(screen.getByText("Undo"));
|
|
|
|
expect(mockUndo).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should call redo when Redo button is clicked", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.click(screen.getByText("Redo"));
|
|
|
|
expect(mockRedo).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Clear Button", () => {
|
|
it("should render Clear button", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.getByText("Clear")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should call clearAnnotations when Clear is clicked", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.click(screen.getByText("Clear"));
|
|
|
|
expect(mockClearAnnotations).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Cancel and Done Buttons", () => {
|
|
it("should render Cancel button", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render Done button", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.getByText("Done")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should call closeModal when Cancel is clicked", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.click(screen.getByText("Cancel"));
|
|
|
|
expect(mockCloseModal).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should save annotations and close when Done is clicked", async () => {
|
|
render(<AnnotationModal />);
|
|
|
|
// Done button should trigger save and close
|
|
// Note: In jsdom, the flattenImage function won't work properly
|
|
// so we just verify the button exists and triggers the handler
|
|
fireEvent.click(screen.getByText("Done"));
|
|
|
|
// closeModal should be called after save (async operation)
|
|
await waitFor(() => {
|
|
expect(mockCloseModal).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Color Picker", () => {
|
|
it("should render color label", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.getByText("Color")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render 8 color buttons", () => {
|
|
const { container } = render(<AnnotationModal />);
|
|
|
|
// Find color buttons (round buttons in the color section)
|
|
const colorButtons = container.querySelectorAll(".rounded-full.w-6.h-6");
|
|
expect(colorButtons.length).toBe(8);
|
|
});
|
|
|
|
it("should call setToolOptions when color is clicked", () => {
|
|
const { container } = render(<AnnotationModal />);
|
|
|
|
// Find color buttons and click one
|
|
const colorButtons = container.querySelectorAll(".rounded-full.w-6.h-6");
|
|
fireEvent.click(colorButtons[2]); // Click third color (yellow)
|
|
|
|
expect(mockSetToolOptions).toHaveBeenCalledWith({ strokeColor: expect.any(String) });
|
|
});
|
|
|
|
it("should highlight selected color", () => {
|
|
mockAnnotationStore = createMockAnnotationStore({
|
|
toolOptions: { ...defaultToolOptions, strokeColor: "#ef4444" },
|
|
});
|
|
|
|
const { container } = render(<AnnotationModal />);
|
|
|
|
// The first color (red #ef4444) should have ring styling
|
|
const colorButtons = container.querySelectorAll(".rounded-full.w-6.h-6");
|
|
expect(colorButtons[0]).toHaveClass("ring-2");
|
|
});
|
|
});
|
|
|
|
describe("Stroke Width", () => {
|
|
it("should render Size label", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.getByText("Size")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render 3 stroke width options", () => {
|
|
const { container } = render(<AnnotationModal />);
|
|
|
|
// Find stroke width buttons (buttons with round indicators inside)
|
|
const sizeSection = container.querySelectorAll(".w-8.h-8.rounded");
|
|
expect(sizeSection.length).toBe(3);
|
|
});
|
|
|
|
it("should call setToolOptions when stroke width is clicked", () => {
|
|
const { container } = render(<AnnotationModal />);
|
|
|
|
const sizeButtons = container.querySelectorAll(".w-8.h-8.rounded");
|
|
fireEvent.click(sizeButtons[1]); // Click medium width
|
|
|
|
expect(mockSetToolOptions).toHaveBeenCalledWith({ strokeWidth: expect.any(Number) });
|
|
});
|
|
});
|
|
|
|
describe("Fill Toggle", () => {
|
|
it("should render Fill button", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.getByText("Fill")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should toggle fill color when Fill is clicked", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.click(screen.getByText("Fill"));
|
|
|
|
expect(mockSetToolOptions).toHaveBeenCalledWith({ fillColor: expect.any(String) });
|
|
});
|
|
|
|
it("should set fill to null when already filled", () => {
|
|
mockAnnotationStore = createMockAnnotationStore({
|
|
toolOptions: { ...defaultToolOptions, fillColor: "#ef4444" },
|
|
});
|
|
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.click(screen.getByText("Fill"));
|
|
|
|
expect(mockSetToolOptions).toHaveBeenCalledWith({ fillColor: null });
|
|
});
|
|
});
|
|
|
|
describe("Zoom Controls", () => {
|
|
it("should render zoom percentage", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
// Default scale is 1, which is 100%
|
|
expect(screen.getByText("100%")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render zoom in and out buttons", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.getByText("+")).toBeInTheDocument();
|
|
expect(screen.getByText("-")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Canvas Element", () => {
|
|
it("should render Konva Stage component", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.getByTestId("konva-stage")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render Konva Layer component", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
expect(screen.getByTestId("konva-layer")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Keyboard Shortcuts", () => {
|
|
it("should call closeModal when Escape is pressed with no text editing", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.keyDown(window, { key: "Escape" });
|
|
|
|
expect(mockCloseModal).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should call undo when Ctrl+Z is pressed", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.keyDown(window, { key: "z", ctrlKey: true });
|
|
|
|
expect(mockUndo).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should call redo when Ctrl+Shift+Z is pressed", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.keyDown(window, { key: "z", ctrlKey: true, shiftKey: true });
|
|
|
|
expect(mockRedo).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should call undo when Cmd+Z is pressed (Mac)", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.keyDown(window, { key: "z", metaKey: true });
|
|
|
|
expect(mockUndo).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should call redo when Cmd+Shift+Z is pressed (Mac)", () => {
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.keyDown(window, { key: "z", metaKey: true, shiftKey: true });
|
|
|
|
expect(mockRedo).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should call deleteAnnotation when Delete is pressed with shape selected", () => {
|
|
mockAnnotationStore = createMockAnnotationStore({ selectedShapeId: "shape-1" });
|
|
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.keyDown(window, { key: "Delete" });
|
|
|
|
expect(mockDeleteAnnotation).toHaveBeenCalledWith("shape-1");
|
|
});
|
|
|
|
it("should call deleteAnnotation when Backspace is pressed with shape selected", () => {
|
|
mockAnnotationStore = createMockAnnotationStore({ selectedShapeId: "shape-1" });
|
|
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.keyDown(window, { key: "Backspace" });
|
|
|
|
expect(mockDeleteAnnotation).toHaveBeenCalledWith("shape-1");
|
|
});
|
|
|
|
it("should not delete when no shape is selected", () => {
|
|
mockAnnotationStore = createMockAnnotationStore({ selectedShapeId: null });
|
|
|
|
render(<AnnotationModal />);
|
|
|
|
fireEvent.keyDown(window, { key: "Delete" });
|
|
|
|
expect(mockDeleteAnnotation).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Modal Layout", () => {
|
|
it("should render top bar with tools", () => {
|
|
const { container } = render(<AnnotationModal />);
|
|
|
|
const topBar = container.querySelector(".h-14.bg-neutral-900.flex");
|
|
expect(topBar).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render bottom options bar", () => {
|
|
const { container } = render(<AnnotationModal />);
|
|
|
|
// Find bottom bar (h-14 at the bottom with border-t)
|
|
const bottomBars = container.querySelectorAll(".h-14.bg-neutral-900");
|
|
expect(bottomBars.length).toBe(2); // Top and bottom bars
|
|
});
|
|
|
|
it("should render canvas container in the middle", () => {
|
|
const { container } = render(<AnnotationModal />);
|
|
|
|
const canvasContainer = container.querySelector(".flex-1.overflow-hidden");
|
|
expect(canvasContainer).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|