엑셀 파일 부르기 완료, 입력창 UI 설정 완료

This commit is contained in:
sheetEasy AI Team
2025-06-24 17:48:11 +09:00
parent 105265a384
commit 5712c40ec9
14 changed files with 456 additions and 5241 deletions

View File

@@ -0,0 +1,122 @@
---
description:
globs:
alwaysApply: false
---
# Tailwind CSS v4 + Shadcn UI 호환성 규칙
## **CSS 설정 (src/index.css)**
- **@theme 레이어 사용**
```css
@theme {
--radius: 0.5rem;
}
```
- **CSS 변수 정의**
- `:root`에 라이트 모드 색상 변수 정의
- `.dark`에 다크 모드 색상 변수 정의
- `hsl(var(--foreground))` 형태로 색상 사용
## **cn 함수 (src/lib/utils.ts)**
- **에러 핸들링 필수**
```typescript
// ✅ DO: fallback 로직 포함
export function cn(...inputs: ClassValue[]) {
try {
return twMerge(clsx(inputs));
} catch (error) {
console.warn("tailwind-merge fallback:", error);
return clsx(inputs);
}
}
// ❌ DON'T: 에러 핸들링 없이 사용
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
## **컴포넌트 스타일링**
- **CSS 변수 활용**
```typescript
// ✅ DO: CSS 변수 기반 스타일링
className="bg-background text-foreground border-border"
// ✅ DO: cn 함수로 조건부 스타일링
className={cn(
"base-styles",
condition && "conditional-styles"
)}
```
- **색상 시스템 준수**
- `background`, `foreground`, `primary`, `secondary` 등 정의된 변수 사용
- 직접 색상 값 대신 변수 사용
## **패키지 관리**
- **필수 패키지**
```json
{
"@tailwindcss/cli": "^4.1.10",
"@tailwindcss/vite": "^4.1.10",
"tailwind-merge": "latest",
"clsx": "^2.1.1",
"class-variance-authority": "^0.7.1"
}
```
- **제거해야 할 파일**
- `tailwind.config.js` (v4는 CSS-first 방식)
- `postcss.config.js` (v4는 PostCSS 불필요)
## **Vite 설정**
- **플러그인 설정**
```typescript
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});
```
## **문제 해결**
- **tailwind-merge 오류 시**
- 최신 버전으로 업데이트
- cn 함수에 fallback 로직 구현
- **스타일이 적용되지 않을 때**
- CSS 변수가 올바르게 정의되었는지 확인
- @theme 레이어가 포함되었는지 확인
- **빌드 오류 시**
- node_modules 캐시 삭제 후 재설치
- package-lock.json 삭제 후 재설치
## **모범 사례**
- **컴포넌트 개발 시**
- 항상 CSS 변수 사용
- cn 함수로 클래스 조합
- 조건부 스타일링에 적절한 패턴 적용
- **테마 관리**
- 라이트/다크 모드 변수 동시 정의
- 일관된 색상 시스템 유지
- **성능 최적화**
- 불필요한 클래스 중복 방지
- cn 함수 사용으로 클래스 충돌 해결
## **참고 자료**
- [Tailwind CSS v4 공식 문서](https://tailwindcss.com/docs/v4-beta)
- [Shadcn UI + Tailwind v4 가이드](https://www.luisball.com/blog/shadcn-ui-with-tailwind-v4)
- [Shadcn UI 공식 설치 가이드](https://ui.shadcn.com/docs/installation/manual)

View File

@@ -3,3 +3,193 @@ description:
globs:
alwaysApply: false
---
# xlsx-js-style 스타일 보존 규칙
## **핵심 원칙**
- xlsx-js-style의 공식 API 구조를 직접 활용하여 스타일 변환
- 복잡한 색상 변환 로직 대신 공식 COLOR_STYLE 형식 지원
- 배경색과 테두리 색상 누락 방지를 위한 완전한 스타일 매핑
## **공식 xlsx-js-style API 활용**
### **색상 처리 (COLOR_STYLE)**
```typescript
// ✅ DO: 공식 COLOR_STYLE 형식 모두 지원
function convertXlsxColorToLuckysheet(colorObj: any): string {
// RGB 형태: {rgb: "FFCC00"}
if (colorObj.rgb) { /* RGB 처리 */ }
// Theme 색상: {theme: 4} 또는 {theme: 1, tint: 0.4}
if (typeof colorObj.theme === 'number') { /* Theme 처리 */ }
// Indexed 색상: Excel 기본 색상표
if (typeof colorObj.indexed === 'number') { /* Indexed 처리 */ }
}
// ❌ DON'T: 특정 색상 형식만 처리
function badColorConvert(colorObj: any): string {
return colorObj.rgb || "rgb(0,0,0)"; // rgb만 처리하고 theme, indexed 무시
}
```
### **스타일 객체 변환**
```typescript
// ✅ DO: 공식 스타일 속성 완전 매핑
function convertXlsxStyleToLuckysheet(xlsxStyle: any): any {
const luckyStyle: any = {};
// 폰트: {name: "Courier", sz: 24, bold: true, color: {rgb: "FF0000"}}
if (xlsxStyle.font) {
if (xlsxStyle.font.name) luckyStyle.ff = xlsxStyle.font.name;
if (xlsxStyle.font.sz) luckyStyle.fs = xlsxStyle.font.sz;
if (xlsxStyle.font.bold) luckyStyle.bl = 1;
if (xlsxStyle.font.color) {
luckyStyle.fc = convertXlsxColorToLuckysheet(xlsxStyle.font.color);
}
}
// 배경: {fgColor: {rgb: "E9E9E9"}}
if (xlsxStyle.fill?.fgColor) {
luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.fgColor);
}
// 테두리: {top: {style: "thin", color: {rgb: "000000"}}}
if (xlsxStyle.border) {
luckyStyle.bd = {};
if (xlsxStyle.border.top) {
luckyStyle.bd.t = {
style: convertBorderStyleToLuckysheet(xlsxStyle.border.top.style),
color: convertXlsxColorToLuckysheet(xlsxStyle.border.top.color)
};
}
}
return luckyStyle;
}
// ❌ DON'T: 수동으로 스타일 속성 하나씩 처리
luckyCell.v.s = {
ff: cell.s.font?.name || "Arial",
bg: cell.s.fill?.fgColor?.rgb || "rgb(255,255,255)" // 직접 rgb만 처리
};
```
## **배경색과 테두리 색상 누락 방지**
### **배경색 처리**
```typescript
// ✅ DO: fgColor와 bgColor 모두 확인
if (xlsxStyle.fill) {
if (xlsxStyle.fill.fgColor) {
luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.fgColor);
} else if (xlsxStyle.fill.bgColor) {
luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.bgColor);
}
}
// ❌ DON'T: fgColor만 확인
if (xlsxStyle.fill?.fgColor) {
luckyStyle.bg = xlsxStyle.fill.fgColor.rgb; // 다른 색상 형식 무시
}
```
### **테두리 색상 처리**
```typescript
// ✅ DO: 모든 테두리 방향과 색상 형식 지원
if (xlsxStyle.border) {
['top', 'bottom', 'left', 'right'].forEach(side => {
if (xlsxStyle.border[side]) {
luckyStyle.bd[side[0]] = {
style: convertBorderStyleToLuckysheet(xlsxStyle.border[side].style),
color: convertXlsxColorToLuckysheet(xlsxStyle.border[side].color)
};
}
});
}
// ❌ DON'T: 하드코딩된 색상 사용
luckyStyle.bd.t = {
style: 1,
color: "rgb(0,0,0)" // 실제 색상 무시
};
```
## **Excel Tint 처리**
```typescript
// ✅ DO: Excel tint 공식 적용
function applyTintToRgbColor(rgbColor: string, tint: number): string {
const applyTint = (color: number, tint: number): number => {
if (tint < 0) {
return Math.round(color * (1 + tint));
} else {
return Math.round(color * (1 - tint) + (255 - 255 * (1 - tint)));
}
};
// RGB 각 채널에 tint 적용
}
// ❌ DON'T: tint 무시
if (colorObj.theme) {
return themeColors[colorObj.theme]; // tint 무시
}
```
## **오류 방지 패턴**
### **안전한 스타일 읽기**
```typescript
// ✅ DO: 옵셔널 체이닝과 타입 검사
workbook = XLSX.read(arrayBuffer, {
cellStyles: true // 스타일 정보 보존
});
// 스타일 정보 확인
if (cell.s) {
console.log(`🎨 셀 ${cellAddress}에 스타일 정보:`, cell.s);
luckyCell.v.s = convertXlsxStyleToLuckysheet(cell.s);
}
// ❌ DON'T: 스타일 옵션 누락
workbook = XLSX.read(arrayBuffer); // cellStyles 옵션 없음
```
### **스타일 쓰기 보존**
```typescript
// ✅ DO: 쓰기 시에도 스타일 보존
const xlsxData = XLSX.write(workbook, {
type: "array",
bookType: "xlsx",
cellStyles: true // 스타일 정보 보존
});
// ❌ DON'T: 쓰기 시 스타일 누락
const xlsxData = XLSX.write(workbook, {
type: "array",
bookType: "xlsx"
// cellStyles 옵션 없음
});
```
## **디버깅 및 검증**
### **스타일 정보 로깅**
```typescript
// ✅ DO: 개발 모드에서 스타일 정보 상세 분석
if (import.meta.env.DEV && cell.s) {
console.log(`🎨 셀 ${cellAddress} 스타일:`, {
font: cell.s.font,
fill: cell.s.fill,
border: cell.s.border,
alignment: cell.s.alignment
});
}
// ❌ DON'T: 스타일 정보 무시
// 스타일 관련 로그 없음
```
## **참고 사항**
- [xlsx-js-style GitHub](https://github.com/gitbrent/xlsx-js-style) 공식 문서 참조
- 공식 COLOR_STYLE 형식: `{rgb: "FFCC00"}`, `{theme: 4}`, `{theme: 1, tint: 0.4}`
- 공식 BORDER_STYLE 값: `thin`, `medium`, `thick`, `dotted`, `dashed` 등
- Excel 테마 색상과 tint 처리는 공식 Excel 색상 공식 사용

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { Button } from "./components/ui/button";
import TestSheetViewer from "./components/sheet/TestSheetViewer";
import HomeButton from "./components/ui/homeButton";
import EditSheetViewer from "./components/sheet/EditSheetViewer";
function App() {
const [showTestViewer, setShowTestViewer] = useState(false);
@@ -12,7 +13,20 @@ function App() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<h1 className="text-2xl font-bold text-blue-600">sheetEasy AI</h1>
{/* showTestViewer가 true일 때만 홈 버튼 노출 */}
{showTestViewer && (
<HomeButton
className="ml-0"
style={{ position: "absolute", left: "1%" }}
onClick={() => {
if (window.confirm("기본 화면으로 돌아가시겠습니까?")) {
setShowTestViewer(false);
}
}}
>
sheetEasy AI
</HomeButton>
)}
</div>
<div className="flex items-center space-x-4">
{/* 테스트 뷰어 토글 버튼 */}
@@ -22,12 +36,12 @@ function App() {
onClick={() => setShowTestViewer(!showTestViewer)}
className="bg-green-500 hover:bg-green-600 text-white border-green-500"
>
🧪
🧪
</Button>
{!showTestViewer && (
<span className="text-sm text-gray-600">
Univer CE
Univer CE
</span>
)}
</div>
@@ -40,24 +54,24 @@ function App() {
{showTestViewer ? (
// 테스트 뷰어 표시
<div className="h-full">
<TestSheetViewer />
<EditSheetViewer />
</div>
) : (
// 메인 페이지
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center py-12">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
🧪 Univer CE
🧪 Univer CE
</h2>
<p className="text-lg text-gray-600 mb-8">
Univer CE
Univer CE
</p>
<Button
onClick={() => setShowTestViewer(true)}
size="lg"
className="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3"
>
</Button>
</div>
</div>

View File

@@ -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 && (
<>

View File

@@ -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>
);
}

View 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;

View File

@@ -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>
);
}

View File

@@ -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");
});
});
});

View File

@@ -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;
});
});
});

View File

@@ -0,0 +1,29 @@
import React from "react";
interface HomeButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
}
/**
* 텍스트만 있는 홈 버튼 컴포넌트
* - 파란색, 볼드, hover 시 underline
* - outline/배경 없음
* - Button 컴포넌트 의존성 없음
*/
const HomeButton: React.FC<HomeButtonProps> = ({ children, ...props }) => {
return (
<button
type="button"
{...props}
className={
"text-2xl font-bold text-blue-600 bg-transparent border-none p-0 m-0 cursor-pointer hover:underline focus:outline-none focus:ring-0 " +
(props.className || "")
}
>
{children}
</button>
);
};
export default HomeButton;

View File

@@ -1,459 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as XLSX from "xlsx-js-style";
import {
validateFileType,
validateFileSize,
getFileErrorMessage,
filterValidFiles,
getFileErrors,
processExcelFile,
MAX_FILE_SIZE,
SUPPORTED_EXTENSIONS,
} from "../fileProcessor";
// xlsx-js-style 모킹 (통합 처리)
vi.mock("xlsx-js-style", () => ({
read: vi.fn(() => ({
SheetNames: ["Sheet1"],
Sheets: {
Sheet1: {
A1: { v: "테스트" },
B1: { v: "한글" },
C1: { v: "데이터" },
"!ref": "A1:C2",
},
},
})),
write: vi.fn(() => new ArrayBuffer(1024)), // XLSX.write 모킹 추가
utils: {
sheet_to_json: vi.fn(() => [
["테스트", "한글", "데이터"],
["값1", "값2", "값3"],
]),
decode_range: vi.fn((_ref) => ({
s: { r: 0, c: 0 },
e: { r: 1, c: 2 },
})),
encode_cell: vi.fn(
(cell) => `${String.fromCharCode(65 + cell.c)}${cell.r + 1}`,
),
aoa_to_sheet: vi.fn(() => ({
A1: { v: "테스트" },
B1: { v: "한글" },
C1: { v: "데이터" },
"!ref": "A1:C1",
})),
book_new: vi.fn(() => ({ SheetNames: [], Sheets: {} })),
book_append_sheet: vi.fn(),
},
}));
// LuckyExcel 모킹
vi.mock("luckyexcel", () => ({
transformExcelToLucky: vi.fn(
(_arrayBuffer, successCallback, _errorCallback) => {
// 성공적인 변환 결과 모킹
const mockResult = {
sheets: [
{
name: "Sheet1",
index: "0",
status: 1,
order: 0,
row: 2,
column: 3,
celldata: [
{
r: 0,
c: 0,
v: { v: "테스트", m: "테스트", ct: { fa: "General", t: "g" } },
},
{
r: 0,
c: 1,
v: { v: "한글", m: "한글", ct: { fa: "General", t: "g" } },
},
{
r: 0,
c: 2,
v: { v: "데이터", m: "데이터", ct: { fa: "General", t: "g" } },
},
{
r: 1,
c: 0,
v: { v: "값1", m: "값1", ct: { fa: "General", t: "g" } },
},
{
r: 1,
c: 1,
v: { v: "값2", m: "값2", ct: { fa: "General", t: "g" } },
},
{
r: 1,
c: 2,
v: { v: "값3", m: "값3", ct: { fa: "General", t: "g" } },
},
],
},
],
};
// 성공 콜백 비동기 호출 (ArrayBuffer 매개변수 대응)
if (typeof successCallback === "function") {
setTimeout(() => successCallback(mockResult, null), 0);
}
},
),
}));
// 파일 생성 도우미 함수
function createMockFile(
name: string,
size: number,
type: string = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
): File {
const mockFile = new Blob(["mock file content"], { type });
Object.defineProperty(mockFile, "name", {
value: name,
writable: false,
});
Object.defineProperty(mockFile, "size", {
value: size,
writable: false,
});
// ArrayBuffer 메서드 모킹
Object.defineProperty(mockFile, "arrayBuffer", {
value: async () => new ArrayBuffer(size),
writable: false,
});
return mockFile as File;
}
// FileList 모킹
class MockFileList {
private _files: File[];
constructor(files: File[]) {
this._files = files;
}
get length(): number {
return this._files.length;
}
item(index: number): File | null {
return this._files[index] || null;
}
get files(): FileList {
const files = this._files;
return Object.assign(files, {
item: (index: number) => files[index] || null,
[Symbol.iterator]: function* (): Generator<File, void, unknown> {
for (let i = 0; i < files.length; i++) {
yield files[i];
}
},
}) as unknown as FileList;
}
}
describe("fileProcessor", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("validateFileType", () => {
it("지원하는 파일 확장자를 승인해야 함", () => {
const validFiles = [
createMockFile("test.xlsx", 1000),
createMockFile("test.xls", 1000),
createMockFile("test.csv", 1000),
createMockFile("한글파일.xlsx", 1000),
];
validFiles.forEach((file) => {
expect(validateFileType(file)).toBe(true);
});
});
it("지원하지 않는 파일 확장자를 거부해야 함", () => {
const invalidFiles = [
createMockFile("test.txt", 1000),
createMockFile("test.pdf", 1000),
createMockFile("test.doc", 1000),
createMockFile("test", 1000),
];
invalidFiles.forEach((file) => {
expect(validateFileType(file)).toBe(false);
});
});
it("대소문자를 무시하고 파일 확장자를 검증해야 함", () => {
const files = [
createMockFile("test.XLSX", 1000),
createMockFile("test.XLS", 1000),
createMockFile("test.CSV", 1000),
];
files.forEach((file) => {
expect(validateFileType(file)).toBe(true);
});
});
});
describe("validateFileSize", () => {
it("허용된 크기의 파일을 승인해야 함", () => {
const smallFile = createMockFile("small.xlsx", 1000);
expect(validateFileSize(smallFile)).toBe(true);
});
it("최대 크기를 초과한 파일을 거부해야 함", () => {
const largeFile = createMockFile("large.xlsx", MAX_FILE_SIZE + 1);
expect(validateFileSize(largeFile)).toBe(false);
});
});
describe("getFileErrorMessage", () => {
it("유효한 파일에 대해 빈 문자열을 반환해야 함", () => {
const validFile = createMockFile("valid.xlsx", 1000);
expect(getFileErrorMessage(validFile)).toBe("");
});
it("잘못된 파일 형식에 대해 적절한 오류 메시지를 반환해야 함", () => {
const invalidFile = createMockFile("invalid.txt", 1000);
const message = getFileErrorMessage(invalidFile);
expect(message).toContain("지원되지 않는 파일 형식");
expect(message).toContain(SUPPORTED_EXTENSIONS.join(", "));
});
it("파일 크기 초과에 대해 적절한 오류 메시지를 반환해야 함", () => {
const largeFile = createMockFile("large.xlsx", MAX_FILE_SIZE + 1);
const message = getFileErrorMessage(largeFile);
expect(message).toContain("파일 크기가 너무 큽니다");
});
});
describe("filterValidFiles", () => {
it("유효한 파일들만 필터링해야 함", () => {
const fileList = new MockFileList([
createMockFile("valid1.xlsx", 1000),
createMockFile("invalid.txt", 1000),
createMockFile("valid2.csv", 1000),
createMockFile("large.xlsx", MAX_FILE_SIZE + 1),
]).files;
const validFiles = filterValidFiles(fileList);
expect(validFiles).toHaveLength(2);
expect(validFiles[0].name).toBe("valid1.xlsx");
expect(validFiles[1].name).toBe("valid2.csv");
});
});
describe("getFileErrors", () => {
it("무효한 파일들의 오류 목록을 반환해야 함", () => {
const fileList = new MockFileList([
createMockFile("valid.xlsx", 1000),
createMockFile("invalid.txt", 1000),
createMockFile("large.xlsx", MAX_FILE_SIZE + 1),
]).files;
const errors = getFileErrors(fileList);
expect(errors).toHaveLength(2);
expect(errors[0].file.name).toBe("invalid.txt");
expect(errors[0].error).toContain("지원되지 않는 파일 형식");
expect(errors[1].file.name).toBe("large.xlsx");
expect(errors[1].error).toContain("파일 크기가 너무 큽니다");
});
});
describe("SheetJS 통합 파일 처리", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("XLSX 파일을 성공적으로 처리해야 함", async () => {
const xlsxFile = createMockFile(
"test.xlsx",
1024,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
const result = await processExcelFile(xlsxFile);
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(Array.isArray(result.data)).toBe(true);
expect(result.data).toHaveLength(1);
expect(result.data![0].name).toBe("Sheet1");
// XLSX 파일은 변환 없이 직접 처리되므로 XLSX.write가 호출되지 않음
});
it("XLS 파일을 성공적으로 처리해야 함", async () => {
const xlsFile = createMockFile(
"test.xls",
1024,
"application/vnd.ms-excel",
);
const result = await processExcelFile(xlsFile);
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(Array.isArray(result.data)).toBe(true);
expect(result.data).toHaveLength(1);
expect(result.data![0].name).toBe("Sheet1");
// XLS 파일은 SheetJS를 통해 XLSX로 변환 후 처리
expect(XLSX.write).toHaveBeenCalled();
});
it("CSV 파일을 성공적으로 처리해야 함", async () => {
const csvFile = createMockFile("test.csv", 1024, "text/csv");
const result = await processExcelFile(csvFile);
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(Array.isArray(result.data)).toBe(true);
expect(result.data).toHaveLength(1);
expect(result.data![0].name).toBe("Sheet1");
// CSV 파일은 SheetJS를 통해 XLSX로 변환 후 처리
expect(XLSX.write).toHaveBeenCalled();
});
it("한글 파일명을 올바르게 처리해야 함", async () => {
const koreanFile = createMockFile(
"한글파일명_테스트데이터.xlsx",
1024,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
const result = await processExcelFile(koreanFile);
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
});
it("빈 파일을 적절히 처리해야 함", async () => {
const emptyFile = createMockFile("empty.xlsx", 0);
const result = await processExcelFile(emptyFile);
expect(result.success).toBe(false);
expect(result.error).toContain("파일이 비어있습니다");
});
it("유효하지 않은 workbook 을 처리해야 함", async () => {
(XLSX.read as any).mockReturnValueOnce(null);
const invalidFile = createMockFile("invalid.xlsx", 1024);
const result = await processExcelFile(invalidFile);
expect(result.success).toBe(false);
expect(result.error).toContain("워크북을 생성할 수 없습니다");
});
it("시트가 없는 workbook을 처리해야 함", async () => {
(XLSX.read as any).mockReturnValueOnce({
SheetNames: [],
Sheets: {},
});
const noSheetsFile = createMockFile("no-sheets.xlsx", 1024);
const result = await processExcelFile(noSheetsFile);
expect(result.success).toBe(false);
expect(result.error).toContain("시트 이름 정보가 없습니다");
});
it("Sheets 속성이 없는 workbook을 처리해야 함", async () => {
(XLSX.read as any).mockReturnValueOnce({
SheetNames: ["Sheet1"],
// Sheets 속성 누락
});
const corruptedFile = createMockFile("corrupted.xlsx", 1024);
const result = await processExcelFile(corruptedFile);
expect(result.success).toBe(false);
expect(result.error).toContain("유효한 시트가 없습니다");
});
it("XLSX.read 실패 시 대체 인코딩을 시도해야 함", async () => {
// 첫 번째 호출은 실패, 두 번째 호출은 성공
(XLSX.read as any)
.mockImplementationOnce(() => {
throw new Error("UTF-8 read failed");
})
.mockReturnValueOnce({
SheetNames: ["Sheet1"],
Sheets: {
Sheet1: { A1: { v: "성공" } },
},
});
const fallbackFile = createMockFile("fallback.csv", 1024, "text/csv");
const result = await processExcelFile(fallbackFile);
expect(result.success).toBe(true);
expect(XLSX.read).toHaveBeenCalledTimes(2);
// CSV 파일은 TextDecoder를 사용하여 문자열로 읽어서 처리
expect(XLSX.read).toHaveBeenNthCalledWith(
2,
expect.any(String),
expect.objectContaining({
type: "string",
codepage: 949, // EUC-KR 대체 인코딩
}),
);
});
it("모든 읽기 시도가 실패하면 적절한 오류를 반환해야 함", async () => {
(XLSX.read as any).mockImplementation(() => {
throw new Error("Read completely failed");
});
const failedFile = createMockFile("failed.xlsx", 1024);
const result = await processExcelFile(failedFile);
expect(result.success).toBe(false);
expect(result.error).toContain("파일을 읽을 수 없습니다");
});
it("한글 데이터를 올바르게 처리해야 함", async () => {
// beforeEach에서 설정된 기본 모킹을 그대로 사용하지만,
// 실제로는 시트명이 변경되지 않는 것이 정상 동작입니다.
// LuckyExcel에서 변환할 때 시트명은 일반적으로 유지되지만,
// 모킹 데이터에서는 "Sheet1"로 설정되어 있으므로 이를 맞춰야 합니다.
// 한글 데이터가 포함된 시트 모킹
(XLSX.read as any).mockReturnValueOnce({
SheetNames: ["한글시트"],
Sheets: {
: {
A1: { v: "이름" },
B1: { v: "나이" },
C1: { v: "주소" },
A2: { v: "김철수" },
B2: { v: 30 },
C2: { v: "서울시 강남구" },
"!ref": "A1:C2",
},
},
});
const koreanDataFile = createMockFile("한글데이터.xlsx", 1024);
const result = await processExcelFile(koreanDataFile);
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(Array.isArray(result.data)).toBe(true);
expect(result.data).toHaveLength(1);
// 실제 모킹 데이터에서는 "Sheet1"을 사용하므로 이를 확인합니다.
expect(result.data![0].name).toBe("Sheet1"); // 모킹 데이터의 실제 시트명
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,417 +0,0 @@
/**
* xlsx-js-style 스타일 보존 테스트 유틸리티
* - 다양한 스타일이 적용된 Excel 파일 생성
* - 스타일 정보 확인 도구
*/
import * as XLSX from "xlsx-js-style";
/**
* 스타일이 적용된 테스트 Excel 파일 생성
*/
export function createStyledTestExcel(): ArrayBuffer {
// 새 워크북 생성
const wb = XLSX.utils.book_new();
// 테스트 데이터 생성 - xlsx-js-style 공식 API 완전 활용
const testData = [
// 첫 번째 행 - 폰트 스타일 테스트
[
{
v: "굵은 글씨",
t: "s",
s: {
font: {
name: "Courier", // 공식 문서 예시
sz: 24, // 공식 문서 예시
bold: true,
color: { rgb: "000000" },
},
},
},
{
v: "빨간 글씨",
t: "s",
s: {
font: {
bold: true,
color: { rgb: "FF0000" }, // 공식 문서: {color: {rgb: "FF0000"}}
sz: 12,
},
},
},
{
v: "테마 색상",
t: "s",
s: {
font: {
color: { theme: 4 }, // 공식 문서: {theme: 4} (Blue, Accent 1)
sz: 14,
italic: true,
},
},
},
],
// 두 번째 행 - 배경색 테스트
[
{
v: "노란 배경",
t: "s",
s: {
fill: {
patternType: "solid",
fgColor: { rgb: "FFFF00" }, // 공식 문서: {fgColor: {rgb: "E9E9E9"}}
},
},
},
{
v: "테마 배경",
t: "s",
s: {
fill: {
patternType: "solid",
fgColor: { theme: 1, tint: 0.4 }, // 공식 문서: {theme: 1, tint: 0.4} ("Blue, Accent 1, Lighter 40%")
},
font: { color: { rgb: "000000" } },
},
},
{
v: "인덱스 색상",
t: "s",
s: {
fill: {
patternType: "solid",
fgColor: { indexed: 5 }, // Excel 기본 색상표 - 노랑
},
},
},
],
// 세 번째 행 - 테두리 테스트
[
{
v: "얇은 테두리",
t: "s",
s: {
border: {
top: { style: "thin", color: { rgb: "000000" } },
bottom: { style: "thin", color: { rgb: "000000" } },
left: { style: "thin", color: { rgb: "000000" } },
right: { style: "thin", color: { rgb: "000000" } },
},
},
},
{
v: "두꺼운 테두리",
t: "s",
s: {
border: {
top: { style: "thick", color: { theme: 2 } }, // 테마 색상 사용
bottom: { style: "thick", color: { theme: 2 } },
left: { style: "thick", color: { theme: 2 } },
right: { style: "thick", color: { theme: 2 } },
},
},
},
{
v: "다양한 테두리",
t: "s",
s: {
border: {
top: { style: "dotted", color: { indexed: 4 } }, // 인덱스 색상 - 파랑
bottom: { style: "dashed", color: { indexed: 4 } },
left: { style: "dashDot", color: { indexed: 4 } },
right: { style: "double", color: { indexed: 4 } },
},
},
},
],
// 네 번째 행 - 복합 스타일 테스트
[
{
v: "복합 스타일",
t: "s",
s: {
font: {
bold: true,
italic: true,
underline: true,
sz: 16,
color: { rgb: "FFFFFF" },
name: "Courier", // 공식 문서 예시
},
fill: {
patternType: "solid",
fgColor: { theme: 7, tint: -0.2 }, // 어두운 보라색
},
border: {
top: { style: "medium", color: { rgb: "FFD700" } },
bottom: { style: "medium", color: { rgb: "FFD700" } },
left: { style: "medium", color: { rgb: "FFD700" } },
right: { style: "medium", color: { rgb: "FFD700" } },
},
alignment: {
horizontal: "center",
vertical: "middle",
wrapText: true, // 공식 문서: {wrapText: true}
},
},
},
{
v: 1234.567,
t: "n",
s: {
numFmt: "0.00%", // 공식 문서: numFmt 예시
alignment: { horizontal: "right" },
font: { bold: true },
},
},
{
v: "줄바꿈\n테스트",
t: "s",
s: {
alignment: {
wrapText: true,
vertical: "top",
textRotation: 0, // 공식 문서: textRotation
},
font: { sz: 10 },
},
},
],
// 다섯 번째 행 - 고급 스타일 테스트
[
{
v: "취소선 텍스트",
t: "s",
s: {
font: {
strike: true, // 공식 문서: {strike: true}
sz: 12,
color: { theme: 5, tint: 0.6 }, // 밝은 빨강
},
},
},
{
v: "회전 텍스트",
t: "s",
s: {
alignment: {
textRotation: 45, // 공식 문서: textRotation
horizontal: "center",
vertical: "middle",
},
font: { sz: 14, bold: true },
},
},
{
v: new Date(),
t: "d",
s: {
numFmt: "m/dd/yy", // 공식 문서: 날짜 포맷 예시
font: { name: "Arial", sz: 10 },
alignment: { horizontal: "center" },
},
},
],
];
// 워크시트 생성
const ws = XLSX.utils.aoa_to_sheet(testData);
// 병합 셀 추가
if (!ws["!merges"]) ws["!merges"] = [];
ws["!merges"].push(
{ s: { r: 0, c: 0 }, e: { r: 0, c: 1 } }, // A1:B1 병합
{ s: { r: 2, c: 2 }, e: { r: 3, c: 2 } }, // C3:C4 병합
);
// 열 너비 설정
ws["!cols"] = [
{ wpx: 120 }, // A열 너비
{ wpx: 100 }, // B열 너비
{ wpx: 80 }, // C열 너비
];
// 행 높이 설정
ws["!rows"] = [
{ hpx: 30 }, // 1행 높이
{ hpx: 25 }, // 2행 높이
{ hpx: 40 }, // 3행 높이
];
// 워크시트를 워크북에 추가
XLSX.utils.book_append_sheet(wb, ws, "스타일테스트");
// 추가 시트 생성 (간단한 데이터)
const simpleData = [
["이름", "나이", "직업"],
["홍길동", 30, "개발자"],
["김철수", 25, "디자이너"],
["이영희", 35, "기획자"],
];
const ws2 = XLSX.utils.aoa_to_sheet(simpleData);
// 헤더 스타일 적용
["A1", "B1", "C1"].forEach((cellAddr) => {
if (ws2[cellAddr]) {
ws2[cellAddr].s = {
font: { bold: true, color: { rgb: "FFFFFF" } },
fill: { patternType: "solid", fgColor: { rgb: "5B9BD5" } },
alignment: { horizontal: "center" },
};
}
});
XLSX.utils.book_append_sheet(wb, ws2, "간단한데이터");
// Excel 파일로 변환
const excelBuffer = XLSX.write(wb, {
type: "array",
bookType: "xlsx",
cellStyles: true,
cellDates: true,
bookSST: true,
});
// ArrayBuffer로 변환
if (excelBuffer instanceof Uint8Array) {
return excelBuffer.buffer.slice(
excelBuffer.byteOffset,
excelBuffer.byteOffset + excelBuffer.byteLength,
);
}
return excelBuffer;
}
/**
* 셀 스타일 정보 분석
*/
export function analyzeSheetStyles(workbook: any): void {
console.log("🎨 =================================");
console.log("🎨 Excel 파일 스타일 정보 분석");
console.log("🎨 =================================");
// 🔍 워크북 전체 스타일 정보 확인
console.log("🔍 워크북 메타데이터:", {
Props: workbook.Props ? "있음" : "없음",
Custprops: workbook.Custprops ? "있음" : "없음",
Workbook: workbook.Workbook ? "있음" : "없음",
SSF: workbook.SSF ? "있음" : "없음",
SheetNames: workbook.SheetNames
? workbook.SheetNames.length + "개"
: "없음",
Sheets: workbook.Sheets
? Object.keys(workbook.Sheets).length + "개"
: "없음",
});
// 🔍 워크북 스타일 정보 상세 분석
if (workbook.SSF) {
console.log("🔍 워크북 SSF 스타일 정보:", workbook.SSF);
}
if (workbook.Workbook && workbook.Workbook.Styles) {
console.log("🔍 워크북 Styles:", workbook.Workbook.Styles);
}
// 워크북의 모든 키 확인
console.log("🔍 워크북 전체 키들:", Object.keys(workbook));
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
console.log("🎨 ❌ 시트가 없습니다.");
console.log("🎨 ❌ 워크북 전체 구조:", Object.keys(workbook));
return;
}
workbook.SheetNames.forEach((sheetName: string, sheetIndex: number) => {
const sheet = workbook.Sheets[sheetName];
if (!sheet) return;
console.log(`🎨 시트 ${sheetIndex + 1}: "${sheetName}"`);
// 시트 메타데이터
console.log(`🎨 - 데이터 범위: ${sheet["!ref"] || "없음"}`);
console.log(`🎨 - 병합 셀: ${sheet["!merges"]?.length || 0}`);
console.log(`🎨 - 열 설정: ${sheet["!cols"]?.length || 0}`);
console.log(`🎨 - 행 설정: ${sheet["!rows"]?.length || 0}`);
// 병합 셀 상세 정보
if (sheet["!merges"]) {
sheet["!merges"].forEach((merge: any, index: number) => {
console.log(
`🎨 - 병합 ${index + 1}: ${XLSX.utils.encode_cell(merge.s)}:${XLSX.utils.encode_cell(merge.e)}`,
);
});
}
// 스타일이 적용된 셀 찾기
const styledCells: string[] = [];
const cellAddresses = Object.keys(sheet).filter(
(key) => !key.startsWith("!"),
);
// 🔍 시트 데이터 존재 여부 확인
console.log(`🔍 ${sheetName} 기본 정보: ${cellAddresses.length}개 셀 발견`);
cellAddresses.forEach((cellAddr) => {
const cell = sheet[cellAddr];
if (cell && cell.s) {
styledCells.push(cellAddr);
// 🔍 첫 3개 셀의 실제 스타일 구조 확인
if (styledCells.length <= 3) {
console.log(`🔍 셀 ${cellAddr} cell.s 원시값:`, cell.s);
console.log(`🔍 cell.s 타입:`, typeof cell.s);
console.log(`🔍 cell.s 키들:`, Object.keys(cell.s || {}));
}
// 스타일 정보 간단 확인
const hasStyles = {
font: !!cell.s.font,
fill: !!cell.s.fill,
border: !!cell.s.border,
alignment: !!cell.s.alignment,
};
if (Object.values(hasStyles).some((v) => v)) {
console.log(`🎨 셀 ${cellAddr} 스타일:`, hasStyles);
} else if (styledCells.length <= 3) {
console.log(`❌ 셀 ${cellAddr} 스타일 없음:`, hasStyles);
}
}
});
console.log(
`🎨 - 스타일 적용된 셀: ${styledCells.length}개 (${styledCells.join(", ")})`,
);
});
console.log("🎨 =================================");
}
/**
* 브라우저에서 테스트 파일 다운로드
*/
export function downloadTestFile(): void {
try {
const buffer = createStyledTestExcel();
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "스타일테스트.xlsx";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log("🎨 스타일 테스트 파일 다운로드 완료!");
} catch (error) {
console.error("🎨 테스트 파일 생성 실패:", error);
}
}