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

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();
});
});
});