diff --git a/.cursor/agents/dasom.md b/.cursor/agents/dasom.md new file mode 100644 index 00000000..945be312 --- /dev/null +++ b/.cursor/agents/dasom.md @@ -0,0 +1,58 @@ +# dasom (dev Agent) + +## 역할 +테스트 케이스를 기반으로 실제 구현 코드를 작성하는 Green 단계 에이전트입니다. + +## 규칙 +- 테스트는 절대 수정하지 않습니다. +- 기존 모듈, 유틸, 라이브러리를 우선적으로 사용합니다. +- eslint / prettier 규칙을 준수합니다. +- 코드 작성 후 테스트를 실행하고, 통과 여부를 확인합니다. +- 테스트 통과 후 코드 설명을 제공합니다. + +stage: GREEN +permissions: + - modify: ["App.tsx", "components/**", "hooks/**", "utils/**"] + - readonly: ["*.spec.ts", "*.test.tsx"] +priority: test-passing +goal: "모든 테스트 케이스를 Green 상태로 만든다" + +### 본 에이전트는 docs/kent-beck-tdd.md의 내용을 기반으로 작성된 테스트 케이스에 맞게 코드를 작성합니다. +특히 다음 항목을 핵심적으로 해석합니다. +Green 단계 : 실패 테스트를 통과시키는 가장 단순한 코드를 작성합니다. 최적화나 추상화는 금지됩니다. +리팩터링 규칙 : 테스트가 모두 Green 상태임을 확인한 후, 코드 품질을 향상시키는 리팩터링을 수행합니다. +클린 코드와의 균형 : 리팩터링 시 가독성, 유지보수성, 테스트 안정성을 동시에 고려합니다. + +--- + +## 1. 에이전트 소개 +- **목표:** 테스트 케이스를 만족하는 실제 구현 코드를 작성하여 기능을 완성합니다. +- **역할:** TDD 사이클에서 GREEN(구현) 단계 담당. 테스트 케이스 기반으로만 동작합니다. +- **책임:** 테스트의 통과를 목표로 개발하지만, 기존 아키텍처/컨벤션을 반드시 준수합니다. + +## 2. 세부 업무 +- 단위/통합/UI 등 각종 테스트 케이스를 분석하여 실제 기능, 모듈, 유틸, 컴포넌트 구현 + - 예: 이벤트 유틸 함수, 주요 비즈니스 로직 등 기능 유형별 예시 설명 +- 기존 코드 베이스의 구조와 일관성을 유지하며 불필요한 중복 구현을 지양합니다. +- 코드에는 주요 분기점, 복잡 로직, 외부 영향 구간에 대한 설명 주석을 추가합니다. +- 각 함수, 컴포넌트에는 JSDoc 등 형식으로 타입/목적/사용 예시 주석을 적극적으로 작성합니다. +- PR 작성 시 기능별 요약, 변경점, 테스트 결과·커버리지를 명확히 설명합니다. + +## 3. 실행 프로세스 +1. 테스트 케이스 분석: 기능 요구사항 및 expect 조건 완벽 파악 +2. 구현 코드 작성: 기존 코드와 호환되게 실제 코드를 작성 +3. 테스트 실행: 모든 관련 테스트를 실행하여 통과 여부 확인 +4. 테스트 통과 후 코드에 대한 설명 작성 (무엇을, 왜, 어떻게 구현했는지) +5. Pull Request 또는 결과 제출 시 테스트 성공 결과를 첨부 +- 만약 테스트 실패 시, 실패 로그 분석 → 추가 구현/리팩터링 수행 → 재검증 반복 + +## 4. MCP/외부 문서 활용 기준 +- MCP: Context7, Docs, RepoStructure 등의 메타 컨텍스트 프레임워크를 최우선 참고 +- 공식 문서, 사내/팀 위키, 최신 외부 오픈소스 레퍼런스 순서 우선 활용 +- 필요시 코드 범위 내에서 예시, Docstring, Readme 등 자체 문서도 함께 참조 +- 외부 패키지/library는 코드베이스 규칙상 허용 범위 내에서만 도입 (임의 신규 도입 최소화) + +## 5. 예외 상황 지침 +- 테스트 자체에 오류 또는 불명확점이 있을 경우 TC, TT 에이전트 등과 협업하여 원인·명확화 요청 +- 명세가 불완전하거나 비즈니스 도메인 요구가 모호한 경우, 관련 담당자 또는 컨텍스트 MCP를 통해 추가 자료 요청 +- 테스트를 통과하지 못할 때는 코드가 아닌 테스트 기준(명세, 요건)에 문제가 없는지 우선 점검 → 필요시 사유 및 대응 방안을 주석 또는 설명에 남깁니다. diff --git a/.cursor/agents/heejae.md b/.cursor/agents/heejae.md new file mode 100644 index 00000000..2972cbd0 --- /dev/null +++ b/.cursor/agents/heejae.md @@ -0,0 +1,92 @@ +# heejae (Test Code Agent) + +## Name & Description +**Name**: heejae (Test Code Agent) +**Description**: heejae는 작성된 테스트 케이스를 실제 동작 가능한 테스트 코드로 완성하는 AI 에이전트입니다. +TDD 사이클 중 GREEN 단계의 역할을 담당하며, 이미 설계된 테스트 케이스를 기반으로 구체적인 `expect`, `mock`, `render`, `act` 등을 작성하여 테스트를 통과 가능한 상태로 만듭니다. + +--- + +## 역할 +- jinsung이가 설계한 테스트 케이스를 바탕으로 실제 테스트 코드를 작성합니다. +- 주어진 테스트 환경(`vitest`, `@testing-library/react`, 등)에 맞춰 코드를 완성합니다. +- **TDD 사이클의 GREEN 단계**를 수행합니다. +- 기존 테스트 코드 스타일과 컨벤션을 유지합니다. + +--- + +## 특징 +- **실행 가능한 코드 작성** + - `expect` 구문, `act`, `render`, `mock` 등을 포함한 실제 테스트 로직을 작성합니다. +- **환경 인식형 동작** + - 프로젝트 내 공통 설정(`setupTests.ts`, `test-utils.ts`)을 자동으로 고려합니다. +- **안정성 중심 설계** + - 불필요한 DOM 접근, 무의미한 await, 과도한 mock을 지양합니다. +- **테스트의 의도 중심** + - 설계된 테스트의 “검증 목적”을 정확히 코드로 표현합니다. + +--- + +## 작업 원칙 + +### 기존 테스트 구조 활용 +- `setupTests.ts`, `test-utils.ts`, `renderHook`, `act`, `vi.useFakeTimers()` 등 프로젝트의 기존 테스트 환경을 반드시 재사용합니다. +- 불필요한 중복 초기화나 import는 금지합니다. +- 기존 테스트 코드 스타일(`describe`/`it` 네이밍, expect 방식 등)을 그대로 따릅니다. + +### TDD 단계 인식 +- heejae는 **TDD의 GREEN 단계**에서만 동작합니다. +- 구현이 필요한 부분이 있을 경우, 반드시 “테스트 기준에서 실패 원인”을 명시한 후 코멘트를 남깁니다. +- 테스트가 실패 중이면, 코드가 아닌 **테스트 로직 보완**으로 해결하는 것을 우선시합니다. + +### 코드 작성 지침 +- 모든 테스트에는 최소 한 개 이상의 `expect` 구문이 포함되어야 합니다. +- 비동기 테스트는 `await` 또는 `waitFor`를 사용해야 합니다. +- React 컴포넌트 테스트는 `render()` 이후 `screen` API를 사용해 검증합니다. +- 상태 변화는 반드시 `act()`로 감싸야 합니다. +- Mock 데이터는 실제 데이터 구조를 기반으로 설계하며, `vi.fn()`으로 함수 모킹을 수행합니다. + +### 본 에이전트는 docs/kent-beck-tdd.md의 내용을 기반으로 테스트 코드를 작성합니다. +특히 다음 항목을 핵심적으로 해석합니다. +- Red 단계 : 실패하는 테스트를 구현하되, “의도적으로 실패하도록” 작성합니다. 즉, Green 상태가 아님을 보장합니다. +- 테스트 코드는 명확성, 간결성, 일관성을 최우선으로 작성합니다. 불필요한 mock, 변수, 주석을 피합니다. +- it('무엇을 해야 한다') 형태로 행동 기반(BDD) 네이밍을 따릅니다. + +--- + +## TIP: 테스트 품질 철학 +heejae는 단순히 테스트를 “통과시키는” 것이 아니라, **“의미 있는 테스트”** 를 작성해야 합니다. +다음 기준을 항상 고려하세요: + +- **안티패턴 방지** + - “결과만 검증하는 테스트”, “의미 없는 snapshot”, “mock에만 의존한 테스트”는 지양합니다. +- **테스트 명세 충실** + - jinsung이가 정의한 테스트 명세에 누락된 검증 항목이 없는지 확인합니다. +- **테스트 철학 반영** + - Kent Beck, Testing Library 철학을 참고해 “사용자 관점에서 검증하는 테스트”를 지향합니다. + - 예: “컴포넌트의 내부 상태”보다는 “화면에서 보이는 변화”를 우선 검증. + +--- + +## 출력 결과 +heejae의 결과물은 **실행 가능한 테스트 코드**입니다. + +- 입력: jinsung이가 설계한 테스트 케이스 (비어 있는 구조) +- 출력: 완성된 테스트 코드 (실제 동작 가능) + +### 예시 +```tsx +// 입력 (jinsung이가 설계한 케이스) +it('반복 일정은 반복 아이콘(🔁)이 표시된다', () => { + // 반복 일정에만 아이콘 렌더링 +}); + +// 출력 (heejae가 작성한 코드) +it('반복 일정은 반복 아이콘(🔁)이 표시된다', async () => { + render(); + expect(screen.getByText('회의')).toBeInTheDocument(); + expect(screen.getByLabelText('반복 아이콘')).toBeVisible(); +}); + +### 지켜야 할 규칙 +dasom 이 바로 이어서 코드를 작성하면 통과해야 한다. diff --git a/.cursor/agents/jinsung.md b/.cursor/agents/jinsung.md new file mode 100644 index 00000000..c04eb6e7 --- /dev/null +++ b/.cursor/agents/jinsung.md @@ -0,0 +1,128 @@ +# jinsung (Test Designer Agent) + +## Name & Role & Focus +**Name:** jinsung (Test Designer Agent) +**Role:** 명세 기반 테스트 설계 에이전트 +**description:** 사용자가 입력한 문장만 literal하게 해석하고, 어떤 의미적 확장이나 명세 기반 보강도 수행하지 않는 테스트 설계 전용 에이전트. +**Focus:** TDD(Test-Driven Development) 관점에서 “무엇을 테스트할 것인가” 를 정의하는 단계 수행 + +jinsung은 테스트를 직접 작성하지 않습니다. +대신 테스트의 목적, 시나리오, 기대값, 구조(describe/it) 등을 설계합니다. +이후 TC(Test Code Agent) 가 jinsung의 설계 내용을 바탕으로 실제 테스트 코드를 작성합니다. + +--- + +## 작동 방식 + +1. 사용자가 테스트를 설계하고 싶은 **명세 문서**를 지정합니다. + 예: `/specs/prd.md` + +2. 사용자가 구체적으로 요청합니다. 입력된 문장만 그대로 반영합니다. +예: +"1. 반복 유형 선택" 테스트 설계해줘 +"3. 반복 일정 수정 케이스" 도 추가해줘 + +3. jinsung은 다음을 수행합니다. +- 명세 항목 분석 +- 해당 항목의 테스트 목적 정의 +- `describe` / `it` 구조 제안 +- 각 테스트에 대한 구체적인 **기댓값(expect)** 포함 +- 기존 프로젝트의 테스트 패턴(setupTests.ts 등) 준수 + +4. jinsung은 한 번에 모든 테스트를 제시하지 않습니다. +- 사용자가 "2번도 만들어줘", "3번도 확장해줘" 라고 하면 + → 그때마다 해당 항목만 독립적으로 설계합니다. +- 즉, **단계적(Iterative)** 으로 테스트 설계가 진행됩니다. + +5. 본 에이전트는 docs/kent-beck-tdd.md의 내용을 기반으로 테스트 설계를 수행합니다. +특히 다음 항목을 핵심적으로 해석합니다 +5-1. TDD의 핵심 “테스트를 먼저 작성한다(Test First)” 원칙을 기반으로, 구현보다 테스트 설계를 우선시합니다. +5-2. 기본 원칙 — 작게, 자주, 명확하게 하나의 명세마다 작은 단위 테스트를 설계하며, describe/it 구조를 행동 중심으로 작성합니다. + +참고: +테스트 설계는 “Red” 단계의 일부로 간주됩니다. +즉, 진성이는 코드 대신 실패를 정의하는 역할을 합니다. + +--- + +## 테스트 설계 원칙 + +### 1 명세 기반 +- 테스트는 반드시 지정된 명세 문서의 범위 내에서만 설계됩니다. +- 명세에 없는 기능이나 추가 구현은 제안하지 않습니다. + +### 2 TDD 관점 +- 테스트 설계는 구현보다 우선합니다. +- “무엇을 검증해야 하는가?” → “어떻게 검증할 것인가?” 순으로 접근합니다. +- 테스트의 목적을 분명히 서술합니다. + +### 3 구체적 기대값 +- 각 `it` 문에는 최소 하나 이상의 `expect` 문이 포함되어야 합니다. +- UI 요소의 존재, 함수 결과값, 상태 변화 등 **검증 가능한 구체적 기준**으로 작성합니다. + +### 4 레드 케이스 포함 +- 모든 기능에는 최소 한 개 이상의 “실패 시나리오(레드 케이스)”를 포함합니다. +- 예: 잘못된 입력, 누락된 필드, 잘못된 상태, 비정상적인 조건 등 +- 단, 실제 구현을 망가뜨리는 코드 제안은 절대 하지 않습니다. + +### 5 일관성 유지 +- 기존 테스트 구조(`setupTests.ts`, `renderHook`, `act`, `vi.useFakeTimers()` 등)를 재사용합니다. +- 프로젝트 컨벤션(`describe` / `it` / `expect`) 형식을 따릅니다. +- 기존 테스트 명명 규칙을 유지합니다. + +--- + +## jinsung 대화 흐름 예시 + +> **사용자:** +> jinsung아, `/specs/prd.md` 문서에서 “1. 반복 유형 선택”에 대한 테스트를 설계해줘. + +> **jinsung:** +> “반복 유형 선택” 기능은 일정 생성 또는 수정 시 반복 옵션(매일, 매주, 매월, 매년)을 설정할 수 있어야 합니다. +> 아래는 해당 기능에 대한 테스트 설계입니다. + +```ts +describe('반복 일정 기능 - 반복 유형 선택', () => { +it('일정 생성/수정 시 반복 유형 옵션이 노출된다', () => { +}); + +it('31일에 "매월" 반복을 설정하면 31일에만 반복 일정이 생성된다', () => { +}); + +it('윤년 2월 29일에 "매년" 반복을 설정하면 윤년에만 생성된다', () => { + +}); +}); + +## 작업 가이드라인 + +jinsung는 다음 규칙을 반드시 준수해야 합니다. + +**기존 테스트 작성 방식 준수** +- 프로젝트에 이미 존재하는 테스트 구조(`setupTests.ts`, `renderHook`, `act`, `vi.useFakeTimers()` 등)를 재사용합니다. +- 중복된 설정이나 초기화 코드는 작성하지 않습니다. +- 동일한 테스트 환경 구성을 유지해야 합니다. + +**TDD 관점의 테스트 설계** +- 테스트 설계는 TDD의 한 과정입니다. +- 항상 “구현보다 테스트를 먼저” 작성합니다. +- 테스트 명세(`describe`, `it`)는 구체적이고 명확해야 하며, + 실제 구현 시도를 유도할 만큼 상세하게 작성해야 합니다. + +**참조 문서 활용** +- jinsung은 `/specs/` 디렉토리 내 명세 문서를 참고하여 테스트를 설계합니다. +- 명세에서 벗어나는 기능은 제안하지 않습니다. + +**작업 범위 제한** +- jinsung은 테스트 코드만 작성하며, 구현 코드 수정은 금지됩니다. +- 명세에 정의된 기능 범위 내에서만 테스트를 설계해야 합니다. +- 과한 수정이나 기능 확장은 경계합니다. + +**출력 결과 형식** +- 결과물은 다음 중 하나입니다: + - 테스트 케이스가 채워진 신규 테스트 파일 + - 기존 테스트 파일에 추가되는 테스트 케이스 +- 모든 테스트는 `describe` / `it` 구조를 따르며 테스트 설계 단계에서는 describe/it 구조만 작성 +- 실제 click, input, expect 등 테스트 구현 내용은 포함하지 않음 +- 테스트 본문은 작성하지 말아줘 + diff --git a/.cursor/docs/heejae/mui_extension.md b/.cursor/docs/heejae/mui_extension.md new file mode 100644 index 00000000..f298a18a --- /dev/null +++ b/.cursor/docs/heejae/mui_extension.md @@ -0,0 +1,113 @@ +# heejae MUI Extension + +> **목적:** +> heejae가 Material UI(MUI) 기반 컴포넌트의 테스트 코드를 작성할 때 +> 더 정확하고 사용자 친화적인 로직을 구현할 수 있도록 돕는 확장 규칙이다. +> +> 이 문서는 TDD 사이클 중 **GREEN 단계**에서 적용된다. + +--- + +## 1. 기본 원칙 + +- MUI 테스트는 **구조가 아닌 사용자 경험(UI interaction)** 을 검증해야 한다. +- DOM 구조(`.MuiButtonBase-root`)나 CSS 클래스 접근은 금지한다. +- 오직 **role, aria-label, text, data-testid** 를 기준으로 선택한다. +- heejae는 항상 `@testing-library/react` 와 `userEvent` 중심으로 작성한다. + +**좋은 예** +```tsx +expect(screen.getByRole('button', { name: '저장' })).toBeEnabled(); + +나쁜 예 + +// 내부 클래스 구조를 테스트하면 MUI 버전 업 시 깨질 수 있음 +expect(container.querySelector('.MuiButtonBase-root')).toBeTruthy(); + +## 2. 기본 원칙컴포넌트별 테스트 패턴 +2-1. Button + +버튼의 활성/비활성, 클릭 반응, 텍스트 렌더링 검증에 집중 + +toBeEnabled(), toBeDisabled(), toHaveTextContent() 사용 + +const button = screen.getByRole('button', { name: '저장' }); +expect(button).toBeEnabled(); +await userEvent.click(button); +expect(handleSave).toHaveBeenCalled(); + +2-2. TextField / Input + +placeholder, label, value, error 메시지를 기준으로 검증 + +getByLabelText, getByPlaceholderText 우선 사용 + +const input = screen.getByLabelText('이메일'); +await userEvent.type(input, 'test@example.com'); +expect(input).toHaveValue('test@example.com'); + +2-3. Select + +userEvent.click으로 열고, 옵션 선택 후 UI 반영 검증 + +role="option" 또는 getByText로 옵션 선택 + +await userEvent.click(screen.getByRole('button', { name: '반복 유형' })); +await userEvent.click(screen.getByRole('option', { name: '매일' })); +expect(screen.getByRole('button', { name: '매일' })).toBeInTheDocument(); + +2-4. Dialog / Modal + +열린 상태(toBeInTheDocument)와 닫힘 상태(not.toBeInTheDocument)를 검증 + +aria-labelledby, aria-describedby 가 있다면 이를 적극 활용 + +await userEvent.click(screen.getByRole('button', { name: '삭제' })); +expect(screen.getByRole('dialog', { name: '삭제 확인' })).toBeInTheDocument(); + +await userEvent.click(screen.getByRole('button', { name: '취소' })); +await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); +}); + +2-5. Snackbar / Alert + +메시지 텍스트 기반으로 노출 여부 확인 + +setTimeout 등 비동기 동작은 await waitFor 로 감싸기 + +await userEvent.click(screen.getByRole('button', { name: '저장' })); +await waitFor(() => { + expect(screen.getByText('저장되었습니다')).toBeInTheDocument(); +}); + +3. 접근성(A11y) 원칙 + +getByRole과 getByLabelText를 항상 우선 사용 + +aria-label, aria-labelledby, aria-describedby를 테스트 기준으로 포함 + +data-testid는 최후의 수단으로만 사용 + +// 좋은 예 +expect(screen.getByRole('textbox', { name: '이름' })).toHaveValue('정민'); + +// 최소한으로 허용 +expect(screen.getByTestId('name-input')).toHaveValue('정민'); + +4. async 처리 원칙 + +MUI 컴포넌트는 내부 transition, animation, timeout이 많다. + +따라서 모든 UI 변경 검증은 await waitFor 로 감싸는 것을 기본으로 한다. + +await userEvent.click(screen.getByRole('button', { name: '삭제' })); +await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); +}); + +5. snapshot은 지양 + +MUI의 DOM 구조는 버전마다 달라지므로 snapshot 테스트는 불안정하다. + +대신 사용자 시나리오 기반 검증으로 대체한다. \ No newline at end of file diff --git a/.cursor/docs/kent-beck-tdd.md b/.cursor/docs/kent-beck-tdd.md new file mode 100644 index 00000000..08945bf8 --- /dev/null +++ b/.cursor/docs/kent-beck-tdd.md @@ -0,0 +1,60 @@ +# TDD 가이드 — Kent Beck의 원칙을 실무에 적용하기 + +> 이 문서는 Kent Beck의 TDD 철학(레드-그린-리팩터)을 실무에 적용하기 위한 실전 가이드입니다. +> React + Vitest 환경을 기준으로 예시와 체크리스트, AI 프롬프트 템플릿까지 포함합니다. + +--- + +## 1. TDD의 핵심 (한 문장 요약) +- **테스트를 먼저 작성한다(Test First)** → 실패 확인(Red) → 최소 구현(Green) → 깨끗하게 다듬는다(Refactor). + +Kent Beck은 작은 단계로 자주 실패를 만들고, 그 실패를 빠르게 지나가며 시스템을 안전하게 진화시키는 것을 강조합니다. + +--- + +## 2. 기본 원칙 (켄트 벡 스타일) +1. **작게, 자주**: 한 번에 한 가지 행동만 바꾼다. (작은 테스트 → 작은 구현) +2. **빠르게**: 테스트는 매우 빨라야 한다. (수초 이내) +3. **명확하게**: 테스트 이름은 문서가 된다 — 행동(describe/it)을 그대로 쓴다. +4. **하나의 실패**: 한 번에 하나의 테스트만 실패하게 만들자. +5. **Refactor 안전성**: 테스트는 리팩터링의 안전망이다. 테스트가 있다면 마음껏 리팩터링하라. +6. **테스트는 문서**: 테스트는 API/행동에 관한 살아있는 문서이다. + +--- + +## 3. 실전 규칙 (구체적) +- **Red**: 실패하는 테스트를 작성한다. (작은 시나리오, 엣지 케이스 포함) +- **Green**: 테스트를 통과시키는 가장 단순한 구현을 작성한다. (하드코딩 허용하되 임시) +- **Refactor**: 중복 제거, 의미있는 함수/모듈 분리, 네이밍 개선. 모든 변경 후 테스트 통과 확인. +- **한 테스트는 한 주장(Assertion)**: 각 테스트는 가능한 한 하나의 주장(assertion)을 가짐. (복잡하면 내부 arrange/act/assert 분할) +- **테스트의 가독성**: 테스트는 읽는 사람(팀원, 미래의 나)을 위한 문서다. setup/teardown과 helper를 적절히 사용. +- **느슨한 결합**: 테스트는 내부 구현에 너무 의존하지 않도록 작성(흔히 검증 대상은 "동작"이다). +- **목(Mock) 남용 금지**: 필요한 경우에만 사용. 통합 수준 테스트는 실제 객체/유틸을 사용. + +--- + +## 4. 테스팅 스타일 가이드 (React + Vitest 권장) +- 테스트 툴: `vitest`, `@testing-library/react`, `@testing-library/user-event` +- 테스트 명명: + - `describe('Feature or Component')` + - `it('상황 설명 — 기대 행동')` +- 비동기/상태 변경: `userEvent.setup()` + `await` 또는 `await findBy...` / `waitFor` 사용 +- 접근성 권장: `getByRole`, `getByLabelText`, `getByTestId`(필요시) 순으로 사용 +- 테스트 전용 유틸: `test-utils.tsx`에 공통 render 래퍼(Providers, router, i18n 등)를 두자 + +--- + +## 5. 예시: 작은 TDD 사이클 (반복 유형 선택 기능) +### Red (테스트) +```ts +// tests/repeatType.test.tsx +it('일정 생성 시 반복 체크박스 클릭하면 반복 유형 Select가 표시된다 (RED)', async () => { + render(); + const checkbox = screen.getByLabelText('반복 일정'); + expect(checkbox).not.toBeChecked(); + + const user = userEvent.setup(); + await user.click(checkbox); + + await waitFor(() => expect(screen.getByText('반복 유형')).toBeInTheDocument()); +}); diff --git a/.cursor/specs/prd.md b/.cursor/specs/prd.md new file mode 100644 index 00000000..cbba69b5 --- /dev/null +++ b/.cursor/specs/prd.md @@ -0,0 +1,121 @@ +# 반복 일정 기능 명세 (`prd.md`) + +이 문서는 **반복 일정(Recurring Event)** 기능을 TDD 기반으로 개발할 때 +테스트 설계 에이전트(TT.md)가 참고해야 할 상세 명세를 정의합니다. +모든 테스트는 RED → GREEN → REFACTOR 사이클을 따릅니다. + +--- + +## 1. TDD 기본 원칙 + +1. **RED (실패 테스트 작성)** + - 명세에 정의된 동작을 기준으로 테스트를 먼저 작성합니다. + - 아직 기능이 없거나 실패해야 정상입니다. + +2. **GREEN (기능 구현)** + - 테스트를 통과시키기 위한 최소한의 코드만 작성합니다. + - 하드코딩이 허용됩니다. (테스트를 통과시키는 것이 우선) + +3. **REFACTOR (리팩토링)** + - 테스트가 통과된 이후에만 코드 개선을 진행합니다. + - 중복 제거, 의미 있는 네이밍, 구조 개선을 수행합니다. + +--- + +## 2. 기능 명세 + +### 2.1 반복 유형 선택 (Repeat Type Selection) + + - 일정 생성 또는 수정 시 반복 유형을 선택할 수 있다. + - 반복 유형은 다음과 같다: 매일, 매주, 매월, 매년 + - 31일에 매월을 선택한다면 → 매월 마지막이 아닌, 31일에만 생성하세요. + - 윤년 29일에 매년을 선택한다면 → 29일에만 생성하세요! + - 반복일정은 일정 겹침을 고려하지 않는다. + +**예외 규칙** +- 31일에 “매월”을 선택한 경우 → “매월 마지막”이 아닌, **31일에만 생성**되어야 한다. +- 윤년의 2월 29일에 “매년”을 선택한 경우 → **2월 29일에만 생성**되어야 한다. +- 반복 일정은 다른 일정과의 겹침을 고려하지 않는다. + +**TDD 가이드** +- RED: “반복 유형을 선택할 수 있다” 테스트 작성 +- GREEN: 반복 유형 로직 구현 +- REFACTOR: 반복일 계산 로직을 `getNextRepeatDate()` 유틸로 분리 + +--- + +### 2.2 반복 일정 표시 (Repeat Display) + +- 캘린더 뷰에서 반복 일정은 **반복 아이콘(🔁)** 으로 표시되어야 한다. +- 단일 일정은 아이콘이 표시되지 않는다. + +**TDD 가이드** +- RED: “반복 일정은 반복 아이콘을 표시한다” 테스트 작성 +- GREEN: 조건부 렌더링 구현 +- REFACTOR: 반복 여부를 확인하는 로직을 `isRepeating()` 헬퍼로 분리 + +--- + +### 2.3 반복 종료 조건 (Repeat End) + +- 반복 종료 조건을 지정할 수 있다. +- 옵션: 특정 날짜(`until`) + - 예: `2025-12-31`까지 반복 +- 종료일 이후에는 반복이 생성되지 않아야 한다. + +**TDD 가이드** +- RED: “2025-12-31 이후 일정은 생성되지 않는다” 테스트 작성 +- GREEN: `endDate` 체크 로직 구현 +- REFACTOR: 반복 생성 로직을 `generateRepeatsUntil()` 함수로 분리 + +--- + +### 2.4 반복 일정 수정 (Repeat Edit) + +- 반복 일정 수정 시 “해당 일정만 수정하시겠어요?” 라는 메시지가 표시되어야 한다. + +#### 2.4.1 ‘예’를 누르는 경우 (단일 수정) +- 해당 일정만 수정된다. +- 해당 일정은 **반복 일정이 아닌 단일 일정으로 변경**된다. +- 반복 아이콘이 제거되어야 한다. + +#### 2.4.2 ‘아니오’를 누르는 경우 (전체 수정) +- 동일한 반복 그룹의 모든 일정이 수정된다. +- 반복 아이콘은 그대로 유지된다. + +**TDD 가이드** +- RED: “예 선택 시 단일 수정이 된다” 테스트 작성 +- GREEN: 단일 수정 로직 구현 +- REFACTOR: 수정 모달 및 반복 관리 로직 분리 + +--- + +### 2.5 반복 일정 삭제 (Repeat Delete) + +- 반복 일정 삭제 시 “해당 일정만 삭제하시겠어요?” 라는 메시지가 표시되어야 한다. + +#### 2.5.1 ‘예’를 누르는 경우 (단일 삭제) +- 선택한 일정만 삭제된다. + +#### 2.5.2 ‘아니오’를 누르는 경우 (전체 삭제) +- 동일한 반복 그룹의 모든 인스턴스가 삭제된다. + +**TDD 가이드** +- RED: “예 선택 시 단일 삭제 테스트 작성” +- GREEN: 삭제 로직 구현 +- REFACTOR: `deleteSingleOrAll()` 함수로 로직 통합 + +--- + +## 3. AI 개발 시 주의사항 + +- 반드시 테스트부터 작성해야 합니다. (RED 우선) +- 명세에 없는 기능은 추가하지 않습니다. +- 테스트 설명(`describe`, `it`)은 한글로 작성합니다. +- 테스트 함수명은 명세 문장 그대로 작성해야 합니다. + 예: `"31일에 매월을 선택하면 31일에만 생성된다"` +- 테스트 프레임워크는 `vitest` 또는 `@testing-library/react`를 사용합니다. +- 상태 관리는 React의 `useState`, `useEffect`를 사용합니다. +- 날짜 연산은 `dayjs` 사용을 기본으로 합니다. + +--- \ No newline at end of file diff --git a/cursor-rules.md b/cursor-rules.md new file mode 100644 index 00000000..29faf382 --- /dev/null +++ b/cursor-rules.md @@ -0,0 +1,30 @@ +# 🧭 Cursor 개발 규칙 + +이 문서는 Cursor 내 모든 AI 개발 에이전트가 따라야 하는 공통 규칙을 정의합니다. + +--- + +## 👩‍💻 공통 개발 원칙 + +1. 모든 기능 개발은 TDD 기반으로 진행해야 합니다. + (테스트 코드 작성 → 테스트 통과 → 리팩토링) +2. 기능 명세는 `.cursor/agents` 또는 프로젝트 내 명세 문서를 통해 제공됩니다. +3. 명세 외의 기능은 임의로 추가하지 않습니다. +4. 테스트 명세와 실제 구현은 반드시 **일대일 대응**되어야 합니다. +5. `TT.md`(테스트 설계 에이전트)는 테스트를 설계하고 작성하는 역할만 수행합니다. + 실제 로직 구현은 개발 에이전트가 담당합니다. + +--- + +## 🧠 TDD 사이클 규칙 + +- **RED**: 실패하는 테스트를 먼저 작성합니다. +- **GREEN**: 테스트를 통과시키는 최소한의 코드를 작성합니다. +- **REFACTOR**: 코드 품질 개선. 테스트는 항상 통과 상태 유지. + +--- + +## 📂 프로젝트 내 명세 문서 위치 + +- `/agents/TT.md` → 테스트 설계 에이전트 (TDD 중심 테스트 작성) +- `/specs/recurring-events.md` → 반복 일정 기능 명세 diff --git a/package.json b/package.json index 73d85b72..1f64aec8 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "concurrently": "^8.2.2", "eslint": "^9.30.0", "eslint-config-prettier": "^10.1.5", + "eslint-plugin-cypress": "^5.2.0", "eslint-plugin-import": "^2.32.0", "eslint-plugin-prettier": "^5.5.1", "eslint-plugin-react": "^7.37.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3848a91..e458dd7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: eslint-config-prettier: specifier: ^10.1.5 version: 10.1.5(eslint@9.30.0) + eslint-plugin-cypress: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.30.0) eslint-plugin-import: specifier: ^2.32.0 version: 2.32.0(@typescript-eslint/parser@8.35.0(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0) @@ -1607,6 +1610,11 @@ packages: eslint-import-resolver-webpack: optional: true + eslint-plugin-cypress@5.2.0: + resolution: {integrity: sha512-vuCUBQloUSILxtJrUWV39vNIQPlbg0L7cTunEAzvaUzv9LFZZym+KFLH18n9j2cZuFPdlxOqTubCvg5se0DyGw==} + peerDependencies: + eslint: '>=9' + eslint-plugin-import@2.32.0: resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} engines: {node: '>=4'} @@ -4928,6 +4936,11 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-cypress@5.2.0(eslint@9.30.0): + dependencies: + eslint: 9.30.0 + globals: 16.3.0 + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.0(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0): dependencies: '@rtsao/scc': 1.1.0 diff --git a/src/App.tsx b/src/App.tsx index 195c5b05..f2175771 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,12 @@ -import { Notifications, ChevronLeft, ChevronRight, Delete, Edit, Close } from '@mui/icons-material'; +import { + Notifications, + ChevronLeft, + ChevronRight, + Delete, + Edit, + Close, + Repeat, +} from '@mui/icons-material'; import { Alert, AlertTitle, @@ -35,12 +43,13 @@ import { useEventForm } from './hooks/useEventForm.ts'; import { useEventOperations } from './hooks/useEventOperations.ts'; import { useNotifications } from './hooks/useNotifications.ts'; import { useSearch } from './hooks/useSearch.ts'; -// import { Event, EventForm, RepeatType } from './types'; -import { Event, EventForm } from './types'; +import { Event, EventForm, RepeatType } from './types'; +// import { Event, EventForm } from './types'; import { formatDate, formatMonth, formatWeek, + generateRepeatDates, getEventsForDay, getWeekDates, getWeeksAtMonth, @@ -77,11 +86,11 @@ function App() { isRepeating, setIsRepeating, repeatType, - // setRepeatType, + setRepeatType, repeatInterval, - // setRepeatInterval, + setRepeatInterval, repeatEndDate, - // setRepeatEndDate, + setRepeatEndDate, notificationTime, setNotificationTime, startTimeError, @@ -92,10 +101,15 @@ function App() { handleEndTimeChange, resetForm, editEvent, + editMode, + setEditMode, + isEditModeDialogOpen, + setIsEditModeDialogOpen, } = useEventForm(); - const { events, saveEvent, deleteEvent } = useEventOperations(Boolean(editingEvent), () => - setEditingEvent(null) + const { events, saveEvent, deleteEvent, fetchEvents } = useEventOperations( + Boolean(editingEvent), + () => setEditingEvent(null) ); const { notifications, notifiedEvents, setNotifications } = useNotifications(events); @@ -104,9 +118,50 @@ function App() { const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); const [overlappingEvents, setOverlappingEvents] = useState([]); + const [isDeleteModeDialogOpen, setIsDeleteModeDialogOpen] = useState(false); + const [eventToDelete, setEventToDelete] = useState(null); const { enqueueSnackbar } = useSnackbar(); + /** + * 반복 일정 편집 모드 선택 처리 + * @param mode 'single': 해당 일정만 수정 (반복 제거), 'full': 전체 반복 일정 수정 + */ + const handleEditModeChoice = (mode: 'single' | 'full') => { + setIsEditModeDialogOpen(false); + // 모드만 설정하고, "일정 수정" 버튼 클릭 시 실제 저장 + setEditMode(mode); + }; + + const handleDeleteModeChoice = async (mode: 'single' | 'full') => { + setIsDeleteModeDialogOpen(false); + + if (!eventToDelete) return; + + try { + if (mode === 'single') { + const response = await fetch('/api/events/' + eventToDelete.id, { method: 'DELETE' }); + if (!response.ok) { + throw new Error('Failed to delete event'); + } + } else { + const response = await fetch('/api/recurring-events/' + eventToDelete.repeat.id, { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error('Failed to delete recurring events'); + } + } + + await fetchEvents(); + enqueueSnackbar('일정이 삭제되었습니다.', { variant: 'info' }); + setEventToDelete(null); + } catch (error) { + console.error('Error deleting event:', error); + enqueueSnackbar('일정 삭제 실패', { variant: 'error' }); + } + }; + const addOrUpdateEvent = async () => { if (!title || !date || !startTime || !endTime) { enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); @@ -118,6 +173,73 @@ function App() { return; } + // 반복 일정을 편집하는 경우 + if (editingEvent && editingEvent.repeat.type !== 'none') { + // editMode가 'single'로 설정되었으면 해당 일정만 수정 (반복 제거) + if (editMode === 'single') { + const eventData: Event | EventForm = { + id: editingEvent.id, + title, + date, + startTime, + endTime, + description, + location, + category, + repeat: { type: 'none', interval: 1 }, // 반복 제거 + notificationTime, + }; + await saveEvent(eventData); + resetForm(); + return; + } + + // editMode가 'full'로 설정되었으면 전체 반복 일정 수정 + if (editMode === 'full') { + try { + // 반복 일정 시리즈 전체 수정 API 호출 + const updateData = { + title, + startTime, + endTime, + description, + location, + category, + notificationTime, + repeat: { + type: editingEvent.repeat.type, + interval: editingEvent.repeat.interval, + endDate: editingEvent.repeat.endDate || undefined, + }, + }; + + const response = await fetch(`/api/recurring-events/${editingEvent.repeat.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData), + }); + + if (!response.ok) { + throw new Error('Failed to update recurring events'); + } + + // 이벤트 목록 다시 불러오기 + await fetchEvents(); + enqueueSnackbar('반복 일정이 수정되었습니다.', { variant: 'success' }); + resetForm(); + return; + } catch (error) { + console.error('Error updating recurring events:', error); + enqueueSnackbar('반복 일정 수정 실패', { variant: 'error' }); + return; + } + } + + // editMode가 설정되지 않았으면 모달 표시 + setIsEditModeDialogOpen(true); + return; + } + const eventData: Event | EventForm = { id: editingEvent ? editingEvent.id : undefined, title, @@ -140,8 +262,46 @@ function App() { setOverlappingEvents(overlapping); setIsOverlapDialogOpen(true); } else { - await saveEvent(eventData); - resetForm(); + // 반복 일정이고 편집하지 않는 경우, 여러 인스턴스 생성 + if (isRepeating && !editingEvent) { + const repeatDates = generateRepeatDates( + date, + repeatType, + repeatInterval, + repeatEndDate || undefined + ); + + const repeatingEvents = repeatDates.map((repeatDate) => ({ + ...eventData, + date: repeatDate, + id: undefined, // 서버에서 생성 + })); + + // 반복 일정 저장 (여러 인스턴스) + try { + const response = await fetch('/api/events-list', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ events: repeatingEvents }), + }); + + if (!response.ok) { + throw new Error('Failed to save repeat events'); + } + + // 이벤트 목록 다시 불러오기 + await fetchEvents(); + enqueueSnackbar('일정이 추가되었습니다.', { variant: 'success' }); + resetForm(); + } catch (error) { + console.error('Error saving repeat events:', error); + enqueueSnackbar('일정 저장 실패', { variant: 'error' }); + } + } else { + // 단일 일정 또는 편집 시 기존 로직 + await saveEvent(eventData); + resetForm(); + } } }; @@ -437,12 +597,12 @@ function App() { - {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} - {/* {isRepeating && ( + {isRepeating && ( - 반복 유형 + 반복 유형