feat(ai-character): 이미지 crop 조작 개선
This commit is contained in:
@@ -1,197 +1,233 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
|
||||
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: 600,
|
||||
width: 1200,
|
||||
};
|
||||
|
||||
function setPreviewFrameSize(width: number, height: number): void {
|
||||
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => ({ bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0, toJSON: () => ({}) }),
|
||||
});
|
||||
const squarePolicy = { aspect: 1, maxWidth: 800, noUpscale: true } as const;
|
||||
|
||||
function renderDialog(props: ImageCropDialogProps): void {
|
||||
render(<ImageCropDialog {...props} />);
|
||||
}
|
||||
|
||||
function rect(width: number, height: number): DOMRect {
|
||||
return { bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0, toJSON: () => ({}) };
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cropperFake.checkOrientation = undefined;
|
||||
cropperFake.getCoordinates.mockReturnValue({ height: 600, left: 300, top: 0, width: 600 });
|
||||
cropperFake.src = "";
|
||||
cropperFake.stencilAspectRatio = 0;
|
||||
});
|
||||
|
||||
async function withElementRects(testBody: () => Promise<void>): Promise<void> {
|
||||
const originalElementRect = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "getBoundingClientRect");
|
||||
const originalImageRect = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "getBoundingClientRect");
|
||||
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 });
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value(this: HTMLElement) {
|
||||
if (this.getAttribute("aria-label") === "이미지 crop viewport") {
|
||||
return rect(181, 256);
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
return rect(0, 0);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => rect(384, 384),
|
||||
});
|
||||
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 });
|
||||
|
||||
return testBody().finally(() => {
|
||||
if (originalElementRect === undefined) {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, "getBoundingClientRect");
|
||||
} else {
|
||||
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", originalElementRect);
|
||||
}
|
||||
expect(cropperFake.checkOrientation).toBe(false);
|
||||
});
|
||||
|
||||
if (originalImageRect === undefined) {
|
||||
Reflect.deleteProperty(HTMLImageElement.prototype, "getBoundingClientRect");
|
||||
} else {
|
||||
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", originalImageRect);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply controls", async () => {
|
||||
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 onCancel = vi.fn();
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" })));
|
||||
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 });
|
||||
|
||||
render(<ImageCropDialog image={image} onApply={onApply} onCancel={onCancel} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
|
||||
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
|
||||
expect(screen.getByText("예상 결과 400 × 400px")).toBeInTheDocument();
|
||||
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: "초기화" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
await screen.findByText("예상 결과 600 × 600px");
|
||||
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 0, offsetY: 0, outputHeight: 600, outputWidth: 600, zoom: 1 }));
|
||||
expect(onApply).toHaveBeenCalledWith(expect.any(File));
|
||||
fireEvent.click(screen.getByRole("button", { name: "취소" }));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
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("ImageCropDialog supports keyboard movement and no-upscale sizing", async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 2, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
|
||||
fireEvent.keyDown(preview, { key: "ArrowRight" });
|
||||
fireEvent.keyDown(preview, { key: "+" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
expect(await screen.findByText("예상 결과 545 × 272px")).toBeInTheDocument();
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 272, outputWidth: 545, zoom: 1.1 }));
|
||||
});
|
||||
|
||||
test("ImageCropDialog sends preview frame dimensions with crop offsets", async () => {
|
||||
setPreviewFrameSize(256, 256);
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
await screen.findByText("예상 결과 1000 × 1000px");
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, previewFrameHeight: 256, previewFrameWidth: 256, sourceHeight: 3000, sourceWidth: 4000 }));
|
||||
});
|
||||
|
||||
test("ImageCropDialog measures the visible crop viewport instead of the transformed image", async () => {
|
||||
await withElementRects(async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 210 / 297, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
|
||||
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
await screen.findByText("예상 결과 1000 × 1414px");
|
||||
expect(screen.getByLabelText("이미지 crop viewport")).toHaveStyle({ aspectRatio: `${210 / 297}` });
|
||||
expect(screen.getByAltText("선택한 이미지 미리보기")).toHaveClass("h-full", "w-auto", "max-w-none");
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ previewFrameHeight: 256, previewFrameWidth: 181, zoom: 1.5 }));
|
||||
});
|
||||
});
|
||||
|
||||
test("ImageCropDialog clamps movement on axes without crop overhang", async () => {
|
||||
await withElementRects(async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetY}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 210 / 297, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "아래로 이동" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
expect(screen.getByAltText("선택한 이미지 미리보기")).toHaveStyle({ transform: "translate(-50%, -50%) translate(0px, 0px) scale(1)" });
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetY: 0 }));
|
||||
});
|
||||
});
|
||||
|
||||
test("ImageCropDialog keeps apply single-flight and allows retry after render failure", async () => {
|
||||
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;
|
||||
}));
|
||||
|
||||
render(<ImageCropDialog image={image} onApply={onApply} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
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("ImageCropDialog changes zoom with a two pointer pinch", async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
|
||||
fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 });
|
||||
fireEvent.pointerDown(preview, { clientX: 200, clientY: 100, pointerId: 2 });
|
||||
fireEvent.pointerMove(preview, { clientX: 250, clientY: 100, pointerId: 2 });
|
||||
fireEvent.pointerUp(preview, { pointerId: 1 });
|
||||
fireEvent.pointerUp(preview, { pointerId: 2 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
expect(await screen.findByText("예상 결과 400 × 400px")).toBeInTheDocument();
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ outputHeight: 400, outputWidth: 400, zoom: 1.5 }));
|
||||
expect(screen.getByRole("slider", { name: "확대 비율" })).toHaveValue("1.5");
|
||||
});
|
||||
|
||||
test("ImageCropDialog disables native touch gestures on the crop preview", () => {
|
||||
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />);
|
||||
|
||||
expect(screen.getByRole("application", { name: "이미지 crop 미리보기" })).toHaveStyle({ touchAction: "none" });
|
||||
});
|
||||
|
||||
test("ImageCropDialog closes itself on Escape without bubbling to parent dialogs", () => {
|
||||
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={{ aspect: 1, maxWidth: 800, noUpscale: true }} />
|
||||
<ImageCropDialog image={image} onApply={vi.fn()} onCancel={onCancel} open policy={squarePolicy} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
@@ -201,24 +237,27 @@ test("ImageCropDialog closes itself on Escape without bubbling to parent dialogs
|
||||
expect(onParentEscape).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ImageCropDialog supports free ratio output and pointer drag movement", async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX},${request.offsetY}`], "crop.png", { type: "image/png" })));
|
||||
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 });
|
||||
|
||||
render(<ImageCropDialog image={{ ...image, height: 600, width: 1200 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: "free", maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
|
||||
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
|
||||
fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 });
|
||||
fireEvent.pointerMove(preview, { clientX: 130, clientY: 115, pointerId: 1 });
|
||||
fireEvent.pointerUp(preview, { pointerId: 1 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
await screen.findByRole("status");
|
||||
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
|
||||
|
||||
expect(await screen.findByText("예상 결과 800 × 400px")).toBeInTheDocument();
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 30, offsetY: 15, outputHeight: 400, outputWidth: 800 }));
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
resolveCrop(croppedFile);
|
||||
await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile));
|
||||
});
|
||||
|
||||
test("ImageCropDialog renders nothing when closed", () => {
|
||||
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open={false} policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />);
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user