diff --git a/.github/scripts/gen-summary.js b/.github/scripts/gen-summary.js new file mode 100644 index 0000000..9b25d80 --- /dev/null +++ b/.github/scripts/gen-summary.js @@ -0,0 +1,22 @@ +const fs = require('fs'); + +const data = JSON.parse(fs.readFileSync('jest-results.json', 'utf-8')); +const { numTotalTests, numPassedTests, numFailedTests, testResults } = data; + +let summary = `## ✅ Jest Test Summary\n`; +summary += `- Total: **${numTotalTests}**\n`; +summary += `- Passed: ✅ ${numPassedTests}\n`; +summary += `- Failed: ❌ ${numFailedTests}\n\n`; + +if (numFailedTests > 0) { + summary += `### ❌ Failed Tests\n`; + testResults.forEach(file => { + file.assertionResults + .filter(test => test.status === 'failed') + .forEach(test => { + summary += `- ${test.fullName} (${file.name})\n`; + }); + }); +} + +fs.writeFileSync('summary.md', summary); diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml new file mode 100644 index 0000000..a17df83 --- /dev/null +++ b/.github/workflows/pr-test.yml @@ -0,0 +1,41 @@ +name: Jest Test with Sticky Comment (Internal) + +on: + pull_request: + branches: [main] + +permissions: + pull-requests: write + issues: write + +jobs: + test: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + + steps: + - name: Checkout PR Code + uses: actions/checkout@v3 + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: Install dependencies + run: npm ci + + - name: Run Jest + run: npx jest --json --outputFile=jest-results.json + continue-on-error: true + + - name: Generate Markdown Summary + run: node .github/scripts/gen-summary.js > summary.md + + - name: Sticky PR Comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: jest-summary + path: summary.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 4cf757b..7553d92 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ yarn-error.log* next-env.d.ts # Test -test/ \ No newline at end of file +app/test/ +app/api/test \ No newline at end of file diff --git a/.replit b/.replit new file mode 100644 index 0000000..0d00d98 --- /dev/null +++ b/.replit @@ -0,0 +1,12 @@ +modules = ["nodejs-20", "web"] +run = "npm run dev" + +[nix] +channel = "stable-24_05" + +[deployment] +run = ["sh", "-c", "npm run dev"] + +[[ports]] +localPort = 3000 +externalPort = 80 diff --git a/.vscode/settings.json b/.vscode/settings.json index 293af73..9e46ab5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "[typescript]": { - "editor.defaultFormatter": "denoland.vscode-deno" + "editor.defaultFormatter": "vscode.typescript-language-features" }, "deno.enablePaths": [ "supabase/functions" diff --git a/__tests__/manager-tool/extract/EndX.test.tsx b/__tests__/manager-tool/extract/EndX.test.tsx new file mode 100644 index 0000000..b99284e --- /dev/null +++ b/__tests__/manager-tool/extract/EndX.test.tsx @@ -0,0 +1,216 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WordExtractorApp from "@/app/manager-tool/extract/endx/EndX"; +import { getOutsideHelpModal } from "@/test/utils/dom"; + +jest.mock("@/app/manager-tool/extract/components/FileContentDisplay", () => { + return ({ onFileUpload, fileContent, resultData, resultTitle }: any) => ( +
+
File Content: {fileContent || "No content"}
+
Result Title: {resultTitle}
+
Result Count: {resultData?.length || 0}
+ +
{fileContent}
+
+ ); +}); + +describe("EndX", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("초기 렌더링이 정상적으로 되는지 확인", () => { + render(); + + expect(screen.getByText("X로 끝나는 단어 추출")).toBeInTheDocument(); + expect(screen.getAllByText("설정")).toHaveLength(2); + expect(screen.getAllByText("실행")).toHaveLength(2); + }); + + it("끝글자 입력이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const wordEndInput = screen + .getAllByPlaceholderText("끝글자를 입력하세요") + .find((el) => !el.closest('[data-testid="help-modal"]')); + expect(wordEndInput).toBeDefined(); + await user.type(wordEndInput!, "다"); + + expect(wordEndInput).toHaveValue("다"); + }); + + it("정렬 체크박스가 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const checkboxes = screen.getAllByTestId("checkbox"); + const sortCheckbox = checkboxes.find( + (el) => !el.closest('[data-testid="help-modal"]'), + ); + + expect(sortCheckbox).toBeDefined(); // 혹시 못 찾을 때 대비 + expect(sortCheckbox).toBeChecked(); + + await user.click(sortCheckbox!); + expect(sortCheckbox).not.toBeChecked(); + }); + + it("파일 내용이 없을 때 단어 추출 버튼이 비활성화되는지 확인", () => { + render(); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).toBeDisabled(); + }); + + it("파일 업로드 후 단어 추출이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 끝글자 입력 + const wordEndInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("끝글자를 입력하세요"), + ); + await user.type(wordEndInput, "st"); + + // 단어 추출 실행 + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).not.toBeDisabled(); + + await user.click(extractButton); + + // 결과 확인 (test로 끝나는 단어가 추출되어야 함) + await waitFor(() => { + const resultDisplay = screen.getByTestId("file-content-display"); + expect(resultDisplay).toHaveTextContent("Result Count: 1"); + const resultWord = screen.getByTestId("result-word"); + expect(resultWord).toBeInTheDocument(); + expect(resultWord).toHaveTextContent("test"); + }); + }); + + it("단어 추출 결과에 따라 다운로드 버튼이 활성화되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).toBeDisabled(); + + // Mock 파일 업로드 및 단어 추출 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const wordEndInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("끝글자를 입력하세요"), + ); + await user.type(wordEndInput, "st"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + expect(downloadButton).not.toBeDisabled(); + }); + }); + + it("정렬 옵션이 제대로 적용되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // 정렬 해제 + const sortCheckbox = getOutsideHelpModal(() => + screen.getAllByTestId("checkbox"), + ); + await user.click(sortCheckbox); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const wordEndInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("끝글자를 입력하세요"), + ); + await user.type(wordEndInput, "st"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + // 정렬이 해제된 상태에서도 단어 추출이 동작해야 함 + await waitFor(() => { + expect(screen.getByText(/Result Count: 1/)).toBeInTheDocument(); + }); + }); + + it("다운로드 기능이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + + // URL.createObjectURL mock 설정 + const createObjectURLSpy = jest.spyOn(URL, "createObjectURL"); + + // createElement mock 설정 + const realCreateElement = document.createElement.bind(document); + + const mockLink = document.createElement("a"); + mockLink.click = jest.fn(); + + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation((tagName) => { + if (tagName === "a") return mockLink; + return realCreateElement(tagName); + }); + + render(); + + // 단어 추출까지 완료 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const wordEndInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("끝글자를 입력하세요"), + ); + await user.type(wordEndInput, "st"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).not.toBeDisabled(); + }); + + // 다운로드 실행 + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + await user.click(downloadButton); + + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + + createElementSpy.mockRestore(); + createObjectURLSpy.mockRestore(); + }); +}); diff --git a/__tests__/manager-tool/extract/EnglishMission.test.tsx b/__tests__/manager-tool/extract/EnglishMission.test.tsx new file mode 100644 index 0000000..a425ea9 --- /dev/null +++ b/__tests__/manager-tool/extract/EnglishMission.test.tsx @@ -0,0 +1,253 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WordExtractorApp from "@/app/manager-tool/extract/english-mission/EnglishMission"; +import { getOutsideHelpModal } from "@/test/utils/dom"; + +jest.mock("@/app/manager-tool/extract/components/FileContentDisplay", () => { + return ({ onFileUpload, fileContent, resultData, resultTitle }: any) => ( +
+
File Content: {fileContent || "No content"}
+
Result Title: {resultTitle}
+
+ Result Count: {resultData?.length || 0} +
+ +
{resultData}
+
+ ); +}); + +describe("EnglishMission", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("초기 렌더링이 정상적으로 되는지 확인", () => { + render(); + + expect(screen.getByText("영어 미션단어 추출")).toBeInTheDocument(); + expect(screen.getAllByText("설정")).toHaveLength(2); + expect(screen.getAllByText("실행")).toHaveLength(2); + }); + + it("최소 포함수 입력이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const minMissionInput = screen + .getAllByPlaceholderText("최소 포함수를 입력하세요") + .find((el) => !el.closest('[data-testid="help-modal"]')); + expect(minMissionInput).toBeDefined(); + await user.type(minMissionInput!, "2"); + + expect(minMissionInput).toHaveValue(2); + }); + + it("결과 정렬 체크박스가 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const checkboxes = screen.getAllByTestId("checkbox"); + const sortCheckbox = checkboxes.find( + (el) => !el.closest('[data-testid="help-modal"]'), + ); + + expect(sortCheckbox).toBeDefined(); + expect(sortCheckbox).toBeChecked(); + + await user.click(sortCheckbox!); + expect(sortCheckbox).not.toBeChecked(); + }); + + it("파일 내용이 없을 때 단어 추출 버튼이 비활성화되는지 확인", () => { + render(); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).toBeDisabled(); + }); + + it("파일 업로드 후 단어 추출이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 최소 포함수 설정 + const minMissionInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("최소 포함수를 입력하세요"), + ); + await user.clear(minMissionInput); + await user.type(minMissionInput, "1"); + + // 단어 추출 실행 + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).not.toBeDisabled(); + + await user.click(extractButton); + + // 결과 확인 (미션 글자가 1개 이상 포함된 단어가 추출되어야 함) + await waitFor(() => { + const resultCountDisplay = screen.getByTestId("result-count"); + expect(resultCountDisplay).toHaveTextContent("Result Count: 4"); + const resultDataDisplay = screen.getByTestId("result-word"); + expect(resultDataDisplay).toHaveTextContent("error [r:3 e:1 o:1]"); + }); + }); + + it("단어 추출 결과에 따라 다운로드 버튼이 활성화되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).toBeDisabled(); + + // Mock 파일 업로드 및 단어 추출 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const minMissionInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("최소 포함수를 입력하세요"), + ); + await user.clear(minMissionInput); + await user.type(minMissionInput, "1"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + expect(downloadButton).not.toBeDisabled(); + }); + }); + + it("정렬 옵션이 제대로 적용되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // 정렬 해제 + const sortCheckbox = getOutsideHelpModal(() => + screen.getAllByTestId("checkbox"), + ); + await user.click(sortCheckbox); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const minMissionInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("최소 포함수를 입력하세요"), + ); + await user.clear(minMissionInput); + await user.type(minMissionInput, "1"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + // 정렬이 해제된 상태에서도 단어 추출이 동작해야 함 + await waitFor(() => { + const resultCountDisplay = screen.getByTestId("result-count"); + expect(resultCountDisplay).toHaveTextContent("Result Count: 4"); + const resultDataDisplay = screen.getByTestId("result-word"); + expect(resultDataDisplay).toHaveTextContent("error [e:1 o:1 r:3]"); + }); + }); + + it("최소 포함수가 0 이하일 때 자동으로 양수로 설정되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const minMissionInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("최소 포함수를 입력하세요"), + ); + + await user.clear(minMissionInput); + await user.type(minMissionInput, "-5"); + + expect(minMissionInput).toHaveValue(5); + }); + + it("다운로드 기능이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + + // URL.createObjectURL mock 설정 + const createObjectURLSpy = jest.spyOn(URL, "createObjectURL"); + + // createElement mock 설정 + const realCreateElement = document.createElement.bind(document); + + const mockLink = document.createElement("a"); + mockLink.click = jest.fn(); + + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation((tagName) => { + if (tagName === "a") return mockLink; + return realCreateElement(tagName); + }); + + render(); + + // 단어 추출까지 완료 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const minMissionInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("최소 포함수를 입력하세요"), + ); + await user.clear(minMissionInput); + await user.type(minMissionInput, "1"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).not.toBeDisabled(); + }); + + // 다운로드 실행 + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + await user.click(downloadButton); + + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + + createElementSpy.mockRestore(); + createObjectURLSpy.mockRestore(); + }); + + it("파일의 총 단어 수가 정상적으로 표시되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 파일의 총 단어 수 표시 확인 (4개: error, computer, nano, emotionlessness) + expect(screen.getByText("4")).toBeInTheDocument(); + expect(screen.getByText("파일의 총 단어 수")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/manager-tool/extract/FileContentDisplay.test.tsx b/__tests__/manager-tool/extract/FileContentDisplay.test.tsx new file mode 100644 index 0000000..40c8e7c --- /dev/null +++ b/__tests__/manager-tool/extract/FileContentDisplay.test.tsx @@ -0,0 +1,185 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import FileContentDisplay from "@/app/manager-tool/extract/components/FileContentDisplay"; + +// Mock UI components +jest.mock("@/app/components/ui/card", () => ({ + Card: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + CardContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + CardHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + CardTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +jest.mock("@/app/components/ui/input", () => ({ + Input: (props: any) => , +})); + +jest.mock("@/app/components/ui/label", () => ({ + Label: ({ children, ...props }: any) => ( + + ), +})); + +jest.mock("@/app/components/ui/scroll-area", () => ({ + ScrollArea: ({ children, ...props }: any) => ( +
+ {children} +
+ ), +})); + +jest.mock("@/app/components/ui/badge", () => ({ + Badge: ({ children, ...props }: any) => ( + + {children} + + ), +})); + +jest.mock("@/app/components/ui/button", () => ({ + Button: ({ children, onClick, ...props }: any) => ( + + ), +})); + +describe("FileContentDisplay", () => { + const mockSetFileContent = jest.fn(); + const mockSetFile = jest.fn(); + const mockOnFileUpload = jest.fn(); + const mockOnError = jest.fn(); + + const defaultProps = { + setFileContent: mockSetFileContent, + setFile: mockSetFile, + file: null, + fileContent: null, + onFileUpload: mockOnFileUpload, + onError: mockOnError, + resultData: [], + resultTitle: "처리 결과", + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("렌더링이 정상적으로 되는지 확인", () => { + render(); + + expect(screen.getByText("파일 업로드")).toBeInTheDocument(); + expect(screen.getByText("업로드된 파일 내용")).toBeInTheDocument(); + expect(screen.getByText("처리 결과")).toBeInTheDocument(); + }); + + it("파일이 없을 때 플레이스홀더가 표시되는지 확인", () => { + render(); + + expect( + screen.getByText("아직 파일이 업로드되지 않았습니다."), + ).toBeInTheDocument(); + expect(screen.getByText("아직 처리 결과가 없습니다.")).toBeInTheDocument(); + }); + + it("파일 업로드 처리가 정상적으로 되는지 확인", async () => { + const user = userEvent.setup(); + const mockFile = new File(["test content"], "test.txt", { + type: "text/plain", + }); + + render(); + + const fileInput = screen.getByLabelText("텍스트 파일 선택"); + + await user.upload(fileInput, mockFile); + + await waitFor(() => { + expect(mockSetFile).toHaveBeenCalledWith(mockFile); + }); + }); + + it("파일이 업로드되면 파일 정보가 표시되는지 확인", () => { + const mockFile = new File(["test content"], "test.txt", { + type: "text/plain", + }); + + render(); + + expect(screen.getByText("test.txt")).toBeInTheDocument(); + expect(screen.getByText(/KB/)).toBeInTheDocument(); + }); + + it("파일 내용이 있을 때 정상적으로 표시되는지 확인", () => { + const testContent = "line1\nline2\nline3"; + + render(); + + expect(screen.getByText((c) => c.includes("line1"))).toBeInTheDocument(); + expect(screen.getByText((c) => c.includes("line2"))).toBeInTheDocument(); + expect(screen.getByText((c) => c.includes("line3"))).toBeInTheDocument(); + }); + + it("결과 데이터가 있을 때 정상적으로 표시되는지 확인", () => { + const resultData = ["result1", "result2", "result3"]; + + render(); + + expect(screen.getByText((c) => c.includes("result1"))).toBeInTheDocument(); + expect(screen.getByText((c) => c.includes("result2"))).toBeInTheDocument(); + expect(screen.getByText((c) => c.includes("result3"))).toBeInTheDocument(); + expect(screen.getByText("3개")).toBeInTheDocument(); + }); + + it("초기화 버튼이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + const mockFile = new File(["test content"], "test.txt", { + type: "text/plain", + }); + + render(); + + const resetButton = screen.getByText("초기화"); + await user.click(resetButton); + + expect(mockSetFile).toHaveBeenCalledWith(null); + expect(mockSetFileContent).toHaveBeenCalledWith(null); + }); + + it("검색 기능이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + const testContent = "apple\nbanana\napricot\ncherry"; + + render(); + + const searchInput = screen.getByPlaceholderText("내용 검색..."); + await user.type(searchInput, "ap"); + + // 검색 결과에 apple과 apricot만 표시되어야 함 + expect(screen.getByText("2줄")).toBeInTheDocument(); + }); + + it("대용량 파일에서 가상화 모드가 활성화되는지 확인", () => { + // 5000줄 이상의 대용량 텍스트 생성 + const largeContent = Array.from( + { length: 6000 }, + (_, i) => `line${i}`, + ).join("\n"); + + render(); + + expect( + screen.getByText("대용량 파일 - 가상화 모드 (6,000줄)"), + ).toBeInTheDocument(); + }); +}); diff --git a/__tests__/manager-tool/extract/KoreanMission.test.tsx b/__tests__/manager-tool/extract/KoreanMission.test.tsx new file mode 100644 index 0000000..4f82008 --- /dev/null +++ b/__tests__/manager-tool/extract/KoreanMission.test.tsx @@ -0,0 +1,260 @@ + +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WordExtractorApp from "@/app/manager-tool/extract/korean-mission/KoreanMission"; +import { getOutsideHelpModal } from "@/test/utils/dom"; + +jest.mock("@/app/manager-tool/extract/components/FileContentDisplay", () => { + return ({ onFileUpload, fileContent, resultData, resultTitle }: any) => ( +
+
File Content: {fileContent || "No content"}
+
Result Title: {resultTitle}
+
Result Count: {resultData?.length || 0}
+ +
{resultData}
+
+ ); +}); + +describe("KoreanMission", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("초기 렌더링이 정상적으로 되는지 확인", () => { + render(); + + expect(screen.getByText("한국어 미션단어 추출 - A")).toBeInTheDocument(); + expect(screen.getAllByText("설정")).toHaveLength(2); + expect(screen.getAllByText("실행")).toHaveLength(2); + }); + + it("1미 포함 체크박스가 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const checkboxes = screen.getAllByTestId("checkbox"); + const oneMissionCheckbox = checkboxes.find( + (el) => !el.closest('[data-testid="help-modal"]') && el.closest('[id="one-mission"]') + ); + + if (oneMissionCheckbox) { + expect(oneMissionCheckbox).not.toBeChecked(); + await user.click(oneMissionCheckbox); + expect(oneMissionCheckbox).toBeChecked(); + } + }); + + it("미션 글자 표시 체크박스가 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const checkboxes = screen.getAllByTestId("checkbox"); + const missionLetterCheckbox = checkboxes.find( + (el) => !el.closest('[data-testid="help-modal"]') && el.closest('[id="show-mletter"]') + ); + + if (missionLetterCheckbox) { + expect(missionLetterCheckbox).not.toBeChecked(); + await user.click(missionLetterCheckbox); + expect(missionLetterCheckbox).toBeChecked(); + } + }); + + it("정렬 모드 체크박스가 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const checkboxes = screen.getAllByTestId("checkbox"); + const sortCheckboxes = checkboxes.filter( + (el) => !el.closest('[data-testid="help-modal"]') && + (el.closest('[id="sort-미션글자 포함순"]') || el.closest('[id="sort-글자길이순"]') || el.closest('[id="sort-ㄱㄴㄷ순"]')) + ); + + // 정렬 모드 체크박스들이 존재하는지 확인 + expect(sortCheckboxes.length).toBeGreaterThan(0); + + // 첫 번째 정렬 모드 체크박스 테스트 + if (sortCheckboxes[0]) { + await user.click(sortCheckboxes[0]); + expect(sortCheckboxes[0]).toBeChecked(); + expect(screen.getByText(/1순위/)).toBeInTheDocument(); + } + }); + + it("파일 내용이 없을 때 단어 추출 버튼이 비활성화되는지 확인", () => { + render(); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).toBeDisabled(); + }); + + it("파일 업로드 후 단어 추출이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 정렬 모드 선택 (미션글자 포함순) + const checkboxes = screen.getAllByTestId("checkbox"); + const missionSortCheckbox = checkboxes.find( + (el) => !el.closest('[data-testid="help-modal"]') && el.closest('[id="sort-미션글자 포함순"]') + ); + + if (missionSortCheckbox) { + await user.click(missionSortCheckbox); + } + + // 단어 추출 실행 + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).not.toBeDisabled(); + + await user.click(extractButton); + + // 결과 확인 (미션 단어가 추출되어야 함) + await waitFor(() => { + const resultDisplay = screen.getByTestId("file-content-display"); + expect(resultDisplay).toHaveTextContent(/Result Count: [1-9]/); + }); + }); + + it("단어 추출 결과에 따라 다운로드 버튼이 활성화되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).toBeDisabled(); + + // Mock 파일 업로드 및 단어 추출 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 정렬 모드 선택 + const checkboxes = screen.getAllByTestId("checkbox"); + const missionSortCheckbox = checkboxes.find( + (el) => !el.closest('[data-testid="help-modal"]') && el.closest('[id="sort-미션글자 포함순"]') + ); + + if (missionSortCheckbox) { + await user.click(missionSortCheckbox); + } + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + expect(downloadButton).not.toBeDisabled(); + }); + }); + + it("1미 포함 옵션이 제대로 적용되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // 1미 포함 체크 + const checkboxes = screen.getAllByTestId("checkbox"); + const oneMissionCheckbox = checkboxes.find( + (el) => !el.closest('[data-testid="help-modal"]') && el.closest('[id="one-mission"]') + ); + + if (oneMissionCheckbox) { + await user.click(oneMissionCheckbox); + } + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 정렬 모드 선택 + const missionSortCheckbox = checkboxes.find( + (el) => !el.closest('[data-testid="help-modal"]') && el.closest('[id="sort-미션글자 포함순"]') + ); + + if (missionSortCheckbox) { + await user.click(missionSortCheckbox); + } + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + // 1미 포함 옵션이 적용된 상태에서도 단어 추출이 동작해야 함 + await waitFor(() => { + expect(screen.getByText(/Result Count: [1-9]/)).toBeInTheDocument(); + }); + }); + + it("다운로드 기능이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + + // URL.createObjectURL mock 설정 + const createObjectURLSpy = jest.spyOn(URL, "createObjectURL"); + + // createElement mock 설정 + const realCreateElement = document.createElement.bind(document); + + const mockLink = document.createElement("a"); + mockLink.click = jest.fn(); + + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation((tagName) => { + if (tagName === "a") return mockLink; + return realCreateElement(tagName); + }); + + render(); + + // 단어 추출까지 완료 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 정렬 모드 선택 + const checkboxes = screen.getAllByTestId("checkbox"); + const missionSortCheckbox = checkboxes.find( + (el) => !el.closest('[data-testid="help-modal"]') && el.closest('[id="sort-미션글자 포함순"]') + ); + + if (missionSortCheckbox) { + await user.click(missionSortCheckbox); + } + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).not.toBeDisabled(); + }); + + // 다운로드 실행 + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + await user.click(downloadButton); + + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + + createElementSpy.mockRestore(); + createObjectURLSpy.mockRestore(); + }); +}); diff --git a/__tests__/manager-tool/extract/KoreanMissionB.test.tsx b/__tests__/manager-tool/extract/KoreanMissionB.test.tsx new file mode 100644 index 0000000..5126b01 --- /dev/null +++ b/__tests__/manager-tool/extract/KoreanMissionB.test.tsx @@ -0,0 +1,197 @@ + +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WordExtractorApp from "@/app/manager-tool/extract/korean-mission-b/KoreanMissionB"; +import { getOutsideHelpModal } from "@/test/utils/dom"; + +jest.mock("@/app/manager-tool/extract/components/FileContentDisplay", () => { + return ({ onFileUpload, fileContent, resultData, resultTitle }: any) => ( +
+
File Content: {fileContent || "No content"}
+
Result Title: {resultTitle}
+
Result Count: {resultData?.length || 0}
+ +
{resultData}
+
+ ); +}); + +describe("KoreanMissionB", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("초기 렌더링이 정상적으로 되는지 확인", () => { + render(); + + expect(screen.getByText("한국어 미션 단어 추출 - B")).toBeInTheDocument(); + expect(screen.getByText("설정")).toBeInTheDocument(); + expect(screen.getAllByText("실행")).toHaveLength(2); + }); + + it("미션글자 표시 체크박스가 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const checkboxes = screen.getAllByTestId("checkbox"); + const missionLetterCheckbox = checkboxes.find( + (el) => !el.closest('[data-testid="help-modal"]'), + ); + + expect(missionLetterCheckbox).toBeDefined(); + expect(missionLetterCheckbox).not.toBeChecked(); + + await user.click(missionLetterCheckbox!); + expect(missionLetterCheckbox).toBeChecked(); + }); + + it("파일 내용이 없을 때 단어 추출 버튼이 비활성화되는지 확인", () => { + render(); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).toBeDisabled(); + }); + + it("파일 업로드 후 단어 추출이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 단어 추출 실행 + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).not.toBeDisabled(); + + await user.click(extractButton); + + // 결과 확인 (1티어 미션단어가 추출되어야 함) + await waitFor(() => { + const resultWord = screen.getByTestId("result-word"); + expect(resultWord).toBeInTheDocument(); + expect(resultWord).toHaveTextContent(/기가막힌/) + }); + }); + + it("단어 추출 결과에 따라 다운로드 버튼이 활성화되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).toBeDisabled(); + + // Mock 파일 업로드 및 단어 추출 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + expect(downloadButton).not.toBeDisabled(); + }); + }); + + it("미션글자 표시 옵션이 제대로 적용되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // 미션글자 표시 체크 + const missionLetterCheckbox = getOutsideHelpModal(() => + screen.getAllByTestId("checkbox"), + ); + await user.click(missionLetterCheckbox); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + // 미션글자 표시 옵션이 활성화된 상태에서도 단어 추출이 동작해야 함 + await waitFor(() => { + const resultWord = screen.getByTestId("result-word"); + expect(resultWord).toBeInTheDocument(); + expect(resultWord).toHaveTextContent(/[가1]/) + }); + }); + + it("다운로드 기능이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + + // URL.createObjectURL mock 설정 + const createObjectURLSpy = jest.spyOn(URL, "createObjectURL"); + + // createElement mock 설정 + const realCreateElement = document.createElement.bind(document); + + const mockLink = document.createElement("a"); + mockLink.click = jest.fn(); + + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation((tagName) => { + if (tagName === "a") return mockLink; + return realCreateElement(tagName); + }); + + render(); + + // 단어 추출까지 완료 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).not.toBeDisabled(); + }); + + // 다운로드 실행 + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + await user.click(downloadButton); + + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + + createElementSpy.mockRestore(); + createObjectURLSpy.mockRestore(); + }); + + it("파일 내용이 있을 때 파일 단어 수가 표시되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 파일 단어 수가 표시되어야 함 + await waitFor(() => { + expect(screen.getByText("파일의 총 단어 수")).toBeInTheDocument(); + }); + }); +}); diff --git a/__tests__/manager-tool/extract/LenX.test.tsx b/__tests__/manager-tool/extract/LenX.test.tsx new file mode 100644 index 0000000..1acf5a3 --- /dev/null +++ b/__tests__/manager-tool/extract/LenX.test.tsx @@ -0,0 +1,227 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WordExtractorApp from "@/app/manager-tool/extract/lenx/LenX"; +import { getOutsideHelpModal } from "@/test/utils/dom"; + +// Mocking components used in WordExtractorApp +jest.mock("@/app/manager-tool/extract/components/FileContentDisplay", () => { + return ({ onFileUpload, fileContent, resultData, resultTitle }: any) => ( +
+
File Content: {fileContent || "No content"}
+
Result Title: {resultTitle}
+
+ Result Count: {resultData?.length || 0} +
+ +
{fileContent}
+
+ ); +}); + +describe("LenX", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("초기 렌더링이 정상적으로 되는지 확인", () => { + render(); + + expect(screen.getByText("X 글자수 단어 추출")).toBeInTheDocument(); + expect(screen.getAllByText("설정")).toHaveLength(2); + expect(screen.getAllByText("실행")).toHaveLength(2); + }); + + it("글자길이 입력이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const wordLengthInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("단어 길이 수를 입력하세요"), + ); + expect(wordLengthInput).toBeDefined(); + + await user.clear(wordLengthInput!); + await user.type(wordLengthInput!, "4"); + + expect(wordLengthInput).toHaveValue(4); + }); + + it("정렬 체크박스가 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const sortCheckbox = getOutsideHelpModal(() => + screen.getAllByTestId("checkbox"), + ); + + expect(sortCheckbox).toBeDefined(); // 혹시 못 찾을 때 대비 + expect(sortCheckbox).toBeChecked(); + + await user.click(sortCheckbox!); + expect(sortCheckbox).not.toBeChecked(); + }); + + it("파일 내용이 없을 때 단어 추출 버튼이 비활성화되는지 확인", () => { + render(); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).toBeDisabled(); + }); + + it("파일 업로드 후 단어 추출이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 글자길이 입력 + const wordLengthInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("단어 길이 수를 입력하세요"), + ); + + await user.clear(wordLengthInput!); + await user.type(wordLengthInput, "4"); + + // 단어 추출 실행 + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).not.toBeDisabled(); + + await user.click(extractButton); + + // 결과 확인 (test로 끝나는 단어가 추출되어야 함) + await waitFor(() => { + const resultDisplay = screen.getByTestId("result-count"); + expect(resultDisplay).toHaveTextContent("Result Count: 2"); + const resultWord = screen.getByTestId("result-word"); + expect(resultWord).toBeInTheDocument(); + expect(resultWord).toHaveTextContent("test"); + expect(resultWord).toHaveTextContent("data"); + }); + }); + + it("단어 추출 결과에 따라 다운로드 버튼이 활성화되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).toBeDisabled(); + + // Mock 파일 업로드 및 단어 추출 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const wordLengthInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("단어 길이 수를 입력하세요"), + ); + await user.clear(wordLengthInput!); + await user.type(wordLengthInput, "4"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + expect(downloadButton).not.toBeDisabled(); + }); + }); + + it("정렬 옵션이 제대로 적용되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // 정렬 해제 + const sortCheckbox = getOutsideHelpModal(() => + screen.getAllByTestId("checkbox"), + ); + await user.click(sortCheckbox); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const wordLengthInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("단어 길이 수를 입력하세요"), + ); + await user.clear(wordLengthInput!); + await user.type(wordLengthInput, "4"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + // 정렬이 해제된 상태에서도 단어 추출이 동작해야 함 + await waitFor(() => { + const resultDisplay = screen.getByTestId("result-count"); + expect(resultDisplay).toHaveTextContent("Result Count: 2"); + }); + }); + + it("다운로드 기능이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + + // URL.createObjectURL mock 설정 + const createObjectURLSpy = jest.spyOn(URL, "createObjectURL"); + + // createElement mock 설정 + const realCreateElement = document.createElement.bind(document); + + const mockLink = document.createElement("a"); + mockLink.click = jest.fn(); + + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation((tagName) => { + if (tagName === "a") return mockLink; + return realCreateElement(tagName); + }); + + render(); + + // 단어 추출까지 완료 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const wordLengthInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("단어 길이 수를 입력하세요"), + ); + await user.clear(wordLengthInput!); + await user.type(wordLengthInput, "4"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).not.toBeDisabled(); + }); + + // 다운로드 실행 + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + await user.click(downloadButton); + + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + + createElementSpy.mockRestore(); + createObjectURLSpy.mockRestore(); + }); +}); diff --git a/__tests__/manager-tool/extract/Loop.test.tsx b/__tests__/manager-tool/extract/Loop.test.tsx new file mode 100644 index 0000000..1c4cd89 --- /dev/null +++ b/__tests__/manager-tool/extract/Loop.test.tsx @@ -0,0 +1,413 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import LoopWordExtractorApp from "@/app/manager-tool/extract/loop/Loop"; +import { getOutsideHelpModal } from "@/test/utils/dom"; + +// FileContentDisplay 컴포넌트 모킹 +jest.mock("@/app/manager-tool/extract/components/FileContentDisplay", () => { + return ({ onFileUpload, fileContent, resultData, resultTitle }: any) => ( +
+
File Content: {fileContent || "No content"}
+
Result Title: {resultTitle}
+
Result Count: {resultData?.length || 0}
+ +
{resultData?.join(" ")}
+
+ ); +}); + +// DuemLaw 함수 모킹 +jest.mock("@/app/lib/DuemLaw", () => { + return jest.fn((char: string) => { + // 두음법칙 매핑 + const duemMap: { [key: string]: string } = { + 라: "나", + }; + return duemMap[char] || char; + }); +}); + +describe("Loop", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("초기 렌더링이 정상적으로 되는지 확인", () => { + render(); + + expect(screen.getAllByText("설정")).toHaveLength(2); + expect(screen.getAllByText("실행")).toHaveLength(2); + expect(screen.getByText("추출 모드")).toBeInTheDocument(); + }); + + it("돌림글자 입력이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const loopLetterInput = screen + .getAllByPlaceholderText("돌림글자를 입력하세요") + .find((el) => !el.closest('[data-testid="help-modal"]')); + expect(loopLetterInput).toBeDefined(); + await user.type(loopLetterInput!, "라"); + + expect(loopLetterInput).toHaveValue("라"); + }); + + it("정렬 체크박스가 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const checkboxes = screen + .getAllByRole("checkbox") + .filter((el) => !el.closest('[data-testid="help-modal"]')); + const sortCheckbox = checkboxes[0]; // 첫 번째 체크박스가 정렬 옵션 + + expect(sortCheckbox).toBeDefined(); + expect(sortCheckbox).toBeChecked(); + + await user.click(sortCheckbox); + expect(sortCheckbox).not.toBeChecked(); + }); + + it("추출 모드 선택이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const mode1Radio = screen.getByTestId('test-mode1'); + const mode2Radio = screen.getByTestId('test-mode2'); + const mode3Radio = screen.getByTestId('test-mode3'); + const mode4Radio = screen.getByTestId('test-mode4'); + + // 초기에는 아무것도 선택되지 않음 + expect(mode1Radio).not.toBeChecked(); + expect(mode2Radio).not.toBeChecked(); + expect(mode3Radio).not.toBeChecked(); + expect(mode4Radio).not.toBeChecked(); + + // 모드 1 선택 + await user.click(mode1Radio); + expect(mode1Radio).toBeChecked(); + expect(mode2Radio).not.toBeChecked(); + + // 모드 2로 변경 + await user.click(mode2Radio); + expect(mode1Radio).not.toBeChecked(); + expect(mode2Radio).toBeChecked(); + }); + + it("파일 내용이 없을 때 돌림단어 추출 버튼이 비활성화되는지 확인", () => { + render(); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: "돌림단어 추출" }), + ); + expect(extractButton).toBeDisabled(); + }); + + it("필수 조건이 충족되지 않으면 돌림단어 추출 버튼이 비활성화되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // 파일만 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: "돌림단어 추출" }), + ); + expect(extractButton).toBeDisabled(); // 돌림글자와 모드가 없으므로 비활성화 + // 돌림글자 입력 + const loopLetterInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("돌림글자를 입력하세요"), + ); + await user.type(loopLetterInput, "라"); + + expect(extractButton).toBeDisabled(); // 모드가 없으므로 여전히 비활성화 + + // 모드 선택 + const mode1Radio = screen.getByTestId('test-mode1'); + await user.click(mode1Radio); + + expect(extractButton).not.toBeDisabled(); // 모든 조건 충족 + }); + + it("모드 1 돌림단어 추출이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // Mock 파일 업로드 (라메디아노체루나라, 나라, 라미아벨라, 나를인간이라고부르지말라, 라그나로스님의힘이느껴지는구나, 라바하운드와불타오르는아레나, 나가르주나) + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 돌림글자 입력 + const loopLetterInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("돌림글자를 입력하세요"), + ); + await user.type(loopLetterInput, "라"); + + // 모드 1 선택 (시작=끝) + const mode1Radio = screen.getByTestId('test-mode1'); + await user.click(mode1Radio); + + // 돌림단어 추출 실행 + const extractButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: "돌림단어 추출" }), + ); + await user.click(extractButton); + + // 결과 확인 + await waitFor(() => { + const resultDisplay = screen.getByTestId("result-count"); + expect(resultDisplay).toHaveTextContent("Result Count: 2"); + const resultWord = screen.getByTestId("result-word"); + expect(resultWord).toHaveTextContent("라메디아노체루나라"); + expect(resultWord).toHaveTextContent("라미아벨라"); + }); + }); + + it("모드 2 돌림단어 추출이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 돌림글자 입력 + const loopLetterInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("돌림글자를 입력하세요"), + ); + await user.type(loopLetterInput, "라"); + + // 모드 2 선택 (시작(두음법칙)=끝) + const mode2Radio = screen.getByTestId('test-mode2'); + await user.click(mode2Radio); + + // 돌림단어 추출 실행 + const extractButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: "돌림단어 추출" }), + ); + await user.click(extractButton); + + // 결과 확인 (라메디아노체루나라, 나라, 라미아벨라, 나를인간이라고부르지말라) + await waitFor(() => { + const resultDisplay = screen.getByTestId("file-content-display"); + expect(resultDisplay).toHaveTextContent("Result Count: 4"); + const resultWord = screen.getByTestId("result-word"); + expect(resultWord).toHaveTextContent("라메디아노체루나라"); + expect(resultWord).toHaveTextContent("라미아벨라"); + expect(resultWord).toHaveTextContent("나라"); + expect(resultWord).toHaveTextContent("나를인간이라고부르지말라"); + }); + }); + + it("모드 3 돌림단어 추출이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 돌림글자 입력 + const loopLetterInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("돌림글자를 입력하세요"), + ); + await user.type(loopLetterInput, "라"); + + // 모드 3 선택 + const mode3Radio = screen.getByTestId('test-mode3'); + await user.click(mode3Radio); + + // 돌림단어 추출 실행 + const extractButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: "돌림단어 추출" }), + ); + await user.click(extractButton); + + // 결과 확인 (라메디아노체루나라, 라미아벨라, 라그나로스님의힘이느껴지는구나, 라바하운드와불타오르는아레나) + await waitFor(() => { + const resultDisplay = screen.getByTestId("file-content-display"); + expect(resultDisplay).toHaveTextContent("Result Count: 4"); + const resultWord = screen.getByTestId("result-word"); + expect(resultWord).toHaveTextContent("라메디아노체루나라"); + expect(resultWord).toHaveTextContent("라미아벨라"); + expect(resultWord).toHaveTextContent("라그나로스님의힘이느껴지는구나"); + expect(resultWord).toHaveTextContent("라바하운드와불타오르는아레나"); + }); + }); + + it("모드 4 돌림단어 추출이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 돌림글자 입력 + const loopLetterInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("돌림글자를 입력하세요"), + ); + await user.type(loopLetterInput, "라"); + + // 모드 4 선택 + const mode4Radio = screen.getByTestId('test-mode4'); + await user.click(mode4Radio); + + // 돌림단어 추출 실행 + const extractButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: "돌림단어 추출" }), + ); + await user.click(extractButton); + + // 결과 확인 (라메디아노체루나라, 나라, 라미아벨라, 나를인간이라고부르지말라, 라그나로스님의힘이느껴지는구나, 라바하운드와불타오르는아레나, 나가르주나) + await waitFor(() => { + const resultDisplay = screen.getByTestId("file-content-display"); + expect(resultDisplay).toHaveTextContent("Result Count: 7"); + const resultWord = screen.getByTestId("result-word"); + expect(resultWord).toHaveTextContent("라메디아노체루나라"); + expect(resultWord).toHaveTextContent("라미아벨라"); + expect(resultWord).toHaveTextContent("나라"); + expect(resultWord).toHaveTextContent("나를인간이라고부르지말라"); + expect(resultWord).toHaveTextContent("라그나로스님의힘이느껴지는구나"); + expect(resultWord).toHaveTextContent("라바하운드와불타오르는아레나"); + expect(resultWord).toHaveTextContent("나가르주나"); + }); + }); + + it("다운로드 버튼이 추출 결과에 따라 활성화되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).toBeDisabled(); + + // Mock 파일 업로드 및 단어 추출 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const loopLetterInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("돌림글자를 입력하세요"), + ); + await user.type(loopLetterInput, "라"); + + const mode1Radio = screen.getByTestId('test-mode1'); + await user.click(mode1Radio); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: "돌림단어 추출" }), + ); + await user.click(extractButton); + + await waitFor(() => { + expect(downloadButton).not.toBeDisabled(); + }); + }); + + it("정렬 옵션이 제대로 적용되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // 정렬 해제 + const sortCheckbox = screen + .getAllByRole("checkbox") + .filter((el) => !el.closest('[data-testid="help-modal"]'))[0]; + await user.click(sortCheckbox); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const loopLetterInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("돌림글자를 입력하세요"), + ); + await user.type(loopLetterInput, "라"); + + const mode1Radio = screen.getByTestId('test-mode1'); + await user.click(mode1Radio); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: "돌림단어 추출" }), + ); + await user.click(extractButton); + + // 정렬이 해제된 상태에서도 단어 추출이 동작해야 함 // 라미아벨라 라메디아노체루나라 + await waitFor(() => { + const resultDisplay = screen.getByTestId("file-content-display"); + expect(resultDisplay).toHaveTextContent("Result Count: 2"); + const resultWord = screen.getByTestId("result-word"); + expect(resultWord).toHaveTextContent("라미아벨라 라메디아노체루나라"); + }); + }); + + it("다운로드 기능이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + + // URL.createObjectURL mock 설정 + const createObjectURLSpy = jest.spyOn(URL, "createObjectURL"); + + // createElement mock 설정 + const realCreateElement = document.createElement.bind(document); + const mockLink = document.createElement("a"); + mockLink.click = jest.fn(); + + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation((tagName) => { + if (tagName === "a") return mockLink; + return realCreateElement(tagName); + }); + + render(); + + // 돌림단어 추출까지 완료 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const loopLetterInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("돌림글자를 입력하세요"), + ); + await user.type(loopLetterInput, "라"); + + const mode1Radio = screen.getByTestId('test-mode1'); + await user.click(mode1Radio); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: "돌림단어 추출" }), + ); + await user.click(extractButton); + + await waitFor(() => { + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).not.toBeDisabled(); + }); + + // 다운로드 실행 + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + await user.click(downloadButton); + + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + + createElementSpy.mockRestore(); + createObjectURLSpy.mockRestore(); + }); + + +}); diff --git a/__tests__/manager-tool/extract/Merge.test.tsx b/__tests__/manager-tool/extract/Merge.test.tsx new file mode 100644 index 0000000..034b7bc --- /dev/null +++ b/__tests__/manager-tool/extract/Merge.test.tsx @@ -0,0 +1,350 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WordExtractorApp from "@/app/manager-tool/extract/merge/Merge"; +import { getOutsideHelpModal } from "@/test/utils/dom"; +import { act } from "react"; + +describe("Merge", () => { + beforeEach(() => { + jest.clearAllMocks(); + global.FileReader = class { + onload: ((event: any) => void) | null = null; + onerror: ((event: any) => void) | null = null; + result: string | null = null; + + readAsText(file: File) { + setTimeout(() => { + if (file.name === "test1.txt") { + this.result = "zebra\napple"; + } else if (file.name === "test2.txt") { + this.result = "banana\napple"; + } else { + this.result = "unknown"; + } + + this.onload?.({ target: { result: this.result } }); + }, 0); + } + } as any; + }); + + it("초기 렌더링이 정상적으로 되는지 확인", () => { + render(); + + expect(screen.getByText("텍스트 파일 합성")).toBeInTheDocument(); + expect(screen.getByText("첫 번째 텍스트 파일")).toBeInTheDocument(); + expect(screen.getByText("두 번째 텍스트 파일")).toBeInTheDocument(); + expect(screen.getByText("설정")).toBeInTheDocument(); + expect(screen.getAllByText("실행")).toHaveLength(2); + }); + + it("정렬 체크박스가 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const sortCheckbox = screen.getByRole("checkbox", { name: /결과 정렬/i }); + + expect(sortCheckbox).toBeChecked(); + + await user.click(sortCheckbox); + expect(sortCheckbox).not.toBeChecked(); + + await user.click(sortCheckbox); + expect(sortCheckbox).toBeChecked(); + }); + + it("파일이 업로드되지 않았을 때 병합 버튼이 비활성화되는지 확인", () => { + render(); + + const mergeButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: /파일 병합/i }), + ); + expect(mergeButton).toBeDisabled(); + }); + + it("첫 번째 파일만 업로드되었을 때 병합 버튼이 비활성화되는지 확인", async () => { + const user = userEvent.setup(); + await act(() => render()); + + const file1Input = screen.getByLabelText("첫 번째 텍스트 파일"); + const testFile = new File(["test\ncontent\ndata"], "test1.txt", { + type: "text/plain", + }); + + await user.upload(file1Input, testFile); + + await waitFor(() => { + const mergeButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: /파일 병합/i }), + ); + expect(mergeButton).toBeDisabled(); + }); + }); + + it("두 파일이 모두 업로드되면 병합 버튼이 활성화되는지 확인", async () => { + const user = userEvent.setup(); + await act(() => render()); + + const file1Input = screen.getByLabelText("첫 번째 텍스트 파일"); + const file2Input = screen.getByLabelText("두 번째 텍스트 파일"); + + const testFile1 = new File(["test\ncontent"], "test1.txt", { + type: "text/plain", + }); + const testFile2 = new File(["data\ntest"], "test2.txt", { + type: "text/plain", + }); + + await user.upload(file1Input, testFile1); + await waitFor(async () => await user.upload(file2Input, testFile2)); + + await waitFor(() => { + const file1Display = screen.getByTestId("file-content-1"); + const file2Display = screen.getByTestId("file-content-2"); + expect(file1Display).toHaveTextContent("zebra apple"); + expect(file2Display).toHaveTextContent("banana apple"); + const mergeButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: /파일 병합/i }), + ); + expect(mergeButton).not.toBeDisabled(); + }); + }); + + it("파일 병합이 정상적으로 동작하는지 확인 (정렬 활성화)", async () => { + const user = userEvent.setup(); + await act(()=>render()) + + const file1Input = screen.getByLabelText("첫 번째 텍스트 파일"); + const file2Input = screen.getByLabelText("두 번째 텍스트 파일"); + + const testFile1 = new File(["zebra\napple"], "test1.txt", { + type: "text/plain", + }); + const testFile2 = new File(["banana\napple"], "test2.txt", { + type: "text/plain", + }); + + await user.upload(file1Input, testFile1); + await waitFor(async () => await user.upload(file2Input, testFile2)); + + await waitFor(() => { + const mergeButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: /파일 병합/i }), + ); + expect(mergeButton).not.toBeDisabled(); + }); + + const mergeButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: /파일 병합/i }), + ); + await user.click(mergeButton); + + // 병합 결과 확인 (중복 제거 및 정렬) + await waitFor(() => { + const downloadButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: /병합된 파일 다운로드/i }), + ); + expect(downloadButton).not.toBeDisabled(); + const resultDisplay = screen.getByTestId("merged-content"); + expect(resultDisplay).toHaveTextContent("apple"); + expect(resultDisplay).toHaveTextContent("banana"); + expect(resultDisplay).toHaveTextContent("zebra"); + expect(resultDisplay.textContent).toBe("apple\nbanana\nzebra"); + }); + }); + + it("파일 병합이 정상적으로 동작하는지 확인 (정렬 비활성화)", async () => { + const user = userEvent.setup(); + await act(()=>render()); + + // 정렬 비활성화 + const sortCheckbox = getOutsideHelpModal(() => + screen.getAllByRole("checkbox", { name: /결과 정렬/i }), + ); + await user.click(sortCheckbox); + + const file1Input = screen.getByLabelText("첫 번째 텍스트 파일"); + const file2Input = screen.getByLabelText("두 번째 텍스트 파일"); + + const testFile1 = new File(["zebra\napple"], "test1.txt", { + type: "text/plain", + }); + const testFile2 = new File(["banana\napple"], "test2.txt", { + type: "text/plain", + }); + + await user.upload(file1Input, testFile1); + await waitFor(async () => await user.upload(file2Input, testFile2)); + + await waitFor(() => { + const mergeButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: /파일 병합/i }), + ); + expect(mergeButton).not.toBeDisabled(); + }); + + const mergeButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: /파일 병합/i }), + ); + await user.click(mergeButton); + + await waitFor(() => { + const downloadButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: /병합된 파일 다운로드/i }), + ); + expect(downloadButton).not.toBeDisabled(); + const resultDisplay = screen.getByTestId("merged-content"); + expect(resultDisplay).toHaveTextContent("apple"); + expect(resultDisplay).toHaveTextContent("banana"); + expect(resultDisplay).toHaveTextContent("zebra"); + }); + }); + + it("병합 결과가 없을 때 다운로드 버튼이 비활성화되는지 확인", () => { + render(); + + const downloadButton = screen.getByRole("button", { + name: /병합된 파일 다운로드/i, + }); + expect(downloadButton).toBeDisabled(); + }); + + it("다운로드 기능이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + + // URL.createObjectURL mock 설정 + const createObjectURLSpy = jest.spyOn(URL, "createObjectURL"); + const revokeObjectURLSpy = jest.spyOn(URL, "revokeObjectURL"); + + // createElement mock 설정 + const realCreateElement = document.createElement.bind(document); + + const mockLink = document.createElement("a"); + mockLink.click = jest.fn(); + + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation((tagName) => { + if (tagName === "a") return mockLink; + return realCreateElement(tagName); + }); + + await act(()=>render()); + + // 파일 업로드 및 병합까지 완료 + const file1Input = screen.getByLabelText("첫 번째 텍스트 파일"); + const file2Input = screen.getByLabelText("두 번째 텍스트 파일"); + + const testFile1 = new File(["test\ncontent"], "test1.txt", { + type: "text/plain", + }); + const testFile2 = new File(["data\ntest"], "test2.txt", { + type: "text/plain", + }); + + await user.upload(file1Input, testFile1); + await waitFor(async () => await user.upload(file2Input, testFile2)); + + const mergeButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: /파일 병합/i }), + ); + await user.click(mergeButton); + + await waitFor(() => { + const downloadButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: /병합된 파일 다운로드/i }), + ); + expect(downloadButton).not.toBeDisabled(); + }); + + // 다운로드 실행 + const downloadButton = getOutsideHelpModal(() => + screen.getAllByRole("button", { name: /병합된 파일 다운로드/i }), + ); + await user.click(downloadButton); + + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + expect(mockLink.download).toBe("merged_file.txt"); + expect(revokeObjectURLSpy).toHaveBeenCalled(); + + createElementSpy.mockRestore(); + createObjectURLSpy.mockRestore(); + revokeObjectURLSpy.mockRestore(); + }); + + it("첫 번째 파일 초기화가 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + await act(()=>render()); + + const file1Input = screen.getByLabelText("첫 번째 텍스트 파일"); + const testFile = new File(["test content"], "test1.txt", { + type: "text/plain", + }); + + await user.upload(file1Input, testFile); + + // 파일이 업로드되었는지 확인 + await waitFor(() => { + expect(screen.getByText("test1.txt")).toBeInTheDocument(); + }); + + // 초기화 버튼 클릭 + const resetButtons = screen.getAllByText("초기화"); + await user.click(resetButtons[0]); // 첫 번째 파일의 초기화 버튼 + + // 파일이 초기화되었는지 확인 + expect(screen.queryByText("test1.txt")).not.toBeInTheDocument(); + }); + + it("모든 파일 초기화가 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + await act(()=>render()); + + const file1Input = screen.getByLabelText("첫 번째 텍스트 파일"); + const file2Input = screen.getByLabelText("두 번째 텍스트 파일"); + + const testFile1 = new File(["test content 1"], "test1.txt", { + type: "text/plain", + }); + const testFile2 = new File(["test content 2"], "test2.txt", { + type: "text/plain", + }); + + await user.upload(file1Input, testFile1); + await waitFor(async () => await user.upload(file2Input, testFile2)); + + // 파일들이 업로드되었는지 확인 + await waitFor(() => { + expect(screen.getByText("test1.txt")).toBeInTheDocument(); + expect(screen.getByText("test2.txt")).toBeInTheDocument(); + }); + + // 모든 파일 초기화 버튼 클릭 + const resetAllButton = screen.getByText("모든 파일 초기화"); + await user.click(resetAllButton); + + // 모든 파일이 초기화되었는지 확인 + expect(screen.queryByText("test1.txt")).not.toBeInTheDocument(); + expect(screen.queryByText("test2.txt")).not.toBeInTheDocument(); + }); + + it("파일 크기 정보가 올바르게 표시되는지 확인", async () => { + const user = userEvent.setup(); + await act(()=>render()); + + const file1Input = screen.getByLabelText("첫 번째 텍스트 파일"); + const testFile = new File(["test content for size check"], "test1.txt", { + type: "text/plain", + }); + + await user.upload(file1Input, testFile); + + await waitFor(() => { + // 파일 크기가 KB 단위로 표시되는지 확인 + const sizeElement = screen.getByText(/KB/); + expect(sizeElement).toBeInTheDocument(); + }); + }); +}); diff --git a/__tests__/manager-tool/extract/StartX.test.tsx b/__tests__/manager-tool/extract/StartX.test.tsx new file mode 100644 index 0000000..d4191f9 --- /dev/null +++ b/__tests__/manager-tool/extract/StartX.test.tsx @@ -0,0 +1,215 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WordExtractorApp from "@/app/manager-tool/extract/startx/StartX"; +import { getOutsideHelpModal } from "@/test/utils/dom"; + +jest.mock("@/app/manager-tool/extract/components/FileContentDisplay", () => { + return ({ onFileUpload, fileContent, resultData, resultTitle }: any) => ( +
+
File Content: {fileContent || "No content"}
+
Result Title: {resultTitle}
+
Result Count: {resultData?.length || 0}
+ +
{fileContent}
+
+ ); +}); + +describe("StartX", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("초기 렌더링이 정상적으로 되는지 확인", () => { + render(); + + expect(screen.getByText("X로 시작하는 단어 추출")).toBeInTheDocument(); + expect(screen.getAllByText("설정")).toHaveLength(2); + expect(screen.getAllByText("실행")).toHaveLength(2); + }); + + it("끝글자 입력이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const wordStartInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("시작글자를 입력하세요"), + ); + expect(wordStartInput).toBeDefined(); + await user.type(wordStartInput!, "다"); + + expect(wordStartInput).toHaveValue("다"); + }); + + it("정렬 체크박스가 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const sortCheckbox = getOutsideHelpModal(() => + screen.getAllByTestId("checkbox"), + ); + + expect(sortCheckbox).toBeDefined(); // 혹시 못 찾을 때 대비 + expect(sortCheckbox).toBeChecked(); + + await user.click(sortCheckbox!); + expect(sortCheckbox).not.toBeChecked(); + }); + + it("파일 내용이 없을 때 단어 추출 버튼이 비활성화되는지 확인", () => { + render(); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).toBeDisabled(); + }); + + it("파일 업로드 후 단어 추출이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + // 끝글자 입력 + const wordStartInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("시작글자를 입력하세요"), + ); + await user.type(wordStartInput, "te"); + + // 단어 추출 실행 + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).not.toBeDisabled(); + + await user.click(extractButton); + + // 결과 확인 (test로 끝나는 단어가 추출되어야 함) + await waitFor(() => { + const resultDisplay = screen.getByTestId("file-content-display"); + expect(resultDisplay).toHaveTextContent("Result Count: 1"); + const resultWord = screen.getByTestId("result-word"); + expect(resultWord).toBeInTheDocument(); + expect(resultWord).toHaveTextContent("test"); + }); + }); + + it("단어 추출 결과에 따라 다운로드 버튼이 활성화되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).toBeDisabled(); + + // Mock 파일 업로드 및 단어 추출 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const wordStartInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("시작글자를 입력하세요"), + ); + await user.type(wordStartInput, "te"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + expect(downloadButton).not.toBeDisabled(); + }); + }); + + it("정렬 옵션이 제대로 적용되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + // 정렬 해제 + const sortCheckbox = getOutsideHelpModal(() => + screen.getAllByTestId("checkbox"), + ); + await user.click(sortCheckbox); + + // Mock 파일 업로드 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const wordStartInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("시작글자를 입력하세요"), + ); + await user.type(wordStartInput, "te"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + // 정렬이 해제된 상태에서도 단어 추출이 동작해야 함 + await waitFor(() => { + expect(screen.getByText(/Result Count: 1/)).toBeInTheDocument(); + }); + }); + + it("다운로드 기능이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + + // URL.createObjectURL mock 설정 + const createObjectURLSpy = jest.spyOn(URL, "createObjectURL"); + + // createElement mock 설정 + const realCreateElement = document.createElement.bind(document); + + const mockLink = document.createElement("a"); + mockLink.click = jest.fn(); + + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation((tagName) => { + if (tagName === "a") return mockLink; + return realCreateElement(tagName); + }); + + render(); + + // 단어 추출까지 완료 + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const wordStartInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("시작글자를 입력하세요"), + ); + await user.type(wordStartInput, "te"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).not.toBeDisabled(); + }); + + // 다운로드 실행 + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + await user.click(downloadButton); + + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + + createElementSpy.mockRestore(); + createObjectURLSpy.mockRestore(); + }); +}); diff --git a/app/AutoLogin.tsx b/app/AutoLogin.tsx index 3ded4ce..db9cfb3 100644 --- a/app/AutoLogin.tsx +++ b/app/AutoLogin.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect } from "react"; -import { supabase } from "./lib/supabaseClient"; +import { SCM } from "./lib/supabaseClient"; import { useDispatch } from "react-redux"; import type { AppDispatch } from "./store/store"; import { userAction } from "./store/slice"; @@ -10,22 +10,19 @@ const AutoLogin = () => { const dispatch = useDispatch() useEffect(() => { const checkSession = async () => { - const { data, error } = await supabase.auth.getSession(); + const { data, error } = await SCM.get().session(); if (!data || !data.session || error) return; - const { data: ddata, error: err } = await supabase - .from("users") - .select("*") - .eq("id", data.session.user.id); + const { data: ddata, error: err } = await SCM.get().userById(data.session.user.id); - if (err || ddata.length == 0) return; + if (err || !ddata) return; dispatch( userAction.setInfo({ - username: ddata[0].nickname, - role: ddata[0].role ?? "guest", - uuid: ddata[0].id, + username: ddata.nickname, + role: ddata.role ?? "guest", + uuid: ddata.id, }) ); } diff --git a/app/admin/AdminPage.tsx b/app/admin/AdminPage.tsx index 6482b35..227b2da 100644 --- a/app/admin/AdminPage.tsx +++ b/app/admin/AdminPage.tsx @@ -16,7 +16,7 @@ import { AlertCircle } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { supabase } from '../lib/supabaseClient'; +import { SCM } from '../lib/supabaseClient'; const AdminDashboard = () => { const router = useRouter(); @@ -24,7 +24,7 @@ const AdminDashboard = () => { const [waitRequestCount,setWaitRequestCount] = useState(null); const getWordCount = async () => { - const {count, error} = await supabase.from('words').select('word',{ count: 'exact', head: true }); + const {count, error} = await SCM.get().wordsCount(); if (error){ console.log(error) } @@ -34,7 +34,7 @@ const AdminDashboard = () => { } const getWaitRequestCount = async () => { - const {count, error} = await supabase.from('wait_words').select('',{ count: 'exact', head: true }); + const {count, error} = await SCM.get().waitWordsCount(); if (error){ console.log(error) @@ -99,28 +99,27 @@ const AdminDashboard = () => { ]; return ( -
+
{/* 헤더 */}
-

관리자 대시보드

-

단어 관리 시스템의 전반적인 운영을 담당합니다

+

관리자 대시보드

+

단어 관리 시스템의 전반적인 운영을 담당합니다

{/* 통계 카드 */}
{stats.map((stat, index) => ( - +
-

{stat.title}

-

{stat.value===null ? "loading..." : stat.value}

-
-
+

{stat.title}

+

{stat.value===null ? "loading..." : stat.value}

+
-
- +
+
@@ -130,28 +129,28 @@ const AdminDashboard = () => { {/* 주요 기능 메뉴 */}
-

주요 기능

+

주요 기능

{menuItems.map((item, index) => ( handleNavigation(item.path)} >
-
+
- + {item.title}
- + {item.description} @@ -161,37 +160,40 @@ const AdminDashboard = () => {
{/* 빠른 액세스 */} - + - 빠른 액세스 - 자주 사용하는 기능들에 빠르게 접근할 수 있습니다 + 빠른 액세스 + 자주 사용하는 기능들에 빠르게 접근할 수 있습니다
-
diff --git a/app/admin/add-words/AddWordsHome.tsx b/app/admin/add-words/AddWordsHome.tsx index 20a9a36..61d2284 100644 --- a/app/admin/add-words/AddWordsHome.tsx +++ b/app/admin/add-words/AddWordsHome.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +"use client"; +import { useState, useRef } from "react"; import { AlertDialog, AlertDialogContent, @@ -7,52 +8,44 @@ import { } from "@/app/components/ui/alert-dialog"; import { Button } from "@/app/components/ui/button"; import { Progress } from "@/app/components/ui/progress"; -import { FileJson, CheckCircle, AlertCircle } from "lucide-react"; -import { supabase } from "@/app/lib/supabaseClient"; +import { FileJson, CheckCircle, AlertCircle, ArrowLeft } from "lucide-react"; +import { SCM } from "@/app/lib/supabaseClient"; import { useSelector } from 'react-redux'; import { RootState } from "@/app/store/store"; import ErrorModal from "@/app/components/ErrModal"; import { PostgrestError } from "@supabase/supabase-js"; import { chunk as chunkArray } from "es-toolkit"; import JsonViewer from "./JosnViewer" +import Link from "next/link"; +import { isNoin } from "@/app/lib/lib"; +const keya = (word: string, themeName: string) => `[${word}, ${themeName}]` -type WordData = { - k_canuse: boolean; - noin_canuse: boolean; - themes: string[]; -}; +type JsonData = { word: string, themes: string[] }; -type WordEntry = { - word: string; -} & WordData; - -function isWordData(data: unknown): data is WordData { - if ( - typeof data !== 'object' || - data === null - ) { +function isRecordOfStringArray(obj: unknown): obj is Record { + if (typeof obj !== 'object' || obj === null) { return false; } - const obj = data as Record; + for (const [key, value] of Object.entries(obj)) { + if (typeof key !== 'string') { + return false; + } + if (!Array.isArray(value)) { + return false; + } + if (!value.every(item => typeof item === 'string')) { + return false; + } + } - return ( - typeof obj.k_canuse === 'boolean' && - typeof obj.noin_canuse === 'boolean' && - Array.isArray(obj.themes) && - obj.themes.every((t) => typeof t === 'string') - ); + return true; } - - -// 전체 JSON 데이터 구조 (배열 형태) -type JsonData = WordEntry[]; - export default function WordsAddHome() { // 상태 관리 - const [jsonData, setJsonData] = useState(null); + const [jsonData, setJsonData] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); const [currentTask, setCurrentTask] = useState(""); @@ -61,11 +54,14 @@ export default function WordsAddHome() { const [fileUploaded, setFileUploaded] = useState(false); const user = useSelector((state: RootState) => state.user); const [errorModalView, setErrorModalView] = useState(null); + const [fileName, setFileName] = useState(''); + const fileInputRef = useRef(null); - // 파일 업로드 처리 + // 파일 업로드 처리 (드래그/클릭 모두 지원) const handleFileUpload = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; + setFileName(file.name); const reader = new FileReader(); reader.onload = (e) => { @@ -82,19 +78,17 @@ export default function WordsAddHome() { setFileUploaded(false); return; } + const parsedData: JsonData[] = []; - const parsedData: WordEntry[] = []; - - for (const [word, value] of Object.entries(parsed)) { - if (isWordData(value)) { - parsedData.push({ word, ...value }); - } else { - setError(`"${word}" 항목의 데이터 형식이 올바르지 않습니다.`); - setFileUploaded(false); - return; + if (isRecordOfStringArray(parsed)){ + for (const [word, themes] of Object.entries(parsed)){ + parsedData.push({word, themes}) } + }else{ + setError(`데이터 형식이 올바르지 않습니다.`); + setFileUploaded(false); + return; } - setJsonData(parsedData); setFileUploaded(true); setError(null); @@ -107,7 +101,55 @@ export default function WordsAddHome() { reader.readAsText(file); }; + // 파일 드래그 앤 드롭 지원 + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + const file = e.dataTransfer.files?.[0]; + if (!file) return; + setFileName(file.name); + + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const content = ev.target?.result as string; + const parsed = JSON.parse(content); + + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + setError('JSON 데이터가 객체 형식이 아닙니다.'); + setFileUploaded(false); + return; + } + const parsedData: JsonData[] = []; + if (isRecordOfStringArray(parsed)){ + for (const [word, themes] of Object.entries(parsed)){ + parsedData.push({word, themes}) + } + }else{ + setError(`데이터 형식이 올바르지 않습니다.`); + setFileUploaded(false); + return; + } + setJsonData(parsedData); + setFileUploaded(true); + setError(null); + } catch { + setError('JSON 파일 파싱 중 오류가 발생했거나 형식이 올바르지 않습니다.'); + setFileUploaded(false); + } + }; + reader.readAsText(file); + }; // 처리 버튼 클릭 핸들러 const handleProcess = async () => { @@ -121,7 +163,6 @@ export default function WordsAddHome() { setProgress(0); setCurrentTask("데이터 초기화 중..."); - // 처리 로직 시뮬레이션 (실제로는 여기서 처리 로직을 구현하세요) await handleDbProcess(); }; @@ -141,11 +182,31 @@ export default function WordsAddHome() { setProgress(0); setCurrentTask('필요한 정보 가져오는 중...'); - const { data: docsDatas, error: docsDataError } = await supabase.from('docs').select('*'); + const { data: docsDatas, error: docsDataError } = await SCM.get().allDocs(); if (docsDataError) return makeError(docsDataError); - const { data: themeData, error: themeError } = await supabase.from('themes').select('*') + const { data: themeData, error: themeError } = await SCM.get().allThemes(); if (themeError) return makeError(themeError); + const { data: waitWords, error: waitWordsError } = await SCM.get().allWaitWords('add'); + const { data: waitThemeWord, error: waitThemeWordError } = await SCM.get().allWordWaitTheme('add'); + + if (waitWordsError) { return makeError(waitWordsError); } + if (waitThemeWordError) { return makeError(waitThemeWordError); } + + const { data: waitWordTheme, error: waitWordThemeError } = await SCM.get().waitWordsThemes(waitWords.map(({ id }) => id)) + if (waitWordThemeError) { return makeError(waitWordThemeError); } + + const waitWord: Record = {}; + const waitTheme: Record = {}; + + waitWords.forEach(({ id, word, requested_by }) => waitWord[word] = { id, requested_by }); + waitThemeWord.forEach(({ words: { word }, themes: { name }, req_by }) => { + waitTheme[keya(word, name)] = { req_by } + }) + waitWordTheme.forEach(({ wait_words: { word }, themes: { name } }) => { + waitTheme[keya(word, name)] = { req_by: waitWord[word]?.requested_by ?? null } + }) + const letterDocsInfo: Record = {}; const themeDocsInfo: Record = {}; const themeCodeInfo: Record = {} @@ -170,18 +231,20 @@ export default function WordsAddHome() { for (const data of jsonData) { wordAddQuery.push({ word: data.word, - k_canuse: data.k_canuse, - noin_canuse: data.noin_canuse, - added_by: user.uuid + k_canuse: true, + noin_canuse: isNoin(data.themes), + added_by: waitWord[data.word]?.requested_by ?? user.uuid }) } const chuckQuerysA = chunkArray(wordAddQuery, 250) setProgress(20); + const com: Record = {}; // uuid-기여수 + for (let i = 0; i < chuckQuerysA.length; i++) { const chuckQuery = chuckQuerysA[i] setCurrentTask(`단어 추가중... ${i}/${chuckQuerysA.length}`); - const { data: insertedWordsData, error: insertWordError } = await supabase.from('words').upsert(chuckQuery, { ignoreDuplicates: true, onConflict: "word" }).select('*'); + const { data: insertedWordsData, error: insertWordError } = await SCM.add().words(chuckQuery); if (insertWordError) { return makeError(insertWordError) } @@ -189,13 +252,14 @@ export default function WordsAddHome() { continue } - for (const data of insertedWordsData) { - inseredWordMap[data.word] = data.id + inseredWordMap[data.word] = data.id; + const make_by = waitWord[data.word]?.requested_by ?? user.uuid; + com[make_by] = (com[make_by] ?? 0) + 1; logsQuery.push({ word: data.word, processed_by: user.uuid, - make_by: user.uuid, + make_by, r_type: "add" as const, state: "approved" as const }) @@ -204,7 +268,7 @@ export default function WordsAddHome() { docsLogsQuery.push({ docs_id: letterDocsId, word: data.word, - add_by: user.uuid, + add_by: make_by, type: "add" as const }) } @@ -212,12 +276,10 @@ export default function WordsAddHome() { } setProgress(40); - let addWordCount = 0; const needCheckWord: string[] = []; for (const data of jsonData) { const wordId = inseredWordMap[data.word] if (wordId) { - addWordCount += 1; for (const theme of data.themes) { const themeId = themeCodeInfo[theme] if (themeId) { @@ -233,16 +295,16 @@ export default function WordsAddHome() { } setProgress(50); - const needCheckedWordsDatas:{id: number, word: string}[] = [] - const abab = chunkArray(needCheckWord,100) - for (let i=0;i = {}; for (const data of needCheckedWordsDatas) { @@ -271,7 +333,7 @@ export default function WordsAddHome() { for (let i = 0; i < chuckQuerysB.length; i++) { const chuckQuery = chuckQuerysB[i] setCurrentTask(`주제 정보 추가 중...${i}/${chuckQuerysB.length}`) - const { data: insertedThemesData, error: inseredThemeError } = await supabase.from('word_themes').upsert(chuckQuery, { ignoreDuplicates: true, onConflict: "word_id,theme_id" }).select('words(word),themes(name)') + const { data: insertedThemesData, error: inseredThemeError } = await SCM.add().wordsThemes(chuckQuery); if (inseredThemeError) return makeError(inseredThemeError) if (!insertedThemesData) continue for (const data of insertedThemesData) { @@ -283,10 +345,11 @@ export default function WordsAddHome() { for (const theme of themes) { const themeDocsId = themeDocsInfo[theme] if (themeDocsId) { + const k = keya(word, theme) docsLogsQuery.push({ docs_id: themeDocsId, word: word, - add_by: user.uuid, + add_by: waitTheme[k]?.req_by ?? user.uuid, type: "add" as const }) } @@ -295,27 +358,31 @@ export default function WordsAddHome() { setProgress(60); setCurrentTask('로그 등록중...') - const { error: logError } = await supabase.from('logs').insert(logsQuery); + const { error: logError } = await SCM.add().wordLog(logsQuery); if (logError) { return makeError(logError); } - const { data: docsLogData, error: docsLogError } = await supabase.from('docs_logs').insert(docsLogsQuery).select('*,docs(typez)'); + const { error: docsLogError } = await SCM.add().docsLog(docsLogsQuery); if (docsLogError) return makeError(docsLogError) const updateThemeDocsIds: Set = new Set(); - for (const data of docsLogData) { - if (data.docs.typez === "theme") { - updateThemeDocsIds.add(data.docs_id); - } + for (const data of docsLogsQuery) { + updateThemeDocsIds.add(data.docs_id); } setProgress(80); setCurrentTask('마지막 처리 중...') - const { error: rpcError1 } = await supabase.rpc('increment_contribution', { target_id: user.uuid, inc_amount: addWordCount }) - if (rpcError1) return makeError(rpcError1) + for (const [uuid, count] of Object.entries(com)) { + const { error: rpcError1 } = await SCM.update().userContribution({ userId: uuid, amount: count }) + if (rpcError1) return makeError(rpcError1) + } - const { error: rpcError2 } = await supabase.rpc('update_last_updates', { docs_ids: [...updateThemeDocsIds] }) - if (rpcError2) return makeError(rpcError2); + await SCM.update().docsLastUpdate([...updateThemeDocsIds]); + + for (const p of chunkArray(logsQuery.map(({word})=>word),100)){ + const {error} = await SCM.delete().waitWordsByWords(p); + if (error) { return makeError(error); } + } setProgress(100); setCurrentTask('완료!') @@ -328,24 +395,36 @@ export default function WordsAddHome() { }; return ( -
-
-

JSON 파일 처리

+
+
+ {/* 관리자 대시보드로 이동 버튼 */} + + + +

JSON 파일 처리

{/* 파일 업로드 영역 */}
- {/* 파일 내용 표시 */} {/* 파일 내용 표시 - 가상화 적용 */} {fileUploaded && jsonData && ( -
-

업로드된 JSON 데이터

+
+

업로드된 JSON 데이터

@@ -371,9 +450,9 @@ export default function WordsAddHome() { {/* 에러 메시지 */} {error && ( -
+
-

{error}

+

{error}

)} @@ -382,7 +461,7 @@ export default function WordsAddHome() { @@ -391,13 +470,12 @@ export default function WordsAddHome() { {/* 처리 모달 */} - + - + {isProcessing ? "처리 중..." : "처리 완료"} - {/* Fix: Replace AlertDialogDescription with a div to avoid nesting issues */} -
+

{currentTask}

@@ -410,7 +488,7 @@ export default function WordsAddHome() { {!isProcessing && (
-
+
처리가 완료되었습니다!
@@ -429,6 +507,5 @@ export default function WordsAddHome() { {errorModalView && setErrorModalView(null)} />}
- ); } diff --git a/app/admin/add-words/JosnViewer.tsx b/app/admin/add-words/JosnViewer.tsx index 21eeff3..37a5a3f 100644 --- a/app/admin/add-words/JosnViewer.tsx +++ b/app/admin/add-words/JosnViewer.tsx @@ -1,17 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; -type WordData = { - k_canuse: boolean; - noin_canuse: boolean; - themes: string[]; -}; - -type WordEntry = { - word: string; -} & WordData; - - +type WordEntry = { word: string, themes: string[] } type JsonData = WordEntry[]; // JSON 뷰어 컴포넌트 @@ -23,7 +13,7 @@ const JsonViewer = ({ data }: JsonViewerProps) => { const [jsonLines, setJsonLines] = useState([]); /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ const [containerWidth, setContainerWidth] = useState(300); - const containerRef = useRef(null); + const containerRef = useRef(null); // JSON을 라인 별로 분리 useEffect(() => { @@ -40,9 +30,7 @@ const JsonViewer = ({ data }: JsonViewerProps) => { setContainerWidth(entry.contentRect.width); } }); - resizeObserver.observe(containerRef.current); - return () => { if (containerRef.current) { resizeObserver.unobserve(containerRef.current); @@ -51,21 +39,36 @@ const JsonViewer = ({ data }: JsonViewerProps) => { } }, []); - // 각 라인 렌더링 - const Row = ({ index, style }: ListChildComponentProps) => ( -
- {jsonLines[index]} -
- ); + // 각 라인 렌더링 (들여쓰기 보존) + const Row = ({ index, style }: ListChildComponentProps) => { + const line = jsonLines[index] || ""; + // 들여쓰기 계산 (공백 2칸 기준) + const indentMatch = line.match(/^(\s*)/); + const indent = indentMatch ? indentMatch[1].length : 0; + // 2칸마다 0.75rem(=12px) padding-left 추가 + const paddingLeft = `${indent * 0.6}ch`; + + return ( +
+ {line} +
+ ); + }; return ( -
+
{Row} diff --git a/app/admin/add-words/Wrapper.tsx b/app/admin/add-words/Wrapper.tsx deleted file mode 100644 index 91bbee9..0000000 --- a/app/admin/add-words/Wrapper.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; -import NotFound from '@/app/not-found-client'; -import { supabase } from "@/app/lib/supabaseClient"; -import { useSelector } from 'react-redux'; -import { RootState } from "@/app/store/store"; -import { useEffect, useState } from 'react'; -import WordsAddPage from './AddWordsHome'; - -export default function AdminAddHomeWrapper(){ - const [isBlock,setIsBlock] = useState(false); - const [checkingRole,setCheckingRole] = useState(true) - const user = useSelector((state: RootState) => state.user); - const [ok, setOk] = useState(false) - - - useEffect(()=>{ - const checkSession = async () => { - const { data, error } = await supabase.auth.getSession(); - - if (!data || !data.session || error) { - setCheckingRole(false); - setIsBlock(true) - return; - } - - const { data: ddata, error: err } = await supabase - .from("users") - .select("*") - .eq("id", data.session.user.id); - - if (err || ddata.length == 0){ - setCheckingRole(false); - setIsBlock(true) - return; - } - setCheckingRole(false); - setOk(true); - } - checkSession() - },[]) - - if (!["admin","r4"].includes(user.role) || isBlock || checkingRole){ - return - } - - if (ok){ - return - } - -} \ No newline at end of file diff --git a/app/admin/add-words/page.tsx b/app/admin/add-words/page.tsx index 395729c..5630a56 100644 --- a/app/admin/add-words/page.tsx +++ b/app/admin/add-words/page.tsx @@ -1,4 +1,4 @@ -import AdminAddHomeWrapper from "./Wrapper"; +import WordsAddHome from "./AddWordsHome"; export async function generateMetadata() { return { @@ -8,5 +8,5 @@ export async function generateMetadata() { } export default function WordsAddPage(){ - return + return } \ No newline at end of file diff --git a/app/admin/del-words/AdminDelHomeWrapper.tsx b/app/admin/del-words/AdminDelHomeWrapper.tsx deleted file mode 100644 index 75ff797..0000000 --- a/app/admin/del-words/AdminDelHomeWrapper.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; -import NotFound from '@/app/not-found-client'; -import { supabase } from "@/app/lib/supabaseClient"; -import { useSelector } from 'react-redux'; -import { RootState } from "@/app/store/store"; -import { useEffect, useState } from 'react'; -import WordsDelHome from './DelWordsHome'; - -export default function AdminDelHomeWrapper(){ - const [isBlock,setIsBlock] = useState(false); - const [checkingRole,setCheckingRole] = useState(true) - const user = useSelector((state: RootState) => state.user); - const [ok, setOk] = useState(false) - - useEffect(()=>{ - const checkSession = async () => { - const { data, error } = await supabase.auth.getSession(); - - if (!data || !data.session || error) { - setCheckingRole(false); - setIsBlock(true) - return; - } - - const { data: ddata, error: err } = await supabase - .from("users") - .select("*") - .eq("id", data.session.user.id); - - if (err || ddata.length == 0){ - setCheckingRole(false); - setIsBlock(true) - return; - } - setCheckingRole(false); - setOk(true); - } - checkSession() - },[]) - - if (!["admin","r4"].includes(user.role) || isBlock || checkingRole){ - return - } - - if (ok){ - return - } -} \ No newline at end of file diff --git a/app/admin/del-words/DelWordsHome.tsx b/app/admin/del-words/DelWordsHome.tsx index 6ddf9f5..fa8741f 100644 --- a/app/admin/del-words/DelWordsHome.tsx +++ b/app/admin/del-words/DelWordsHome.tsx @@ -1,5 +1,6 @@ +"use client"; import { useState, useRef, ChangeEvent, DragEvent } from 'react'; -import { Upload, FileText, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; +import { Upload, FileText, CheckCircle, AlertCircle, Loader2, ArrowLeft } from 'lucide-react'; import { Dialog, DialogContent, @@ -13,15 +14,17 @@ import { Textarea } from '@/app/components/ui/textarea'; import { Alert, AlertDescription } from '@/app/components/ui/alert'; import { PostgrestError } from '@supabase/supabase-js'; import ErrorModal from "@/app/components/ErrModal"; -import { supabase } from '@/app/lib/supabaseClient'; +import { SCM } from '@/app/lib/supabaseClient'; import { useSelector } from 'react-redux'; import { RootState } from "@/app/store/store"; import { chunk as chunkArray } from "es-toolkit"; +import Link from "next/link"; function isNumericString(str: string): boolean { return /^[0-9]+$/.test(str); } +const keya = (word: string, themeName: string) => `[${word}, ${themeName}]` export default function WordsDelHome() { const [file, setFile] = useState(null); @@ -116,8 +119,6 @@ export default function WordsDelHome() { setProgress(0); setCurrentTask('파일 분석 중...'); - // 실제 처리 로직은 사용자가 구현할 예정 - // 여기서는 시뮬레이션만 진행 await handleDbProcess(); }; @@ -128,9 +129,29 @@ export default function WordsDelHome() { setCurrentTask("필요한 정보 가져오는 중..."); setProgress(0); - const { data: docsDatas, error: docsDataError } = await supabase.from('docs').select('*'); + const { data: docsDatas, error: docsDataError } = await SCM.get().allDocs(); if (docsDataError) return makeError(docsDataError); + const { data: waitWords, error: waitWordsError } = await SCM.get().allWaitWords('delete'); + const { data: waitThemeWord, error: waitThemeWordError } = await SCM.get().allWordWaitTheme('delete'); + + if (waitWordsError) { return makeError(waitWordsError); } + if (waitThemeWordError) { return makeError(waitThemeWordError); } + + const { data: waitWordTheme, error: waitWordThemeError } = await SCM.get().waitWordsThemes(waitWords.map(({ id }) => id)) + if (waitWordThemeError) { return makeError(waitWordThemeError); } + + const waitWord: Record = {}; + const waitTheme: Record = {}; + + waitWords.forEach(({ id, word, requested_by }) => waitWord[word] = { id, requested_by }); + waitThemeWord.forEach(({ words: { word }, themes: { name }, req_by }) => { + waitTheme[keya(word, name)] = { req_by } + }) + waitWordTheme.forEach(({ wait_words: { word }, themes: { name } }) => { + waitTheme[keya(word, name)] = { req_by: waitWord[word]?.requested_by ?? null } + }) + const letterDocsInfo: Record = {}; const themeDocsInfo: Record = {}; @@ -152,7 +173,7 @@ export default function WordsDelHome() { const chuckWords = chunkArray(words, 100) for (const ww of chuckWords) { - const { data: wordsData, error: wordsError } = await supabase.from('words').select('*').in('word', ww); + const { data: wordsData, error: wordsError } = await SCM.get().wordsByWords(ww); if (wordsError) return makeError(wordsError); for (const data of wordsData) { @@ -164,21 +185,18 @@ export default function WordsDelHome() { setCurrentTask("삭제할 단어의 주제 정보 가져오는 중..."); setProgress(50); - const doct: Record = {}; + const doct: Record = {}; const nodel: Set = new Set(); for (const chuckchu of chunkArray(wordIds, 100)) { - const { data: wordThemeData, error: wordThemeError } = await supabase.from('word_themes').select('words(word,id),themes(name,code)').in('word_id', chuckchu) + const { data: wordThemeData, error: wordThemeError } = await SCM.get().wordsThemes(chuckchu) if (wordThemeError) return makeError(wordThemeError) for (const data of wordThemeData) { - const themeDocsId = themeDocsInfo[data.themes.name] if (isNumericString(data.themes.code)){ nodel.add(data.words.id) } - if (themeDocsId) { - doct[data.words.word] = [...(doct[data.words.word] ?? []), themeDocsId] - } + doct[data.words.word] = [...(doct[data.words.word] ?? []), data.themes.name] } } @@ -188,7 +206,7 @@ export default function WordsDelHome() { logsQuery.push({ word: word, processed_by: user.uuid, - make_by: user.uuid, + make_by: waitWord[word]?.requested_by ?? user.uuid, r_type: "delete" as const, state: "approved" as const }) @@ -197,17 +215,18 @@ export default function WordsDelHome() { docsLogsQuery.push({ docs_id: docsId, word: word, - add_by: user.uuid, + add_by: waitWord[word]?.requested_by ?? user.uuid, type: "delete" as const }) } const t = doct[word] ?? [] if (t.length > 0){ - for (const tid of t){ + for (const tname of t){ + const tid = themeDocsInfo[tname] docsLogsQuery.push({ docs_id: tid, word: word, - add_by: user.uuid, + add_by: waitTheme[keya(word,tname)]?.req_by ?? user.uuid, type: "delete" as const }) } @@ -218,11 +237,12 @@ export default function WordsDelHome() { setCurrentTask("로그 등록중..."); setProgress(75); - const { error: logError } = await supabase.from('logs').insert(logsQuery); + const { error: logError } = await SCM.add().wordLog(logsQuery); if (logError) return makeError(logError) - const { data: docsLogData, error: docsLogError } = await supabase.from('docs_logs').insert(docsLogsQuery).select('docs(id)') + const { error: docsLogError } = await SCM.add().docsLog(docsLogsQuery); if (docsLogError) return makeError(docsLogError) + const docsLogData = docsLogsQuery.map(({docs_id})=>docs_id) const deleteWordIdChuck = chunkArray(wordIdsAA, 200); @@ -231,19 +251,18 @@ export default function WordsDelHome() { for (let i = 0; i < deleteWordIdChuck.length; i++) { const wordIdsA = deleteWordIdChuck[i] setCurrentTask(`삭제 처리중... ${i}/${deleteWordIdChuck.length}`) - const { error: deleteWordError } = await supabase.from('words').delete().in('id', wordIdsA) + const { error: deleteWordError } = await SCM.delete().wordByIds(wordIdsA); if (deleteWordError) return makeError(deleteWordError) } - for (const { docs } of docsLogData) { - updateDocsId.add(docs.id) + for (const id of docsLogData) { + updateDocsId.add(id) } setCurrentTask("마지막 처리중..."); setProgress(95); - for (const docsId of updateDocsId) { - await supabase.rpc('update_last_update', { docs_id: docsId }) - } - await supabase.rpc('increment_contribution', { target_id: user.uuid, inc_amount: wordIds.length }) + await SCM.update().docsLastUpdate([...updateDocsId]) + await SCM.update().userContribution({ userId: user.uuid, amount: wordIds.length }) + setProgress(100); setIsProcessing(false); @@ -258,19 +277,26 @@ export default function WordsDelHome() { }; return ( -
+
-

단어 대량 삭제 페이지

-

단어를 대량으로 삭제합니다.

+

단어 대량 삭제 페이지

+

단어를 대량으로 삭제합니다.

-
-

파일 업로드

+ {/* 관리자 대시보드로 이동 버튼 */} + + + +
+

파일 업로드

{/* 드래그 앤 드롭 영역 */}
- -

+ +

{fileName ? `${fileName} 선택됨` : '파일을 드래그하거나 클릭하여 업로드'}

-

지원 형식: TXT, CSV, MD, JSON

+

지원 형식: TXT, CSV, MD, JSON

@@ -303,14 +329,14 @@ export default function WordsDelHome() { {/* 파일 내용 미리보기 */} {fileContent && (
-

+

파일 미리보기