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( , ); 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(); fireEvent.change(screen.getByLabelText("오디오"), { target: { files: [selected] } }); expect(onChange).toHaveBeenCalledWith(selected); rerender(); 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(); const input = screen.getByLabelText("오디오"); 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(); expect(input.files?.[0]).toBe(selected); expect(input.value).toBe("C:\\fakepath\\voice.mp3"); // When rerender(); // 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(); const input = screen.getByLabelText("오디오"); 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(); // 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(); const input = screen.getByLabelText("대표 이미지"); 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(); // Then expect(input.value).toBe(""); }); test("FileField gives same visible file buttons field-specific accessible names", () => { render( <> , ); 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(); 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(); // 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(); // 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( , ); 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(); // 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(); // 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(); // Then expect(onChange).toHaveBeenCalledWith(null); expect(screen.queryByRole("img", { name: "대표 이미지 업로드 미리보기" })).not.toBeInTheDocument(); await waitFor(() => expect(revokeObjectUrl).toHaveBeenCalledWith(secondUrl)); // When rerender(); 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(); // Then expect(screen.getByText("voice.mp3")).toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument(); expect(createObjectUrl).not.toHaveBeenCalled(); expect(revokeObjectUrl).not.toHaveBeenCalled(); });