Files
voiceon-character-admin/src/shared/ui/__tests__/image-crop-dialog.test.tsx

264 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof import("react")>("react");
return {
Cropper: forwardRef<FakeCropperHandle, FakeCropperProps>(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 <div data-testid="advanced-cropper" />;
}),
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(<ImageCropDialog {...props} />);
}
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<File>>(() => 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<File>((_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(
<div onKeyDown={(event) => {
if (event.key === "Escape") {
onParentEscape();
}
}}>
<ImageCropDialog image={image} onApply={vi.fn()} onCancel={onCancel} open policy={squarePolicy} />
</div>,
);
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<File>((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();
});