From 936583ad41260033856da347d27752eaddf2ab52 Mon Sep 17 00:00:00 2001 From: jihyeon Date: Sun, 10 Aug 2025 20:11:38 +0900 Subject: [PATCH 01/71] =?UTF-8?q?build:=20ai=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + pnpm-lock.yaml | 62 ++++++++--- src/App.test.tsx | 115 +++++++++++++++++++++ src/components/index.tsx | 87 +++++++++++----- src/pages/PostsManagerPage.tsx | 180 +++++++++++++++++++++----------- src/test/handlers.ts | 181 +++++++++++++++++++++++++++++++++ src/test/setup.ts | 20 ++++ tsconfig.app.json | 3 +- tsconfig.app.tsbuildinfo | 1 + tsconfig.node.tsbuildinfo | 1 + vitest.config.ts | 1 + 11 files changed, 554 insertions(+), 99 deletions(-) create mode 100644 src/App.test.tsx create mode 100644 src/test/handlers.ts create mode 100644 src/test/setup.ts create mode 100644 tsconfig.app.tsbuildinfo create mode 100644 tsconfig.node.tsbuildinfo diff --git a/package.json b/package.json index e014c5272..b97f9a0aa 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint .", "preview": "vite preview", "test": "vitest", + "test:ui": "vitest --ui", "coverage": "vitest run --coverage" }, "dependencies": { @@ -25,6 +26,7 @@ "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", + "@vitest/ui": "3.2.4", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "eslint": "^9.33.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b2a40d18..64c6a4b1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.0.0 version: 5.0.0(vite@7.1.1(@types/node@22.8.1)) + '@vitest/ui': + specifier: 3.2.4 + version: 3.2.4(vitest@3.2.4) axios: specifier: ^1.11.0 version: 1.11.0 @@ -86,7 +89,7 @@ importers: version: 7.1.1(@types/node@22.8.1) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2)) + version: 3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2)) vitest-browser-react: specifier: ^1.0.1 version: 1.0.1(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(@vitest/browser@2.1.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vitest@3.2.4) @@ -103,10 +106,6 @@ packages: '@asamuzakjp/css-color@2.8.3': resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==} - '@babel/code-frame@7.26.2': - resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -1130,6 +1129,11 @@ packages: '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/ui@3.2.4': + resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} + peerDependencies: + vitest: 3.2.4 + '@vitest/utils@2.1.3': resolution: {integrity: sha512-xpiVfDSg1RrYT0tX6czgerkpcKFmFOF/gCr30+Mve5V2kewCy4Prn1/NDMSRwaSmT7PRaOF83wu+bEtsY1wrvA==} @@ -1467,6 +1471,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1486,6 +1493,9 @@ packages: flatted@3.3.1: resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.9: resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} engines: {node: '>=4.0'} @@ -2004,6 +2014,10 @@ packages: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} + sirv@3.0.1: + resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} + engines: {node: '>=18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2362,12 +2376,6 @@ snapshots: '@csstools/css-tokenizer': 3.0.3 lru-cache: 10.4.3 - '@babel/code-frame@7.26.2': - dependencies: - '@babel/helper-validator-identifier': 7.25.9 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -3083,7 +3091,7 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: - '@babel/code-frame': 7.26.2 + '@babel/code-frame': 7.27.1 '@babel/runtime': 7.26.0 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -3284,7 +3292,7 @@ snapshots: msw: 2.10.4(@types/node@22.8.1)(typescript@5.9.2) sirv: 2.0.4 tinyrainbow: 1.2.0 - vitest: 3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2)) + vitest: 3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2)) ws: 8.18.0 transitivePeerDependencies: - '@types/node' @@ -3344,10 +3352,21 @@ snapshots: dependencies: tinyspy: 4.0.3 + '@vitest/ui@3.2.4(vitest@3.2.4)': + dependencies: + '@vitest/utils': 3.2.4 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.1 + tinyglobby: 0.2.14 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2)) + '@vitest/utils@2.1.3': dependencies: '@vitest/pretty-format': 2.1.3 - loupe: 3.1.3 + loupe: 3.2.0 tinyrainbow: 1.2.0 '@vitest/utils@3.2.4': @@ -3693,6 +3712,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3713,6 +3734,8 @@ snapshots: flatted@3.3.1: {} + flatted@3.3.3: {} + follow-redirects@1.15.9: {} form-data@4.0.4: @@ -4181,6 +4204,12 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 + sirv@3.0.1: + dependencies: + '@polka/url': 1.0.0-next.28 + mrmime: 2.0.0 + totalist: 3.0.1 + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -4360,12 +4389,12 @@ snapshots: '@vitest/browser': 2.1.3(@types/node@22.8.1)(@vitest/spy@3.2.4)(typescript@5.9.2)(vite@7.1.1(@types/node@22.8.1))(vitest@3.2.4) react: 19.1.1 react-dom: 19.1.1(react@19.1.1) - vitest: 3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2)) + vitest: 3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2)) optionalDependencies: '@types/react': 19.1.9 '@types/react-dom': 19.1.7(@types/react@19.1.9) - vitest@3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2)): + vitest@3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2)): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -4393,6 +4422,7 @@ snapshots: optionalDependencies: '@types/node': 22.8.1 '@vitest/browser': 2.1.3(@types/node@22.8.1)(@vitest/spy@3.2.4)(typescript@5.9.2)(vite@7.1.1(@types/node@22.8.1))(vitest@3.2.4) + '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: - jiti diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 000000000..813925622 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,115 @@ +import { render, screen, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import App from "./App" + +// 통합 시나리오: 목록 로딩 → 태그 필터 → 검색 → 상세 댓글 로드/추가/수정/삭제/좋아요 → 게시물 추가/수정/삭제 + +const renderApp = () => render() + +describe("App - 기본 동작", () => { + test("초기 로딩 후 게시물 목록과 페이지네이션이 나타난다", async () => { + renderApp() + + // 로딩 + expect(screen.getByText("로딩 중...")).toBeInTheDocument() + + // 목록 렌더링 + const firstTitleCell = await screen.findByText(/^Post 1$/) + expect(firstTitleCell).toBeInTheDocument() + const row = firstTitleCell.closest("tr")! + + // 페이지네이션 버튼 + expect(screen.getByRole("button", { name: "다음" })).toBeEnabled() + expect(screen.getByRole("button", { name: "이전" })).toBeDisabled() + expect(row).toBeInTheDocument() + }) + + test("태그 필터링이 동작한다", async () => { + renderApp() + + await screen.findByText(/^Post 1$/) + + // 드롭다운 대신 테이블 내 태그 칩 클릭으로 필터 적용 (jsdom pointer 이슈 회피) + const tagChip = await screen.findAllByText("tag-a") + await userEvent.click(tagChip[0]) + + // URL 쿼리 파라미터로 태그가 반영되었는지 확인 + expect(window.location.search).toContain("tag=tag-a") + + // 필터 적용 이후에도 리스트가 렌더링되는지 간단 확인 (여러 항목 허용) + const titles = await screen.findAllByText(/^Post \d+$/) + expect(titles.length).toBeGreaterThan(0) + }) + + test("검색이 동작한다", async () => { + renderApp() + await screen.findByText(/^Post 1$/) + + const input = screen.getByRole("textbox", { name: "게시물 검색" }) + await userEvent.clear(input) + await userEvent.type(input, "Post 2{enter}") + + const results = await screen.findAllByText(/Post 2/) + expect(results.length).toBeGreaterThan(0) + }) +}) + +describe("App - 댓글", () => { + test("게시물 상세 열고 댓글을 로드하고 새 댓글을 추가할 수 있다", async () => { + renderApp() + const firstTitleCell = await screen.findByText(/^Post 1$/) + const row = firstTitleCell.closest("tr")! + await userEvent.click(within(row).getByRole("button", { name: "댓글 보기" })) + + // 상세 다이얼로그 열림 + 댓글 보임 + const dialog = await screen.findByRole("dialog") + expect(within(dialog).getByRole("heading", { name: /^댓글$/ })).toBeInTheDocument() + + // 댓글 추가 + const addCommentBtn = within(dialog).getByRole("button", { name: /댓글 추가/ }) + await userEvent.click(addCommentBtn) + const addDialog = await screen.findByRole("dialog", { name: /새 댓글 추가/i }) + const textarea = within(addDialog).getByPlaceholderText("댓글 내용") + await userEvent.type(textarea, "great!") + await userEvent.click(within(addDialog).getByRole("button", { name: "댓글 추가" })) + expect(await within(dialog).findByText(/great!/)).toBeInTheDocument() + }) +}) + +describe("App - 게시물 CRUD", () => { + test("게시물 추가/수정/삭제가 가능하다", async () => { + renderApp() + await screen.findByText(/^Post 1$/) + + // 추가 + await userEvent.click(screen.getByRole("button", { name: /게시물 추가/ })) + const addDialog = await screen.findByRole("dialog", { name: /새 게시물 추가/i }) + await userEvent.type(within(addDialog).getByPlaceholderText("제목"), "New Title") + const body = within(addDialog).getByPlaceholderText("내용") + await userEvent.type(body, "Body") + const userId = within(addDialog).getByPlaceholderText("사용자 ID") + await userEvent.clear(userId) + await userEvent.type(userId, "1") + await userEvent.click(within(addDialog).getByRole("button", { name: "게시물 추가" })) + + expect(await screen.findByText("New Title")).toBeInTheDocument() + + // 수정 + const titleCell = screen.getByText("New Title") + const row = titleCell.closest("tr")! + await userEvent.click(within(row).getByRole("button", { name: "게시물 수정" })) + const editDialog = await screen.findByRole("dialog", { name: /게시물 수정/i }) + const titleInput = within(editDialog).getByPlaceholderText("제목") + await userEvent.clear(titleInput) + await userEvent.type(titleInput, "Updated Title") + await userEvent.click(within(editDialog).getByRole("button", { name: "게시물 업데이트" })) + expect(await screen.findByText("Updated Title")).toBeInTheDocument() + + // 삭제 + const updatedTitleCell = screen.getByText("Updated Title") + const updatedRow = updatedTitleCell.closest("tr")! + await userEvent.click(within(updatedRow).getByRole("button", { name: "게시물 삭제" })) + + expect(screen.queryByText("Updated Title")).not.toBeInTheDocument() + }, 15000) +}) diff --git a/src/components/index.tsx b/src/components/index.tsx index 8495817d3..24f022e77 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -42,7 +42,8 @@ export const Button = forwardRef(({ className, v Button.displayName = "Button" // 입력 컴포넌트 -export const Input = forwardRef(({ className, type, ...props }, ref) => { +type InputProps = React.InputHTMLAttributes & { className?: string } +export const Input = forwardRef(({ className = "", type, ...props }, ref) => { return ( { Input.displayName = "Input" // 카드 컴포넌트 -export const Card = forwardRef(({ className, ...props }, ref) => ( +type DivProps = React.HTMLAttributes & { className?: string } +export const Card = forwardRef(({ className = "", ...props }, ref) => (
)) Card.displayName = "Card" -export const CardHeader = forwardRef(({ className, ...props }, ref) => ( +export const CardHeader = forwardRef(({ className = "", ...props }, ref) => (
)) CardHeader.displayName = "CardHeader" -export const CardTitle = forwardRef(({ className, ...props }, ref) => ( +export const CardTitle = forwardRef< + HTMLHeadingElement, + React.HTMLAttributes & { className?: string } +>(({ className = "", ...props }, ref) => (

)) CardTitle.displayName = "CardTitle" -export const CardContent = forwardRef(({ className, ...props }, ref) => ( +export const CardContent = forwardRef(({ className = "", ...props }, ref) => (
)) CardContent.displayName = "CardContent" // 텍스트 영역 컴포넌트 -export const Textarea = forwardRef(({ className, ...props }, ref) => { +type TextareaProps = React.TextareaHTMLAttributes & { className?: string } +export const Textarea = forwardRef(({ className = "", ...props }, ref) => { return (