feat: 파일프로세서 개선 - 안정적인 Excel 파일 처리

- 이전 잘 작동하던 코드 로직을 현재 프로세서에 적용
- LuckyExcel 우선 시도 + SheetJS Fallback 패턴 도입
- CSV, XLS, XLSX 모든 형식에 대한 안정적 처리
- 한글 시트명 정규화 및 워크북 구조 검증 강화
- 복잡한 SheetJS 옵션 단순화로 안정성 향상
- 에러 발생 시 빈 시트 생성으로 앱 중단 방지
- 테스트 환경 및 Cursor 규칙 업데이트

Technical improvements:
- convertSheetJSToLuckyExcel 함수로 안정적 데이터 변환
- UTF-8 codepage 설정으로 한글 지원 강화
- validateWorkbook 함수로 방어적 프로그래밍 적용
This commit is contained in:
sheetEasy AI Team
2025-06-20 14:32:33 +09:00
parent f288103e55
commit 3a8c6af7ea
16 changed files with 5249 additions and 133 deletions

View File

@@ -0,0 +1,421 @@
import React, { useCallback, useState, useRef } from "react";
import { Card, CardContent } from "../ui/card";
import { Button } from "../ui/button";
import { FileErrorModal } from "../ui/modal";
import { cn } from "../../lib/utils";
import { useAppStore } from "../../stores/useAppStore";
import {
processExcelFile,
getFileErrors,
filterValidFiles,
} from "../../utils/fileProcessor";
interface FileUploadProps {
className?: string;
}
/**
* 파일 업로드 컴포넌트
* - Drag & Drop 기능 지원
* - .xls, .xlsx 파일 타입 제한
* - 접근성 지원 (ARIA 라벨, 키보드 탐색)
* - 반응형 레이아웃
* - 실제 파일 처리 로직 연결
*/
export function FileUpload({ className }: FileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [showErrorModal, setShowErrorModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 스토어에서 상태 가져오기
const {
isLoading,
error,
fileUploadErrors,
currentFile,
setLoading,
setError,
uploadFile,
clearFileUploadErrors,
} = useAppStore();
/**
* 파일 처리 로직
*/
const handleFileProcessing = useCallback(
async (file: File) => {
setLoading(true, "파일을 처리하는 중...");
setError(null);
clearFileUploadErrors();
try {
const result = await processExcelFile(file);
uploadFile(result);
if (result.success) {
console.log("파일 업로드 성공:", result.fileName);
}
} catch (error) {
console.error("파일 처리 중 예상치 못한 오류:", error);
setError("파일 처리 중 예상치 못한 오류가 발생했습니다.");
} finally {
setLoading(false);
}
},
[setLoading, setError, uploadFile, clearFileUploadErrors],
);
/**
* 파일 선택 처리
*/
const handleFileSelection = useCallback(
async (files: FileList) => {
if (files.length === 0) return;
// 유효하지 않은 파일들의 에러 수집
const fileErrors = getFileErrors(files) || [];
const validFiles = filterValidFiles(files) || [];
// 에러가 있는 파일들을 스토어에 저장
fileErrors.forEach(({ file, error }) => {
useAppStore.getState().addFileUploadError(file.name, error);
});
// 에러가 있으면 모달 표시
if (fileErrors.length > 0) {
setShowErrorModal(true);
}
if (validFiles.length === 0) {
setError("업로드 가능한 파일이 없습니다.");
return;
}
if (validFiles.length > 1) {
setError(
"한 번에 하나의 파일만 업로드할 수 있습니다. 첫 번째 파일을 사용합니다.",
);
}
// 첫 번째 유효한 파일 처리
await handleFileProcessing(validFiles[0]);
},
[handleFileProcessing, setError],
);
/**
* 드래그 앤 드롭 이벤트 핸들러
*/
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragOver(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (isLoading) return;
const files = e.dataTransfer.files;
if (files && files.length > 0) {
await handleFileSelection(files);
}
},
[handleFileSelection, isLoading],
);
/**
* 파일 선택 버튼 클릭 핸들러
*/
const handleFilePickerClick = useCallback(() => {
if (isLoading || !fileInputRef.current) return;
fileInputRef.current.click();
}, [isLoading]);
/**
* 파일 입력 변경 핸들러
*/
const handleFileInputChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
await handleFileSelection(files);
}
// 입력 초기화 (같은 파일 재선택 가능하도록)
e.target.value = "";
},
[handleFileSelection],
);
/**
* 키보드 이벤트 핸들러 (접근성)
*/
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleFilePickerClick();
}
},
[handleFilePickerClick],
);
/**
* 에러 모달 닫기 핸들러
*/
const handleCloseErrorModal = useCallback(() => {
setShowErrorModal(false);
clearFileUploadErrors();
}, [clearFileUploadErrors]);
// 파일이 이미 업로드된 경우 성공 상태 표시
if (currentFile && !error) {
return (
<div
className={cn(
"flex items-center justify-center min-h-[60vh]",
className,
)}
>
<Card className="w-full max-w-2xl">
<CardContent className="p-8 md:p-12">
<div className="text-center">
<div className="mx-auto h-20 w-20 md:h-24 md:w-24 rounded-full bg-green-100 flex items-center justify-center mb-4">
<svg
className="h-10 w-10 md:h-12 md:w-12 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="text-xl md:text-2xl font-semibold mb-2 text-green-800">
</h2>
<p className="text-sm md:text-base text-gray-600 mb-4">
<span className="font-medium text-gray-900">
{currentFile.name}
</span>
</p>
<p className="text-xs text-gray-500 mb-6">
: {(currentFile.size / 1024 / 1024).toFixed(2)} MB
</p>
<Button
onClick={handleFilePickerClick}
variant="outline"
disabled={isLoading}
>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div
className={cn("flex items-center justify-center min-h-[60vh]", className)}
>
<Card className="w-full max-w-2xl">
<CardContent className="p-8 md:p-12">
<div className="text-center">
{/* 아이콘 및 제목 */}
<div className="mb-8">
<div
className={cn(
"mx-auto h-20 w-20 md:h-24 md:w-24 rounded-full flex items-center justify-center mb-4",
error ? "bg-red-100" : "bg-blue-50",
)}
>
{isLoading ? (
<svg
className="h-10 w-10 md:h-12 md:w-12 text-blue-600 animate-spin"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : error ? (
<svg
className="h-10 w-10 md:h-12 md:w-12 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.864-.833-2.634 0L3.197 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
) : (
<svg
className="h-10 w-10 md:h-12 md:w-12 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
)}
</div>
<h2
className={cn(
"text-xl md:text-2xl font-semibold mb-2 text-gray-900",
error ? "text-red-800" : "",
)}
>
{isLoading
? "파일 처리 중..."
: error
? "업로드 오류"
: "Excel 파일을 업로드하세요"}
</h2>
<p className="text-sm md:text-base text-gray-600 mb-6">
{isLoading ? (
<span className="text-blue-600"> ...</span>
) : error ? (
<span className="text-red-600">{error}</span>
) : (
<>
<span className="font-medium text-gray-900">
.xlsx, .xls
</span>{" "}
</>
)}
</p>
</div>
{/* 파일 업로드 에러 목록 */}
{fileUploadErrors.length > 0 && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 className="text-sm font-medium text-red-800 mb-2">
:
</h3>
<ul className="text-xs text-red-700 space-y-1">
{fileUploadErrors.map((error, index) => (
<li key={index}>
<span className="font-medium">{error.fileName}</span>:{" "}
{error.error}
</li>
))}
</ul>
</div>
)}
{/* 드래그 앤 드롭 영역 */}
<div
className={cn(
"border-2 border-dashed rounded-lg p-8 md:p-12 transition-all duration-200 cursor-pointer",
"hover:border-blue-400 hover:bg-blue-50",
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
isDragOver
? "border-blue-500 bg-blue-100 scale-105"
: "border-gray-300",
isLoading && "opacity-50 cursor-not-allowed",
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleFilePickerClick}
onKeyDown={handleKeyDown}
tabIndex={isLoading ? -1 : 0}
role="button"
aria-label="파일 업로드 영역"
aria-describedby="upload-instructions"
>
<div className="flex flex-col items-center justify-center space-y-4">
<div className="text-4xl md:text-6xl">
{isDragOver ? "📂" : "📄"}
</div>
<div className="text-center">
<p className="text-base md:text-lg font-medium mb-2 text-gray-900">
{isDragOver
? "파일을 여기에 놓으세요"
: "파일을 드래그하거나 클릭하세요"}
</p>
<p id="upload-instructions" className="text-sm text-gray-600">
50MB까지
</p>
</div>
</div>
</div>
{/* 숨겨진 파일 입력 */}
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
onChange={handleFileInputChange}
className="hidden"
disabled={isLoading}
aria-label="파일 선택"
/>
{/* 지원 형식 안내 */}
<div className="mt-6 text-xs text-gray-500">
<p> 형식: Excel (.xlsx, .xls)</p>
<p> 크기: 50MB</p>
</div>
</div>
</CardContent>
</Card>
{/* 파일 에러 모달 */}
<FileErrorModal
isOpen={showErrorModal}
onClose={handleCloseErrorModal}
errors={fileUploadErrors}
/>
</div>
);
}

View File

@@ -0,0 +1,431 @@
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { vi } from "vitest";
import { FileUpload } from "../FileUpload";
import { useAppStore } from "../../../stores/useAppStore";
import * as fileProcessor from "../../../utils/fileProcessor";
// Mock dependencies
vi.mock("../../../stores/useAppStore");
// Mock DragEvent for testing environment
class MockDragEvent extends Event {
dataTransfer: DataTransfer;
constructor(
type: string,
options: { bubbles?: boolean; dataTransfer?: any } = {},
) {
super(type, { bubbles: options.bubbles });
this.dataTransfer = options.dataTransfer || {
items: options.dataTransfer?.items || [],
files: options.dataTransfer?.files || [],
};
}
}
// @ts-ignore
global.DragEvent = MockDragEvent;
const mockUseAppStore = useAppStore as vi.MockedFunction<typeof useAppStore>;
describe("FileUpload", () => {
const mockSetLoading = vi.fn();
const mockSetError = vi.fn();
const mockUploadFile = vi.fn();
const mockClearFileUploadErrors = vi.fn();
const mockAddFileUploadError = vi.fn();
// Mock fileProcessor functions
const mockProcessExcelFile = vi.fn();
const mockGetFileErrors = vi.fn();
const mockFilterValidFiles = vi.fn();
const defaultStoreState = {
isLoading: false,
error: null,
fileUploadErrors: [],
currentFile: null,
setLoading: mockSetLoading,
setError: mockSetError,
uploadFile: mockUploadFile,
clearFileUploadErrors: mockClearFileUploadErrors,
};
beforeEach(() => {
vi.clearAllMocks();
mockUseAppStore.mockReturnValue(defaultStoreState);
// @ts-ignore
mockUseAppStore.getState = vi.fn().mockReturnValue({
addFileUploadError: mockAddFileUploadError,
});
// Mock fileProcessor functions
vi.spyOn(fileProcessor, "processExcelFile").mockImplementation(
mockProcessExcelFile,
);
vi.spyOn(fileProcessor, "getFileErrors").mockImplementation(
mockGetFileErrors,
);
vi.spyOn(fileProcessor, "filterValidFiles").mockImplementation(
mockFilterValidFiles,
);
// Default mock implementations
mockGetFileErrors.mockReturnValue([]);
mockFilterValidFiles.mockReturnValue([]);
});
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe("초기 렌더링", () => {
it("기본 업로드 UI를 렌더링한다", () => {
render(<FileUpload />);
expect(screen.getByText("Excel 파일을 업로드하세요")).toBeInTheDocument();
expect(screen.getByText(".xlsx, .xls")).toBeInTheDocument();
expect(
screen.getByText("파일을 드래그 앤 드롭하거나 클릭하여 업로드"),
).toBeInTheDocument();
expect(screen.getByLabelText("파일 업로드 영역")).toBeInTheDocument();
});
it("파일 입력 요소가 올바르게 설정된다", () => {
render(<FileUpload />);
const fileInput = screen.getByLabelText("파일 선택");
expect(fileInput).toBeInTheDocument();
expect(fileInput).toHaveAttribute("type", "file");
expect(fileInput).toHaveAttribute("accept", ".xlsx,.xls");
});
});
describe("로딩 상태", () => {
it("로딩 중일 때 로딩 UI를 표시한다", () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
isLoading: true,
});
render(<FileUpload />);
expect(screen.getByText("파일 처리 중...")).toBeInTheDocument();
expect(screen.getByText("잠시만 기다려주세요...")).toBeInTheDocument();
});
it("로딩 중일 때 파일 업로드 영역이 비활성화된다", () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
isLoading: true,
});
render(<FileUpload />);
const uploadArea = screen.getByLabelText("파일 업로드 영역");
expect(uploadArea).toHaveAttribute("tabindex", "-1");
});
});
describe("에러 상태", () => {
it("에러가 있을 때 에러 UI를 표시한다", () => {
const errorMessage = "파일 업로드에 실패했습니다.";
mockUseAppStore.mockReturnValue({
...defaultStoreState,
error: errorMessage,
});
render(<FileUpload />);
expect(screen.getByText("업로드 오류")).toBeInTheDocument();
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
it("파일 업로드 에러 목록을 표시한다", () => {
const fileUploadErrors = [
{ fileName: "test1.txt", error: "지원되지 않는 파일 형식입니다." },
{ fileName: "test2.pdf", error: "파일 크기가 너무 큽니다." },
];
mockUseAppStore.mockReturnValue({
...defaultStoreState,
fileUploadErrors,
});
render(<FileUpload />);
expect(screen.getByText("파일 업로드 오류:")).toBeInTheDocument();
expect(screen.getByText("test1.txt")).toBeInTheDocument();
expect(
screen.getByText(/지원되지 않는 파일 형식입니다/),
).toBeInTheDocument();
expect(screen.getByText("test2.pdf")).toBeInTheDocument();
expect(screen.getByText(/파일 크기가 너무 큽니다/)).toBeInTheDocument();
});
});
describe("성공 상태", () => {
it("파일 업로드 성공 시 성공 UI를 표시한다", () => {
const currentFile = {
name: "test.xlsx",
size: 1024 * 1024, // 1MB
uploadedAt: new Date(),
};
mockUseAppStore.mockReturnValue({
...defaultStoreState,
currentFile,
});
render(<FileUpload />);
expect(screen.getByText("파일 업로드 완료")).toBeInTheDocument();
expect(screen.getByText("test.xlsx")).toBeInTheDocument();
expect(screen.getByText("파일 크기: 1.00 MB")).toBeInTheDocument();
expect(screen.getByText("다른 파일 업로드")).toBeInTheDocument();
});
});
describe("파일 선택", () => {
it("파일 선택 버튼 클릭 시 파일 입력을 트리거한다", async () => {
const user = userEvent.setup();
render(<FileUpload />);
const uploadArea = screen.getByLabelText("파일 업로드 영역");
const fileInput = screen.getByLabelText("파일 선택");
const clickSpy = vi.spyOn(fileInput, "click");
await user.click(uploadArea);
expect(clickSpy).toHaveBeenCalled();
});
it("키보드 이벤트(Enter)로 파일 선택을 트리거한다", async () => {
const user = userEvent.setup();
render(<FileUpload />);
const uploadArea = screen.getByLabelText("파일 업로드 영역");
const fileInput = screen.getByLabelText("파일 선택");
const clickSpy = vi.spyOn(fileInput, "click");
uploadArea.focus();
await user.keyboard("{Enter}");
expect(clickSpy).toHaveBeenCalled();
});
it("키보드 이벤트(Space)로 파일 선택을 트리거한다", async () => {
const user = userEvent.setup();
render(<FileUpload />);
const uploadArea = screen.getByLabelText("파일 업로드 영역");
const fileInput = screen.getByLabelText("파일 선택");
const clickSpy = vi.spyOn(fileInput, "click");
uploadArea.focus();
await user.keyboard(" ");
expect(clickSpy).toHaveBeenCalled();
});
});
describe("파일 처리", () => {
it("유효한 파일 업로드 시 파일 처리 함수를 호출한다", async () => {
const mockFile = new File(["test content"], "test.xlsx", {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const successResult = {
success: true,
data: [{ id: "sheet1", name: "Sheet1", data: [] }],
fileName: "test.xlsx",
fileSize: 1024,
};
// Mock valid file
mockFilterValidFiles.mockReturnValue([mockFile]);
mockGetFileErrors.mockReturnValue([]);
mockProcessExcelFile.mockResolvedValue(successResult);
const user = userEvent.setup();
render(<FileUpload />);
const fileInput = screen.getByLabelText("파일 선택");
await user.upload(fileInput, mockFile);
await waitFor(() => {
expect(mockProcessExcelFile).toHaveBeenCalledWith(mockFile);
expect(mockSetLoading).toHaveBeenCalledWith(
true,
"파일을 처리하는 중...",
);
expect(mockUploadFile).toHaveBeenCalledWith(successResult);
});
});
it("파일 처리 실패 시 에러 처리를 한다", async () => {
const mockFile = new File(["test content"], "test.xlsx", {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const errorResult = {
success: false,
error: "파일 형식이 올바르지 않습니다.",
fileName: "test.xlsx",
fileSize: 1024,
};
// Mock valid file but processing fails
mockFilterValidFiles.mockReturnValue([mockFile]);
mockGetFileErrors.mockReturnValue([]);
mockProcessExcelFile.mockResolvedValue(errorResult);
const user = userEvent.setup();
render(<FileUpload />);
const fileInput = screen.getByLabelText("파일 선택");
await user.upload(fileInput, mockFile);
await waitFor(() => {
expect(mockUploadFile).toHaveBeenCalledWith(errorResult);
});
});
it("파일 처리 중 예외 발생 시 에러 처리를 한다", async () => {
const mockFile = new File(["test content"], "test.xlsx", {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
// Mock valid file but processing throws
mockFilterValidFiles.mockReturnValue([mockFile]);
mockGetFileErrors.mockReturnValue([]);
mockProcessExcelFile.mockRejectedValue(new Error("Unexpected error"));
const user = userEvent.setup();
render(<FileUpload />);
const fileInput = screen.getByLabelText("파일 선택");
await user.upload(fileInput, mockFile);
await waitFor(() => {
expect(mockSetError).toHaveBeenCalledWith(
"파일 처리 중 예상치 못한 오류가 발생했습니다.",
);
});
});
});
describe("드래그 앤 드롭", () => {
it("드래그 엔터 시 드래그 오버 상태를 활성화한다", async () => {
render(<FileUpload />);
const uploadArea = screen.getByLabelText("파일 업로드 영역");
const dragEnterEvent = new DragEvent("dragenter", {
bubbles: true,
dataTransfer: {
items: [{ kind: "file" }],
},
});
fireEvent(uploadArea, dragEnterEvent);
// 드래그 오버 상태 확인 (드래그 오버 시 특별한 스타일이 적용됨)
expect(uploadArea).toHaveClass(
"border-blue-500",
"bg-blue-100",
"scale-105",
);
});
it("드래그 리브 시 드래그 오버 상태를 비활성화한다", async () => {
render(<FileUpload />);
const uploadArea = screen.getByLabelText("파일 업로드 영역");
// 먼저 드래그 엔터
const dragEnterEvent = new DragEvent("dragenter", {
bubbles: true,
dataTransfer: {
items: [{ kind: "file" }],
},
});
fireEvent(uploadArea, dragEnterEvent);
// 드래그 리브
const dragLeaveEvent = new DragEvent("dragleave", {
bubbles: true,
});
fireEvent(uploadArea, dragLeaveEvent);
expect(uploadArea).toHaveClass("border-gray-300");
});
it("파일 드롭 시 파일 처리를 실행한다", async () => {
const mockFile = new File(["test content"], "test.xlsx", {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
// Mock valid file
mockFilterValidFiles.mockReturnValue([mockFile]);
mockGetFileErrors.mockReturnValue([]);
render(<FileUpload />);
const uploadArea = screen.getByLabelText("파일 업로드 영역");
const dropEvent = new DragEvent("drop", {
bubbles: true,
dataTransfer: {
files: [mockFile],
},
});
fireEvent(uploadArea, dropEvent);
await waitFor(() => {
expect(mockProcessExcelFile).toHaveBeenCalledWith(mockFile);
});
});
});
describe("접근성", () => {
it("ARIA 라벨과 설명이 올바르게 설정된다", () => {
render(<FileUpload />);
const uploadArea = screen.getByLabelText("파일 업로드 영역");
expect(uploadArea).toHaveAttribute(
"aria-describedby",
"upload-instructions",
);
expect(uploadArea).toHaveAttribute("role", "button");
const instructions = screen.getByText("최대 50MB까지 업로드 가능");
expect(instructions).toHaveAttribute("id", "upload-instructions");
});
it("로딩 중일 때 접근성 속성이 올바르게 설정된다", () => {
mockUseAppStore.mockReturnValue({
...defaultStoreState,
isLoading: true,
});
render(<FileUpload />);
const uploadArea = screen.getByLabelText("파일 업로드 영역");
expect(uploadArea).toHaveAttribute("tabindex", "-1");
});
});
});