import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, expect, test, vi } from "vitest"; import { calculateCropSourceRect } from "@/shared/lib/crop-image"; import type { CropRenderRequest } from "@/shared/lib/crop-image"; import { ImageCropDialog } from "@/shared/ui/image-crop-dialog"; import type { ImageCropDialogProps } from "@/shared/ui/image-crop-dialog"; type FakeCropperCoordinates = { readonly height: number; readonly left: number; readonly top: number; readonly width: number; }; type FakeCropperProps = { readonly checkOrientation?: boolean; readonly src?: string; readonly stencilProps?: { readonly aspectRatio?: number; }; }; type FakeCropperHandle = { readonly getCoordinates: () => FakeCropperCoordinates | null; readonly moveImage: (left: number, top?: number) => void; readonly reset: () => void; readonly zoomImage: (scale: number) => void; }; const cropperFake = vi.hoisted(() => { let checkOrientation: boolean | undefined; return { get checkOrientation() { return checkOrientation; }, getCoordinates: vi.fn<() => FakeCropperCoordinates | null>(() => ({ height: 600, left: 300, top: 0, width: 600 })), moveImage: vi.fn(), reset: vi.fn(), set checkOrientation(value: boolean | undefined) { checkOrientation = value; }, src: "", stencilAspectRatio: 0, zoomImage: vi.fn(), }; }); vi.mock("react-advanced-cropper", async () => { const { forwardRef, useImperativeHandle } = await vi.importActual("react"); return { Cropper: forwardRef(function FakeCropper(props, ref) { cropperFake.checkOrientation = props.checkOrientation; cropperFake.src = props.src ?? ""; cropperFake.stencilAspectRatio = props.stencilProps?.aspectRatio ?? 0; useImperativeHandle(ref, () => ({ getCoordinates: cropperFake.getCoordinates, moveImage: cropperFake.moveImage, reset: cropperFake.reset, zoomImage: cropperFake.zoomImage, })); return
; }), ImageRestriction: { fillArea: "fillArea", fitArea: "fitArea", none: "none", stencil: "stencil", }, }; }); const image = { file: new File(["image"], "profile.png", { type: "image/png" }), height: 600, previewUrl: "blob:profile", width: 1200, }; const squarePolicy = { aspect: 1, maxWidth: 800, noUpscale: true } as const; function renderDialog(props: ImageCropDialogProps): void { render(); } beforeEach(() => { vi.clearAllMocks(); cropperFake.checkOrientation = undefined; cropperFake.getCoordinates.mockReturnValue({ height: 600, left: 300, top: 0, width: 600 }); cropperFake.src = ""; cropperFake.stencilAspectRatio = 0; }); test("Given an open dialog, when it renders, then it shows the Cropper viewport without legacy direction or range controls", () => { renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: squarePolicy }); expect(screen.getByRole("application", { name: "이미지 crop viewport" })).toHaveAttribute("tabindex", "0"); expect(cropperFake.src).toBe(image.previewUrl); expect(screen.queryByRole("button", { name: "위로 이동" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "아래로 이동" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "왼쪽으로 이동" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "오른쪽으로 이동" })).not.toBeInTheDocument(); expect(screen.queryByRole("slider", { name: "확대 비율" })).not.toBeInTheDocument(); }); test("Given a local preview URL, when the Cropper renders, then it disables the library orientation request", () => { renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: squarePolicy }); expect(cropperFake.checkOrientation).toBe(false); }); test("Given Cropper coordinates, when apply is selected, then it maps the existing render request to the same source rectangle and returns its File", async () => { const croppedFile = new File(["crop"], "profile-crop.png", { type: "image/png" }); const onApply = vi.fn(); const renderCrop = vi.fn((request: CropRenderRequest) => { expect(calculateCropSourceRect(request)).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 }); return Promise.resolve(croppedFile); }); renderDialog({ image, onApply, onCancel: vi.fn(), open: true, policy: squarePolicy, renderCrop }); fireEvent.click(screen.getByRole("button", { name: "적용" })); await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile)); expect(renderCrop).toHaveBeenCalledTimes(1); }); test.each([ { coordinates: { height: 300, left: 400, top: 100, width: 300 }, expected: { height: 300, sourceX: 400, sourceY: 100, width: 300 }, policy: squarePolicy, }, { coordinates: { height: 1500, left: 1000, top: 500, width: 1060.5 }, expected: { height: 1500, sourceX: 1000, sourceY: 500, width: 1061 }, policy: { aspect: 210 / 297, maxWidth: 1000, noUpscale: true } as const, }, ])("Given an off-center fixed-ratio crop, when apply is selected, then it preserves the Cropper source coordinates", async ({ coordinates, expected, policy }) => { const sourceImage = policy.aspect === 1 ? image : { ...image, height: 3000, width: 4000 }; const renderCrop = vi.fn<(request: CropRenderRequest) => Promise>(() => Promise.resolve(new File(["crop"], "profile-crop.png", { type: "image/png" }))); cropperFake.getCoordinates.mockReturnValue(coordinates); renderDialog({ image: sourceImage, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy, renderCrop }); fireEvent.click(screen.getByRole("button", { name: "적용" })); await waitFor(() => expect(renderCrop).toHaveBeenCalledTimes(1)); expect(calculateCropSourceRect(renderCrop.mock.calls[0]![0])).toEqual(expected); }); test("Given Cropper coordinates are not ready, when apply is selected, then it preserves the centered crop contract", async () => { const croppedFile = new File(["crop"], "profile-crop.png", { type: "image/png" }); const onApply = vi.fn(); const renderCrop = vi.fn((request: CropRenderRequest) => { expect(calculateCropSourceRect(request)).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 }); return Promise.resolve(croppedFile); }); cropperFake.getCoordinates.mockReturnValue(null); renderDialog({ image, onApply, onCancel: vi.fn(), open: true, policy: squarePolicy, renderCrop }); fireEvent.click(screen.getByRole("button", { name: "적용" })); await waitFor(() => expect(renderCrop).toHaveBeenCalledTimes(1)); await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile)); }); test("Given a free aspect policy, when the dialog renders, then it gives the source ratio to the stencil and reports an 800 by 400 result", () => { cropperFake.getCoordinates.mockReturnValue({ height: 600, left: 0, top: 0, width: 1200 }); renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: { aspect: "free", maxWidth: 800, noUpscale: true } }); expect(cropperFake.stencilAspectRatio).toBe(2); expect(screen.getByText("예상 결과 800 × 400px")).toBeInTheDocument(); }); test("Given the focusable Cropper viewport, when keyboard controls and reset are used, then it delegates movement, zoom, and reset to CropperRef", () => { renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: squarePolicy }); const viewport = screen.getByTestId("advanced-cropper"); viewport.focus(); fireEvent.keyDown(viewport, { key: "ArrowUp" }); fireEvent.keyDown(viewport, { key: "ArrowDown" }); fireEvent.keyDown(viewport, { key: "ArrowLeft" }); fireEvent.keyDown(viewport, { key: "ArrowRight" }); fireEvent.keyDown(viewport, { key: "+" }); fireEvent.keyDown(viewport, { key: "=" }); fireEvent.keyDown(viewport, { key: "-" }); fireEvent.click(screen.getByRole("button", { name: "초기화" })); expect(cropperFake.moveImage).toHaveBeenNthCalledWith(1, 0, -10); expect(cropperFake.moveImage).toHaveBeenNthCalledWith(2, 0, 10); expect(cropperFake.moveImage).toHaveBeenNthCalledWith(3, -10, 0); expect(cropperFake.moveImage).toHaveBeenNthCalledWith(4, 10, 0); expect(cropperFake.zoomImage).toHaveBeenNthCalledWith(1, 1.1); expect(cropperFake.zoomImage).toHaveBeenNthCalledWith(2, 1.1); expect(cropperFake.zoomImage).toHaveBeenNthCalledWith(3, 0.9); expect(cropperFake.reset).toHaveBeenCalledTimes(1); }); test("Given an apply request in flight, when apply is repeated and rendering fails, then it stays single-flight, shows the error, and allows retry", async () => { const onApply = vi.fn(); let rejectCrop: (error: Error) => void = () => undefined; const renderCrop = vi.fn(() => new Promise((_resolve, reject) => { rejectCrop = reject; })); renderDialog({ image, onApply, onCancel: vi.fn(), open: true, policy: squarePolicy, renderCrop }); fireEvent.click(screen.getByRole("button", { name: "적용" })); fireEvent.click(screen.getByRole("button", { name: "적용" })); await screen.findByRole("status"); expect(renderCrop).toHaveBeenCalledTimes(1); rejectCrop(new Error("render failed")); expect(await screen.findByRole("alert")).toHaveTextContent("이미지 crop을 적용하지 못했습니다."); fireEvent.click(screen.getByRole("button", { name: "적용" })); expect(renderCrop).toHaveBeenCalledTimes(2); expect(onApply).not.toHaveBeenCalled(); }); test("Given an open dialog inside a parent, when Escape is pressed, then it cancels once without bubbling", () => { const onCancel = vi.fn(); const onParentEscape = vi.fn(); render(
{ if (event.key === "Escape") { onParentEscape(); } }}>
, ); fireEvent.keyDown(screen.getByRole("dialog", { name: "이미지 crop" }), { key: "Escape" }); expect(onCancel).toHaveBeenCalledTimes(1); expect(onParentEscape).not.toHaveBeenCalled(); }); test("Given crop rendering is in progress, when Escape is pressed, then it keeps the pending result active", async () => { const croppedFile = new File(["crop"], "profile-crop.png", { type: "image/png" }); const onApply = vi.fn(); const onCancel = vi.fn(); let resolveCrop: (file: File) => void = () => undefined; const renderCrop = vi.fn(() => new Promise((resolve) => { resolveCrop = resolve; })); renderDialog({ image, onApply, onCancel, open: true, policy: squarePolicy, renderCrop }); fireEvent.click(screen.getByRole("button", { name: "적용" })); await screen.findByRole("status"); fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); expect(onCancel).not.toHaveBeenCalled(); resolveCrop(croppedFile); await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile)); }); test("Given a closed dialog, when it renders, then it returns no dialog", () => { renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: false, policy: squarePolicy }); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); });