250 lines
12 KiB
TypeScript
250 lines
12 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { StrictMode } from "react";
|
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
|
|
|
import { FileField } from "@/shared/ui/file-field";
|
|
|
|
const createObjectUrl = vi.fn<(blob: Blob) => string>();
|
|
const revokeObjectUrl = vi.fn<(url: string) => void>();
|
|
let originalCreateObjectUrl: PropertyDescriptor | undefined;
|
|
let originalRevokeObjectUrl: PropertyDescriptor | undefined;
|
|
|
|
beforeEach(() => {
|
|
createObjectUrl.mockReset().mockImplementation(() => `blob:preview-${createObjectUrl.mock.calls.length}`);
|
|
revokeObjectUrl.mockReset();
|
|
originalCreateObjectUrl = Object.getOwnPropertyDescriptor(URL, "createObjectURL");
|
|
originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL");
|
|
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectUrl });
|
|
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectUrl });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalCreateObjectUrl === undefined) {
|
|
Reflect.deleteProperty(URL, "createObjectURL");
|
|
} else {
|
|
Object.defineProperty(URL, "createObjectURL", originalCreateObjectUrl);
|
|
}
|
|
if (originalRevokeObjectUrl === undefined) {
|
|
Reflect.deleteProperty(URL, "revokeObjectURL");
|
|
} else {
|
|
Object.defineProperty(URL, "revokeObjectURL", originalRevokeObjectUrl);
|
|
}
|
|
});
|
|
|
|
test("FileField exposes label, description, error, accept guidance, keyboard file input, and controlled value", () => {
|
|
const onChange = vi.fn();
|
|
const value = new File(["image"], "profile.png", { type: "image/png" });
|
|
|
|
render(
|
|
<FileField
|
|
accept="image/png"
|
|
acceptDescription="PNG만 업로드할 수 있습니다."
|
|
description="프로필 이미지를 선택하세요."
|
|
error="파일이 너무 큽니다."
|
|
label="대표 이미지"
|
|
onChange={onChange}
|
|
value={value}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("대표 이미지");
|
|
expect(input).toHaveAttribute("accept", "image/png");
|
|
expect(input).toHaveAttribute("aria-invalid", "true");
|
|
expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다.");
|
|
expect(screen.getByText("프로필 이미지를 선택하세요.")).toHaveClass("break-keep");
|
|
expect(screen.getByText("PNG만 업로드할 수 있습니다.")).toHaveClass("break-keep");
|
|
expect(screen.getByRole("button", { name: "대표 이미지 파일 선택" })).toBeInTheDocument();
|
|
expect(screen.getByText("profile.png")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "대표 이미지 선택 취소" })).toHaveTextContent("선택 취소");
|
|
});
|
|
|
|
test("FileField emits File or null and clear selection without owning upload policy", () => {
|
|
const onChange = vi.fn();
|
|
const selected = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
|
|
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={null} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("오디오"), { target: { files: [selected] } });
|
|
expect(onChange).toHaveBeenCalledWith(selected);
|
|
|
|
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={selected} />);
|
|
fireEvent.click(screen.getByRole("button", { name: "오디오 선택 취소" }));
|
|
expect(onChange).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
test("FileField clears the native selection when the controlled value is cleared externally", () => {
|
|
// Given
|
|
const selected = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
|
|
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
|
|
const input = screen.getByLabelText<HTMLInputElement>("오디오");
|
|
fireEvent.change(input, { target: { files: [selected] } });
|
|
let nativeValue = "C:\\fakepath\\voice.mp3";
|
|
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
|
|
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={selected} />);
|
|
expect(input.files?.[0]).toBe(selected);
|
|
expect(input.value).toBe("C:\\fakepath\\voice.mp3");
|
|
|
|
// When
|
|
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
|
|
|
|
// Then
|
|
expect(input.value).toBe("");
|
|
});
|
|
|
|
test("FileField clears a rejected native selection when only the error changes", () => {
|
|
// Given
|
|
const rejected = new File(["audio"], "voice.wav", { type: "audio/wav" });
|
|
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
|
|
const input = screen.getByLabelText<HTMLInputElement>("오디오");
|
|
fireEvent.change(input, { target: { files: [rejected] } });
|
|
let nativeValue = "C:\\fakepath\\voice.wav";
|
|
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
|
|
|
|
// When
|
|
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" error="지원하지 않는 파일입니다." label="오디오" onChange={vi.fn()} value={null} />);
|
|
|
|
// Then
|
|
expect(input.value).toBe("");
|
|
});
|
|
|
|
test("FileField clears the native selection when the controlled value is a different File", () => {
|
|
// Given
|
|
const selected = new File(["source"], "profile.png", { type: "image/png" });
|
|
const cropped = new File(["cropped"], "profile.png", { type: "image/png" });
|
|
const { rerender } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={null} />);
|
|
const input = screen.getByLabelText<HTMLInputElement>("대표 이미지");
|
|
fireEvent.change(input, { target: { files: [selected] } });
|
|
let nativeValue = "C:\\fakepath\\profile.png";
|
|
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
|
|
|
|
// When
|
|
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={cropped} />);
|
|
|
|
// Then
|
|
expect(input.value).toBe("");
|
|
});
|
|
|
|
test("FileField gives same visible file buttons field-specific accessible names", () => {
|
|
render(
|
|
<>
|
|
<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오 파일" onChange={vi.fn()} value={null} />
|
|
<FileField accept="image/png" acceptDescription="PNG" label="커버 이미지" onChange={vi.fn()} value={null} />
|
|
</>,
|
|
);
|
|
|
|
expect(screen.getAllByText("파일 선택")).toHaveLength(2);
|
|
expect(screen.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeInTheDocument();
|
|
});
|
|
|
|
test("FileField visible container exposes the focus-within ring contract", () => {
|
|
// Given
|
|
const { container } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={null} />);
|
|
const input = screen.getByLabelText("대표 이미지");
|
|
|
|
// When
|
|
input.focus();
|
|
|
|
// Then
|
|
expect(input).toHaveFocus();
|
|
expect(container.firstElementChild).toHaveClass("focus-within:ring-2", "focus-within:ring-ring", "focus-within:ring-offset-2");
|
|
});
|
|
|
|
test("FileField reserves a bounded preview frame for the current image value", async () => {
|
|
// Given
|
|
const firstImage = new File(["first"], "first.png", { type: "image/png" });
|
|
const secondImage = new File(["second"], "second.png", { type: "image/png" });
|
|
createObjectUrl.mockImplementation((blob) => blob === firstImage ? "blob:first" : "blob:second");
|
|
const { rerender } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={firstImage} />);
|
|
|
|
// When
|
|
const frame = screen.getByRole("figure", { name: "대표 이미지 업로드 미리보기 영역" });
|
|
|
|
// Then
|
|
expect(frame).toHaveClass("aspect-video", "max-h-64", "w-full", "max-w-md");
|
|
expect(await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:first");
|
|
|
|
// When
|
|
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={secondImage} />);
|
|
|
|
// Then
|
|
expect(screen.getByRole("figure", { name: "대표 이미지 업로드 미리보기 영역" })).toBeInTheDocument();
|
|
await waitFor(() => expect(screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:second"));
|
|
});
|
|
|
|
test("FileField revokes every object URL created under StrictMode", async () => {
|
|
// Given
|
|
const image = new File(["image"], "profile.png", { type: "image/png" });
|
|
|
|
// When
|
|
const { unmount } = render(
|
|
<StrictMode>
|
|
<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={image} />
|
|
</StrictMode>,
|
|
);
|
|
await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
|
|
unmount();
|
|
|
|
// Then
|
|
const createdUrls = createObjectUrl.mock.results.map(({ value }) => value);
|
|
const revokedUrls = revokeObjectUrl.mock.calls.map(([url]) => url);
|
|
expect(new Set(revokedUrls)).toEqual(new Set(createdUrls));
|
|
});
|
|
|
|
test("FileField previews an image and revokes each owned object URL when its controlled value changes", async () => {
|
|
// Given
|
|
const firstImage = new File(["first"], "first.png", { type: "image/png" });
|
|
const secondImage = new File(["second"], "second.png", { type: "image/png" });
|
|
const onChange = vi.fn();
|
|
const { rerender, unmount } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={firstImage} />);
|
|
|
|
// When
|
|
const preview = await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
|
|
const firstUrl = preview.getAttribute("src");
|
|
|
|
// Then
|
|
expect(firstUrl).toMatch(/^blob:preview-/);
|
|
expect(createObjectUrl).toHaveBeenCalledWith(firstImage);
|
|
expect(preview).toHaveClass("h-full", "w-full", "object-contain");
|
|
|
|
// When
|
|
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={secondImage} />);
|
|
|
|
// Then
|
|
await waitFor(() => expect(screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" }).getAttribute("src")).not.toBe(firstUrl));
|
|
const secondUrl = screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" }).getAttribute("src");
|
|
expect(createObjectUrl).toHaveBeenCalledWith(secondImage);
|
|
expect(revokeObjectUrl).toHaveBeenCalledWith(firstUrl);
|
|
|
|
// When
|
|
fireEvent.click(screen.getByRole("button", { name: "대표 이미지 선택 취소" }));
|
|
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={null} />);
|
|
|
|
// Then
|
|
expect(onChange).toHaveBeenCalledWith(null);
|
|
expect(screen.queryByRole("img", { name: "대표 이미지 업로드 미리보기" })).not.toBeInTheDocument();
|
|
await waitFor(() => expect(revokeObjectUrl).toHaveBeenCalledWith(secondUrl));
|
|
|
|
// When
|
|
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={firstImage} />);
|
|
const finalPreview = await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
|
|
const finalUrl = finalPreview.getAttribute("src");
|
|
unmount();
|
|
|
|
// Then
|
|
expect(revokeObjectUrl).toHaveBeenCalledWith(finalUrl);
|
|
});
|
|
|
|
test("FileField keeps non-image files filename-only without creating object URLs", () => {
|
|
// Given
|
|
const audio = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
|
|
|
|
// When
|
|
render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={audio} />);
|
|
|
|
// Then
|
|
expect(screen.getByText("voice.mp3")).toBeInTheDocument();
|
|
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
|
expect(createObjectUrl).not.toHaveBeenCalled();
|
|
expect(revokeObjectUrl).not.toHaveBeenCalled();
|
|
});
|