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 }) =>
{children}
, Layer: ({ children }: { children: ReactNode }) =>
{children}
, Image: () =>
, Rect: () =>
, Ellipse: () =>
, Arrow: () =>
, Line: () =>
, Text: () =>
, 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) => 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(); expect(screen.queryByText("Select")).not.toBeInTheDocument(); }); it("should render when isModalOpen is true", () => { render(); 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(); 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(); 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(); fireEvent.click(screen.getByText("Circle")); expect(mockSetCurrentTool).toHaveBeenCalledWith("circle"); }); it("should highlight the current tool", () => { mockAnnotationStore = createMockAnnotationStore({ currentTool: "circle" }); render(); const circleButton = screen.getByText("Circle"); expect(circleButton).toHaveClass("bg-white"); }); it("should set tool to select when Select is clicked", () => { render(); fireEvent.click(screen.getByText("Select")); expect(mockSetCurrentTool).toHaveBeenCalledWith("select"); }); it("should set tool to rectangle when Rect is clicked", () => { render(); fireEvent.click(screen.getByText("Rect")); expect(mockSetCurrentTool).toHaveBeenCalledWith("rectangle"); }); it("should set tool to arrow when Arrow is clicked", () => { render(); fireEvent.click(screen.getByText("Arrow")); expect(mockSetCurrentTool).toHaveBeenCalledWith("arrow"); }); it("should set tool to freehand when Draw is clicked", () => { render(); fireEvent.click(screen.getByText("Draw")); expect(mockSetCurrentTool).toHaveBeenCalledWith("freehand"); }); it("should set tool to text when Text is clicked", () => { render(); fireEvent.click(screen.getByText("Text")); expect(mockSetCurrentTool).toHaveBeenCalledWith("text"); }); }); describe("Undo/Redo Buttons", () => { it("should render Undo and Redo buttons", () => { render(); expect(screen.getByText("Undo")).toBeInTheDocument(); expect(screen.getByText("Redo")).toBeInTheDocument(); }); it("should call undo when Undo button is clicked", () => { render(); fireEvent.click(screen.getByText("Undo")); expect(mockUndo).toHaveBeenCalled(); }); it("should call redo when Redo button is clicked", () => { render(); fireEvent.click(screen.getByText("Redo")); expect(mockRedo).toHaveBeenCalled(); }); }); describe("Clear Button", () => { it("should render Clear button", () => { render(); expect(screen.getByText("Clear")).toBeInTheDocument(); }); it("should call clearAnnotations when Clear is clicked", () => { render(); fireEvent.click(screen.getByText("Clear")); expect(mockClearAnnotations).toHaveBeenCalled(); }); }); describe("Cancel and Done Buttons", () => { it("should render Cancel button", () => { render(); expect(screen.getByText("Cancel")).toBeInTheDocument(); }); it("should render Done button", () => { render(); expect(screen.getByText("Done")).toBeInTheDocument(); }); it("should call closeModal when Cancel is clicked", () => { render(); fireEvent.click(screen.getByText("Cancel")); expect(mockCloseModal).toHaveBeenCalled(); }); it("should save annotations and close when Done is clicked", async () => { render(); // 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(); expect(screen.getByText("Color")).toBeInTheDocument(); }); it("should render 8 color buttons", () => { const { container } = render(); // 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(); // 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(); // 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(); expect(screen.getByText("Size")).toBeInTheDocument(); }); it("should render 3 stroke width options", () => { const { container } = render(); // 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(); 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(); expect(screen.getByText("Fill")).toBeInTheDocument(); }); it("should toggle fill color when Fill is clicked", () => { render(); 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(); fireEvent.click(screen.getByText("Fill")); expect(mockSetToolOptions).toHaveBeenCalledWith({ fillColor: null }); }); }); describe("Zoom Controls", () => { it("should render zoom percentage", () => { render(); // Default scale is 1, which is 100% expect(screen.getByText("100%")).toBeInTheDocument(); }); it("should render zoom in and out buttons", () => { render(); expect(screen.getByText("+")).toBeInTheDocument(); expect(screen.getByText("-")).toBeInTheDocument(); }); }); describe("Canvas Element", () => { it("should render Konva Stage component", () => { render(); expect(screen.getByTestId("konva-stage")).toBeInTheDocument(); }); it("should render Konva Layer component", () => { render(); expect(screen.getByTestId("konva-layer")).toBeInTheDocument(); }); }); describe("Keyboard Shortcuts", () => { it("should call closeModal when Escape is pressed with no text editing", () => { render(); fireEvent.keyDown(window, { key: "Escape" }); expect(mockCloseModal).toHaveBeenCalled(); }); it("should call undo when Ctrl+Z is pressed", () => { render(); fireEvent.keyDown(window, { key: "z", ctrlKey: true }); expect(mockUndo).toHaveBeenCalled(); }); it("should call redo when Ctrl+Shift+Z is pressed", () => { render(); fireEvent.keyDown(window, { key: "z", ctrlKey: true, shiftKey: true }); expect(mockRedo).toHaveBeenCalled(); }); it("should call undo when Cmd+Z is pressed (Mac)", () => { render(); fireEvent.keyDown(window, { key: "z", metaKey: true }); expect(mockUndo).toHaveBeenCalled(); }); it("should call redo when Cmd+Shift+Z is pressed (Mac)", () => { render(); 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(); 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(); fireEvent.keyDown(window, { key: "Backspace" }); expect(mockDeleteAnnotation).toHaveBeenCalledWith("shape-1"); }); it("should not delete when no shape is selected", () => { mockAnnotationStore = createMockAnnotationStore({ selectedShapeId: null }); render(); fireEvent.keyDown(window, { key: "Delete" }); expect(mockDeleteAnnotation).not.toHaveBeenCalled(); }); }); describe("Modal Layout", () => { it("should render top bar with tools", () => { const { container } = render(); const topBar = container.querySelector(".h-14.bg-neutral-900.flex"); expect(topBar).toBeInTheDocument(); }); it("should render bottom options bar", () => { const { container } = render(); // 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(); const canvasContainer = container.querySelector(".flex-1.overflow-hidden"); expect(canvasContainer).toBeInTheDocument(); }); }); });