엑셀 파일 부르기 완료, 입력창 UI 설정 완료
This commit is contained in:
@@ -14,6 +14,7 @@ import { UniverSheetsNumfmtUIPlugin } from "@univerjs/sheets-numfmt-ui";
|
||||
import { UniverUIPlugin } from "@univerjs/ui";
|
||||
import { cn } from "../../lib/utils";
|
||||
import LuckyExcel from "@zwight/luckyexcel";
|
||||
import PromptInput from "./PromptInput";
|
||||
|
||||
// 언어팩 import
|
||||
import DesignEnUS from "@univerjs/design/locale/en-US";
|
||||
@@ -283,6 +284,7 @@ const TestSheetViewer: React.FC = () => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [currentFile, setCurrentFile] = useState<File | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
||||
// Univer 초기화 함수
|
||||
const initializeUniver = useCallback(async (workbookData?: any) => {
|
||||
@@ -561,39 +563,27 @@ const TestSheetViewer: React.FC = () => {
|
||||
<div className="w-full h-screen flex flex-col relative">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-white border-b p-4 flex-shrink-0 relative z-10">
|
||||
<h1 className="text-xl font-bold">
|
||||
🧪 Univer CE + 파일 업로드 (Window 기반 관리)
|
||||
</h1>
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
isInitialized
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{isInitialized ? "✅ 초기화 완료" : "⏳ 초기화 중..."}
|
||||
</span>
|
||||
|
||||
<div
|
||||
className="mt-2 flex items-center gap-4"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{currentFile && (
|
||||
<>
|
||||
<span className="text-sm text-blue-600 font-medium">
|
||||
📄 {currentFile.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNewUpload}
|
||||
className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
새 파일 업로드
|
||||
</button>
|
||||
</>
|
||||
<span className="text-sm text-blue-600 font-medium">
|
||||
📄 {currentFile.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 디버그 정보 */}
|
||||
<div className="text-xs text-gray-500">
|
||||
전역 인스턴스: {UniverseManager.getInstance() ? "✅" : "❌"} |
|
||||
초기화 중: {UniverseManager.isInitializing() ? "⏳" : "❌"}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNewUpload}
|
||||
className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "0%",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
새 파일 업로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -602,12 +592,24 @@ const TestSheetViewer: React.FC = () => {
|
||||
ref={containerRef}
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
minHeight: "500px",
|
||||
minHeight: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
flexGrow: 0.85,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Univer와 입력창 사이 여백 */}
|
||||
<div style={{ height: "1rem" }} />
|
||||
|
||||
{/* 프롬프트 입력창 - Univer 하단에 이어서 */}
|
||||
<PromptInput
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onExecute={() => {}}
|
||||
disabled={true}
|
||||
/>
|
||||
|
||||
{/* 파일 업로드 오버레이 - 레이어 분리 */}
|
||||
{showUploadOverlay && (
|
||||
<>
|
||||
@@ -1,421 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
59
src/components/sheet/PromptInput.tsx
Normal file
59
src/components/sheet/PromptInput.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
|
||||
interface PromptInputProps {
|
||||
value: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onExecute?: () => void;
|
||||
disabled?: boolean;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 에디트 화면 하단 고정 프롬프트 입력창 컴포넌트
|
||||
* - 이미지 참고: 입력창, Execute 버튼, 안내문구, 글자수 카운트, 하단 고정
|
||||
*/
|
||||
const PromptInput: React.FC<PromptInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onExecute,
|
||||
disabled = true,
|
||||
maxLength = 500,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-[60%] mx-auto bg-white z-10 flex flex-col items-center py-4 px-2">
|
||||
<div className="w-full max-w-3xl flex items-end gap-2">
|
||||
<textarea
|
||||
className="flex-1 resize-none rounded-lg border border-gray-300 bg-gray-50 px-4 py-3 text-base text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-400 disabled:bg-gray-100 disabled:cursor-not-allowed min-h-[48px] max-h-32 shadow-sm"
|
||||
placeholder="질문을 입력하세요.
|
||||
예시) A1부터 A10까지 합계를 구해서 B1에 입력하는 수식을 입력해줘"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={false}
|
||||
maxLength={maxLength}
|
||||
rows={5}
|
||||
/>
|
||||
<div style={{ width: "1rem" }} />
|
||||
<button
|
||||
className="ml-2 px-6 py-2 rounded-lg text-white font-semibold text-base shadow transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #a18fff 0%, #6f6fff 100%)",
|
||||
}}
|
||||
onClick={onExecute}
|
||||
disabled={disabled || !value.trim()}
|
||||
>
|
||||
전송하기
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full max-w-3xl flex justify-between items-center mt-1 px-1">
|
||||
<span className="text-xs text-gray-500">
|
||||
Press Enter to send, Shift+Enter for new line
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{value.length}/{maxLength}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptInput;
|
||||
@@ -1,491 +0,0 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useAppStore } from "../../stores/useAppStore";
|
||||
|
||||
interface SheetViewerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Luckysheet 시트 뷰어 컴포넌트
|
||||
* - 메모리 정보 기반: LuckyExcel 변환 결과를 직접 사용
|
||||
* - 커스텀 검증이나 데이터 구조 변경 금지
|
||||
* - luckysheet.create({ data: exportJson.sheets })로 직접 사용
|
||||
*/
|
||||
export function SheetViewer({ className }: SheetViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const luckysheetRef = useRef<any>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isContainerReady, setIsContainerReady] = useState(false);
|
||||
const [librariesLoaded, setLibrariesLoaded] = useState(false);
|
||||
|
||||
// 스토어에서 현재 파일 정보만 가져오기 (시트 데이터는 LuckyExcel로 직접 변환)
|
||||
const { currentFile, setSelectedRange } = useAppStore();
|
||||
|
||||
/**
|
||||
* CDN 배포판 라이브러리 로딩
|
||||
*/
|
||||
const loadLuckysheetLibrary = useCallback((): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 이미 로드된 경우
|
||||
if (
|
||||
window.luckysheet &&
|
||||
window.LuckyExcel &&
|
||||
window.$ &&
|
||||
librariesLoaded
|
||||
) {
|
||||
console.log("📦 모든 라이브러리가 이미 로드됨");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const loadResource = (
|
||||
type: "css" | "js",
|
||||
src: string,
|
||||
id: string,
|
||||
): Promise<void> => {
|
||||
return new Promise((resourceResolve, resourceReject) => {
|
||||
// 이미 로드된 리소스 체크
|
||||
if (document.querySelector(`[data-luckysheet-id="${id}"]`)) {
|
||||
resourceResolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "css") {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = src;
|
||||
link.setAttribute("data-luckysheet-id", id);
|
||||
link.onload = () => resourceResolve();
|
||||
link.onerror = () =>
|
||||
resourceReject(new Error(`${id} CSS 로드 실패`));
|
||||
document.head.appendChild(link);
|
||||
} else {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.setAttribute("data-luckysheet-id", id);
|
||||
script.onload = () => resourceResolve();
|
||||
script.onerror = () =>
|
||||
resourceReject(new Error(`${id} JS 로드 실패`));
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadSequence = async () => {
|
||||
try {
|
||||
// 1. jQuery (Luckysheet 의존성)
|
||||
if (!window.$) {
|
||||
await loadResource(
|
||||
"js",
|
||||
"https://code.jquery.com/jquery-3.6.0.min.js",
|
||||
"jquery",
|
||||
);
|
||||
}
|
||||
|
||||
// 2. CSS 로드 (공식 문서 순서 준수)
|
||||
await loadResource(
|
||||
"css",
|
||||
"https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/css/pluginsCss.css",
|
||||
"plugins-css",
|
||||
);
|
||||
await loadResource(
|
||||
"css",
|
||||
"https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/plugins.css",
|
||||
"plugins-main-css",
|
||||
);
|
||||
await loadResource(
|
||||
"css",
|
||||
"https://cdn.jsdelivr.net/npm/luckysheet/dist/css/luckysheet.css",
|
||||
"luckysheet-css",
|
||||
);
|
||||
await loadResource(
|
||||
"css",
|
||||
"https://cdn.jsdelivr.net/npm/luckysheet/dist/assets/iconfont/iconfont.css",
|
||||
"iconfont-css",
|
||||
);
|
||||
|
||||
// 3. Plugin JS 먼저 로드 (functionlist 초기화)
|
||||
await loadResource(
|
||||
"js",
|
||||
"https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/js/plugin.js",
|
||||
"plugin-js",
|
||||
);
|
||||
|
||||
// 4. Luckysheet 메인
|
||||
if (!window.luckysheet) {
|
||||
await loadResource(
|
||||
"js",
|
||||
"https://cdn.jsdelivr.net/npm/luckysheet/dist/luckysheet.umd.js",
|
||||
"luckysheet",
|
||||
);
|
||||
}
|
||||
|
||||
// 5. LuckyExcel (Excel 파일 처리용)
|
||||
if (!window.LuckyExcel) {
|
||||
await loadResource(
|
||||
"js",
|
||||
"https://cdn.jsdelivr.net/npm/luckyexcel/dist/luckyexcel.umd.js",
|
||||
"luckyexcel",
|
||||
);
|
||||
}
|
||||
|
||||
// 라이브러리 검증
|
||||
const validationResults = {
|
||||
jquery: !!window.$,
|
||||
luckyExcel: !!window.LuckyExcel,
|
||||
luckysheet: !!window.luckysheet,
|
||||
luckysheetCreate: !!(
|
||||
window.luckysheet &&
|
||||
typeof window.luckysheet.create === "function"
|
||||
),
|
||||
luckysheetDestroy: !!(
|
||||
window.luckysheet &&
|
||||
typeof window.luckysheet.destroy === "function"
|
||||
),
|
||||
};
|
||||
|
||||
if (
|
||||
!validationResults.luckysheet ||
|
||||
!validationResults.luckysheetCreate
|
||||
) {
|
||||
throw new Error(
|
||||
"Luckysheet 객체가 올바르게 초기화되지 않았습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
setLibrariesLoaded(true);
|
||||
console.log("✅ 라이브러리 로드 완료");
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error("❌ 라이브러리 로딩 실패:", error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
loadSequence();
|
||||
});
|
||||
}, [librariesLoaded]);
|
||||
|
||||
/**
|
||||
* 메모리 정보 기반: LuckyExcel 변환 결과를 직접 사용하는 방식
|
||||
* - LuckyExcel.transformExcelToLucky()에서 반환된 exportJson.sheets를 그대로 사용
|
||||
* - 커스텀 검증이나 데이터 구조 변경 금지
|
||||
*/
|
||||
const convertXLSXWithLuckyExcel = useCallback(
|
||||
async (xlsxBuffer: ArrayBuffer, fileName: string) => {
|
||||
if (!containerRef.current) {
|
||||
console.warn("⚠️ 컨테이너가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsConverting(true);
|
||||
setError(null);
|
||||
|
||||
console.log("🍀 메모리 정보 기반: LuckyExcel 직접 변환 시작...");
|
||||
|
||||
// 라이브러리 로드 확인
|
||||
await loadLuckysheetLibrary();
|
||||
|
||||
// 기존 인스턴스 정리
|
||||
try {
|
||||
if (
|
||||
window.luckysheet &&
|
||||
typeof window.luckysheet.destroy === "function"
|
||||
) {
|
||||
window.luckysheet.destroy();
|
||||
console.log("✅ 기존 인스턴스 destroy 완료");
|
||||
}
|
||||
} catch (destroyError) {
|
||||
console.warn("⚠️ destroy 중 오류 (무시됨):", destroyError);
|
||||
}
|
||||
|
||||
// 컨테이너 초기화
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = "";
|
||||
console.log("✅ 컨테이너 초기화 완료");
|
||||
}
|
||||
|
||||
luckysheetRef.current = null;
|
||||
|
||||
console.log("🍀 LuckyExcel.transformExcelToLucky 호출...");
|
||||
|
||||
// ArrayBuffer를 File 객체로 변환 (LuckyExcel은 File 객체 필요)
|
||||
const file = new File([xlsxBuffer], fileName, {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
|
||||
// LuckyExcel의 직접 변환 사용 (Promise 방식)
|
||||
const luckyExcelResult = await new Promise<any>((resolve, reject) => {
|
||||
try {
|
||||
// 🚨 수정: 첫 번째 매개변수는 File 객체여야 함
|
||||
(window.LuckyExcel as any).transformExcelToLucky(
|
||||
file, // ArrayBuffer 대신 File 객체 사용
|
||||
// 성공 콜백
|
||||
(exportJson: any, luckysheetfile: any) => {
|
||||
console.log("🍀 LuckyExcel 변환 성공!");
|
||||
console.log("🍀 exportJson:", exportJson);
|
||||
console.log("🍀 luckysheetfile:", luckysheetfile);
|
||||
resolve(exportJson);
|
||||
},
|
||||
// 에러 콜백
|
||||
(error: any) => {
|
||||
console.error("❌ LuckyExcel 변환 실패:", error);
|
||||
reject(new Error(`LuckyExcel 변환 실패: ${error}`));
|
||||
},
|
||||
);
|
||||
} catch (callError) {
|
||||
console.error("❌ LuckyExcel 호출 중 오류:", callError);
|
||||
reject(callError);
|
||||
}
|
||||
});
|
||||
|
||||
// 결과 검증
|
||||
if (
|
||||
!luckyExcelResult ||
|
||||
!luckyExcelResult.sheets ||
|
||||
!Array.isArray(luckyExcelResult.sheets)
|
||||
) {
|
||||
throw new Error("LuckyExcel 변환 결과가 유효하지 않습니다.");
|
||||
}
|
||||
|
||||
console.log("🎉 LuckyExcel 변환 완료, Luckysheet 생성 중...");
|
||||
|
||||
// 메모리 정보 기반: exportJson.sheets를 그대로 사용
|
||||
// luckysheet.create({ data: exportJson.sheets })
|
||||
window.luckysheet.create({
|
||||
container: containerRef.current?.id || "luckysheet-container",
|
||||
showinfobar: true,
|
||||
showtoolbar: true,
|
||||
showsheetbar: true,
|
||||
showstatisticBar: true,
|
||||
allowCopy: true,
|
||||
allowEdit: true,
|
||||
// 🚨 핵심: LuckyExcel의 원본 변환 결과를 직접 사용
|
||||
data: luckyExcelResult.sheets, // 가공하지 않고 그대로 전달
|
||||
title: luckyExcelResult.info?.name || fileName,
|
||||
// 🚨 수정: userInfo 경로 수정
|
||||
userInfo: luckyExcelResult.info?.creator || false,
|
||||
});
|
||||
|
||||
console.log("🎉 Luckysheet 생성 완료! (원본 데이터 직접 사용)");
|
||||
setIsInitialized(true);
|
||||
setIsConverting(false);
|
||||
setError(null);
|
||||
luckysheetRef.current = window.luckysheet;
|
||||
} catch (conversionError) {
|
||||
console.error("❌ 변환 프로세스 실패:", conversionError);
|
||||
setError(
|
||||
`변환 프로세스에 실패했습니다: ${
|
||||
conversionError instanceof Error
|
||||
? conversionError.message
|
||||
: String(conversionError)
|
||||
}`,
|
||||
);
|
||||
setIsConverting(false);
|
||||
setIsInitialized(false);
|
||||
}
|
||||
},
|
||||
[loadLuckysheetLibrary, setSelectedRange],
|
||||
);
|
||||
|
||||
/**
|
||||
* DOM 컨테이너 준비 상태 체크 - useLayoutEffect로 동기적 체크
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (containerRef.current) {
|
||||
console.log("✅ DOM 컨테이너 준비 완료:", containerRef.current.id);
|
||||
setIsContainerReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* DOM 컨테이너 준비 상태 재체크 (fallback)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isContainerReady) {
|
||||
const timer = setTimeout(() => {
|
||||
if (containerRef.current && !isContainerReady) {
|
||||
console.log("✅ useEffect: DOM 컨테이너 지연 준비 완료");
|
||||
setIsContainerReady(true);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isContainerReady]);
|
||||
|
||||
/**
|
||||
* 컴포넌트 마운트 시 초기화
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentFile?.xlsxBuffer &&
|
||||
isContainerReady &&
|
||||
containerRef.current &&
|
||||
!isInitialized &&
|
||||
!isConverting
|
||||
) {
|
||||
console.log("🔄 XLSX 버퍼 감지, LuckyExcel 직접 변환 시작...", {
|
||||
fileName: currentFile.name,
|
||||
bufferSize: currentFile.xlsxBuffer.byteLength,
|
||||
containerId: containerRef.current.id,
|
||||
});
|
||||
|
||||
// 중복 실행 방지
|
||||
setIsConverting(true);
|
||||
|
||||
// LuckyExcel로 직접 변환
|
||||
convertXLSXWithLuckyExcel(currentFile.xlsxBuffer, currentFile.name);
|
||||
} else if (currentFile && !currentFile.xlsxBuffer) {
|
||||
setError("파일 변환 데이터가 없습니다. 파일을 다시 업로드해주세요.");
|
||||
}
|
||||
}, [
|
||||
currentFile?.xlsxBuffer,
|
||||
currentFile?.name,
|
||||
isContainerReady,
|
||||
isInitialized,
|
||||
isConverting,
|
||||
convertXLSXWithLuckyExcel,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 컴포넌트 언마운트 시 정리
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (luckysheetRef.current && window.luckysheet) {
|
||||
try {
|
||||
window.luckysheet.destroy();
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Luckysheet 정리 중 오류:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 윈도우 리사이즈 처리
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (luckysheetRef.current && window.luckysheet) {
|
||||
try {
|
||||
if (window.luckysheet.resize) {
|
||||
window.luckysheet.resize();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Luckysheet 리사이즈 중 오류:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full h-full min-h-[70vh] ${className || ""}`}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{/* Luckysheet 컨테이너 - 항상 렌더링 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
id="luckysheet-container"
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
minHeight: "70vh",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 에러 상태 오버레이 */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="text-center p-6">
|
||||
<div className="text-red-600 text-lg font-semibold mb-2">
|
||||
시트 로드 오류
|
||||
</div>
|
||||
<div className="text-red-500 text-sm mb-4">{error}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setIsInitialized(false);
|
||||
setIsConverting(false);
|
||||
if (currentFile?.xlsxBuffer) {
|
||||
convertXLSXWithLuckyExcel(
|
||||
currentFile.xlsxBuffer,
|
||||
currentFile.name,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 오버레이 */}
|
||||
{!error &&
|
||||
(isConverting || !isInitialized) &&
|
||||
currentFile?.xlsxBuffer && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-center p-6">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-blue-600 text-lg font-semibold mb-2">
|
||||
{isConverting ? "LuckyExcel 변환 중..." : "시트 초기화 중..."}
|
||||
</div>
|
||||
<div className="text-blue-500 text-sm">
|
||||
{isConverting
|
||||
? "원본 Excel 데이터를 완전한 스타일로 변환하고 있습니다."
|
||||
: "잠시만 기다려주세요."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 없음 상태 오버레이 */}
|
||||
{!error && !currentFile?.xlsxBuffer && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div className="text-center p-6">
|
||||
<div className="text-gray-500 text-lg font-semibold mb-2">
|
||||
표시할 시트가 없습니다
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm">
|
||||
Excel 파일을 업로드해주세요.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 시트 정보 표시 (개발용) */}
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<div className="absolute top-2 right-2 bg-black bg-opacity-75 text-white text-xs p-2 rounded z-10">
|
||||
<div>파일: {currentFile?.name}</div>
|
||||
<div>
|
||||
XLSX 버퍼:{" "}
|
||||
{currentFile?.xlsxBuffer
|
||||
? `${currentFile.xlsxBuffer.byteLength} bytes`
|
||||
: "없음"}
|
||||
</div>
|
||||
<div>변환 중: {isConverting ? "예" : "아니오"}</div>
|
||||
<div>초기화: {isInitialized ? "완료" : "대기"}</div>
|
||||
<div>컨테이너 준비: {isContainerReady ? "완료" : "대기"}</div>
|
||||
<div>방식: LuckyExcel 직접 변환</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
// 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 any;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,402 +0,0 @@
|
||||
// import React from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
import { SheetViewer } from "../SheetViewer";
|
||||
import { useAppStore } from "../../../stores/useAppStore";
|
||||
import type { SheetData } from "../../../types/sheet";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../../../stores/useAppStore");
|
||||
|
||||
// Luckysheet 모킹
|
||||
const mockLuckysheet = {
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
getSheet: vi.fn(),
|
||||
getAllSheets: vi.fn(),
|
||||
setActiveSheet: vi.fn(),
|
||||
};
|
||||
|
||||
// Window.luckysheet 모킹
|
||||
Object.defineProperty(window, "luckysheet", {
|
||||
value: mockLuckysheet,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// useAppStore 모킹 타입
|
||||
const mockUseAppStore = vi.mocked(useAppStore);
|
||||
|
||||
// 기본 스토어 상태
|
||||
const defaultStoreState = {
|
||||
sheets: [],
|
||||
activeSheetId: null,
|
||||
currentFile: null,
|
||||
setSelectedRange: vi.fn(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
setLoading: vi.fn(),
|
||||
setError: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
clearFileUploadErrors: vi.fn(),
|
||||
resetApp: vi.fn(),
|
||||
};
|
||||
|
||||
// 테스트용 시트 데이터
|
||||
const mockSheetData: SheetData[] = [
|
||||
{
|
||||
id: "sheet_0",
|
||||
name: "Sheet1",
|
||||
data: [
|
||||
["A1", "B1", "C1"],
|
||||
["A2", "B2", "C2"],
|
||||
],
|
||||
config: {
|
||||
container: "luckysheet_0",
|
||||
title: "Sheet1",
|
||||
lang: "ko",
|
||||
data: [
|
||||
{
|
||||
name: "Sheet1",
|
||||
index: "0",
|
||||
celldata: [
|
||||
{
|
||||
r: 0,
|
||||
c: 0,
|
||||
v: { v: "A1", m: "A1", ct: { fa: "General", t: "g" } },
|
||||
},
|
||||
{
|
||||
r: 0,
|
||||
c: 1,
|
||||
v: { v: "B1", m: "B1", ct: { fa: "General", t: "g" } },
|
||||
},
|
||||
],
|
||||
status: 1,
|
||||
order: 0,
|
||||
row: 2,
|
||||
column: 3,
|
||||
},
|
||||
],
|
||||
options: {
|
||||
showtoolbar: true,
|
||||
showinfobar: false,
|
||||
showsheetbar: true,
|
||||
showstatisticBar: false,
|
||||
allowCopy: true,
|
||||
allowEdit: true,
|
||||
enableAddRow: true,
|
||||
enableAddCol: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("SheetViewer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseAppStore.mockReturnValue(defaultStoreState);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// DOM 정리
|
||||
document.head.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("초기 렌더링", () => {
|
||||
it("시트 데이터가 없을 때 적절한 메시지를 표시한다", () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: [],
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
expect(screen.getByText("표시할 시트가 없습니다")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Excel 파일을 업로드해주세요."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("시트 데이터가 있을 때 로딩 상태를 표시한다", () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
expect(screen.getByText("시트 로딩 중...")).toBeInTheDocument();
|
||||
expect(screen.getByText("잠시만 기다려주세요.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Luckysheet 컨테이너가 올바르게 렌더링된다", () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
const container = document.getElementById("luckysheet-container");
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container).toHaveClass("w-full", "h-full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Luckysheet 초기화", () => {
|
||||
it("시트 데이터가 변경되면 Luckysheet를 초기화한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// create 호출 시 전달된 설정 확인
|
||||
const createCall = mockLuckysheet.create.mock.calls[0];
|
||||
expect(createCall).toBeDefined();
|
||||
|
||||
const config = createCall[0];
|
||||
expect(config.container).toBe("luckysheet-container");
|
||||
expect(config.title).toBe("test.xlsx");
|
||||
expect(config.lang).toBe("ko");
|
||||
expect(config.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("기존 Luckysheet 인스턴스가 있으면 제거한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
const { rerender } = render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// 시트 데이터 변경
|
||||
const newSheetData: SheetData[] = [
|
||||
{
|
||||
...mockSheetData[0],
|
||||
name: "NewSheet",
|
||||
},
|
||||
];
|
||||
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: newSheetData,
|
||||
currentFile: { name: "new.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
rerender(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.destroy).toHaveBeenCalled();
|
||||
expect(mockLuckysheet.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("에러 처리", () => {
|
||||
it("Luckysheet 초기화 실패 시 에러 메시지를 표시한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
// Luckysheet.create에서 에러 발생 시뮬레이션
|
||||
mockLuckysheet.create.mockImplementation(() => {
|
||||
throw new Error("Luckysheet 초기화 실패");
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("시트 로드 오류")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/시트 초기화에 실패했습니다/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 다시 시도 버튼 확인
|
||||
const retryButton = screen.getByRole("button", { name: "다시 시도" });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("다시 시도 버튼을 클릭하면 초기화를 재시도한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
// 첫 번째 시도에서 실패
|
||||
mockLuckysheet.create.mockImplementationOnce(() => {
|
||||
throw new Error("첫 번째 실패");
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("시트 로드 오류")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 두 번째 시도에서 성공하도록 설정
|
||||
mockLuckysheet.create.mockImplementationOnce(() => {
|
||||
// 성공
|
||||
});
|
||||
|
||||
const retryButton = screen.getByRole("button", { name: "다시 시도" });
|
||||
retryButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("이벤트 핸들링", () => {
|
||||
it("셀 클릭 시 선택된 범위를 스토어에 저장한다", async () => {
|
||||
const mockSetSelectedRange = vi.fn();
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
setSelectedRange: mockSetSelectedRange,
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// create 호출 시 전달된 hook 확인
|
||||
const createCall = mockLuckysheet.create.mock.calls[0];
|
||||
const config = createCall[0];
|
||||
|
||||
// cellClick 핸들러 시뮬레이션
|
||||
const cellClickHandler = config.hook.cellClick;
|
||||
expect(cellClickHandler).toBeDefined();
|
||||
|
||||
const mockCell = {};
|
||||
const mockPosition = { r: 1, c: 2 };
|
||||
const mockSheetFile = { index: "0" };
|
||||
|
||||
cellClickHandler(mockCell, mockPosition, mockSheetFile);
|
||||
|
||||
expect(mockSetSelectedRange).toHaveBeenCalledWith({
|
||||
range: {
|
||||
startRow: 1,
|
||||
startCol: 2,
|
||||
endRow: 1,
|
||||
endCol: 2,
|
||||
},
|
||||
sheetId: "0",
|
||||
});
|
||||
});
|
||||
|
||||
it("시트 활성화 시 활성 시트 ID를 업데이트한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
// setActiveSheetId를 spy로 설정
|
||||
const setActiveSheetIdSpy = vi.fn();
|
||||
useAppStore.getState = vi.fn().mockReturnValue({
|
||||
setActiveSheetId: setActiveSheetIdSpy,
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// create 호출 시 전달된 hook 확인
|
||||
const createCall = mockLuckysheet.create.mock.calls[0];
|
||||
const config = createCall[0];
|
||||
|
||||
// sheetActivate 핸들러 시뮬레이션
|
||||
const sheetActivateHandler = config.hook.sheetActivate;
|
||||
expect(sheetActivateHandler).toBeDefined();
|
||||
|
||||
sheetActivateHandler(0, false, false);
|
||||
|
||||
expect(setActiveSheetIdSpy).toHaveBeenCalledWith("sheet_0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("컴포넌트 생명주기", () => {
|
||||
it("컴포넌트 언마운트 시 Luckysheet를 정리한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
const { unmount } = render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockLuckysheet.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("윈도우 리사이즈 시 Luckysheet 리사이즈를 호출한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 윈도우 리사이즈 이벤트 시뮬레이션
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
expect(mockLuckysheet.resize).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("개발 모드 정보", () => {
|
||||
it("개발 모드에서 시트 정보를 표시한다", () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
activeSheetId: "sheet_0",
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
expect(screen.getByText("시트 개수: 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("활성 시트: sheet_0")).toBeInTheDocument();
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user