feat(ai-character): 리소스 관리 기반 정비

This commit is contained in:
Yu Sung
2026-08-01 01:30:24 +09:00
parent 55ba0df77a
commit a1cae336d9
73 changed files with 5162 additions and 816 deletions

View File

@@ -11,6 +11,51 @@ const image = {
width: 600,
};
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: () => ({}) }),
});
}
function rect(width: number, height: number): DOMRect {
return { bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0, toJSON: () => ({}) };
}
async function withElementRects(testBody: () => Promise<void>): Promise<void> {
const originalElementRect = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "getBoundingClientRect");
const originalImageRect = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "getBoundingClientRect");
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", {
configurable: true,
value(this: HTMLElement) {
if (this.getAttribute("aria-label") === "이미지 crop viewport") {
return rect(181, 256);
}
return rect(0, 0);
},
});
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", {
configurable: true,
value: () => rect(384, 384),
});
return testBody().finally(() => {
if (originalElementRect === undefined) {
Reflect.deleteProperty(HTMLElement.prototype, "getBoundingClientRect");
} else {
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", originalElementRect);
}
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 () => {
const onApply = vi.fn();
const onCancel = vi.fn();
@@ -20,7 +65,7 @@ test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply con
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
expect(screen.getByText("예상 결과 600 × 600px")).toBeInTheDocument();
expect(screen.getByText("예상 결과 400 × 400px")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "초기화" }));
fireEvent.click(screen.getByRole("button", { name: "적용" }));
@@ -42,8 +87,118 @@ test("ImageCropDialog supports keyboard movement and no-upscale sizing", async (
fireEvent.keyDown(preview, { key: "+" });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
expect(await screen.findByText("예상 결과 600 × 300px")).toBeInTheDocument();
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 300, outputWidth: 600, zoom: 1.1 }));
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 () => {
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} />);
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", () => {
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 }} />
</div>,
);
fireEvent.keyDown(screen.getByRole("dialog", { name: "이미지 crop" }), { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onParentEscape).not.toHaveBeenCalled();
});
test("ImageCropDialog supports free ratio output and pointer drag movement", async () => {
@@ -52,6 +207,7 @@ test("ImageCropDialog supports free ratio output and pointer drag movement", asy
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 });