feat: 파일프로세서 개선 - 안정적인 Excel 파일 처리
- 이전 잘 작동하던 코드 로직을 현재 프로세서에 적용 - LuckyExcel 우선 시도 + SheetJS Fallback 패턴 도입 - CSV, XLS, XLSX 모든 형식에 대한 안정적 처리 - 한글 시트명 정규화 및 워크북 구조 검증 강화 - 복잡한 SheetJS 옵션 단순화로 안정성 향상 - 에러 발생 시 빈 시트 생성으로 앱 중단 방지 - 테스트 환경 및 Cursor 규칙 업데이트 Technical improvements: - convertSheetJSToLuckyExcel 함수로 안정적 데이터 변환 - UTF-8 codepage 설정으로 한글 지원 강화 - validateWorkbook 함수로 방어적 프로그래밍 적용
This commit is contained in:
421
src/components/sheet/FileUpload.tsx
Normal file
421
src/components/sheet/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
431
src/components/sheet/__tests__/FileUpload.test.tsx
Normal file
431
src/components/sheet/__tests__/FileUpload.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user